mirror of
https://github.com/doum1004/llmwiki-cli.git
synced 2026-05-19 09:13:36 +02:00
Phase 2: Read + Write + List + Search
Add core page operations and text search: - wiki read/write/append for page CRUD via stdin/stdout - wiki list with --tree and --json output - wiki search with term-frequency scoring, snippets, --all flag - WikiManager class and search engine in src/lib/ - 23 new tests (46 total)
This commit is contained in:
@@ -85,7 +85,7 @@ bun run typecheck # TypeScript check
|
||||
|
||||
See `docs/phase-{1-5}.md` for detailed tracking. Current status:
|
||||
- Phase 1: **COMPLETE** — init, registry, use
|
||||
- Phase 2: Not started — read, write, append, list, search
|
||||
- Phase 2: **COMPLETE** — read, write, append, list, search
|
||||
- Phase 3: Not started — index, log, commit, history, diff
|
||||
- Phase 4: Not started — lint, links, backlinks, orphans, status
|
||||
- Phase 5: Not started — auth, repo, push, pull, sync
|
||||
|
||||
12
bin/wiki.ts
12
bin/wiki.ts
@@ -4,6 +4,11 @@ import { Command } from "commander";
|
||||
import { makeInitCommand } from "../src/commands/init.ts";
|
||||
import { makeRegistryCommand } from "../src/commands/registry.ts";
|
||||
import { makeUseCommand } from "../src/commands/use.ts";
|
||||
import { makeReadCommand } from "../src/commands/read.ts";
|
||||
import { makeWriteCommand } from "../src/commands/write.ts";
|
||||
import { makeAppendCommand } from "../src/commands/append.ts";
|
||||
import { makeListCommand } from "../src/commands/list.ts";
|
||||
import { makeSearchCommand } from "../src/commands/search.ts";
|
||||
import { resolveWiki } from "../src/lib/resolver.ts";
|
||||
import type { GlobalOptions } from "../src/types.ts";
|
||||
|
||||
@@ -20,6 +25,13 @@ program.addCommand(makeInitCommand());
|
||||
program.addCommand(makeRegistryCommand());
|
||||
program.addCommand(makeUseCommand());
|
||||
|
||||
// Commands that require wiki resolution
|
||||
program.addCommand(makeReadCommand());
|
||||
program.addCommand(makeWriteCommand());
|
||||
program.addCommand(makeAppendCommand());
|
||||
program.addCommand(makeListCommand());
|
||||
program.addCommand(makeSearchCommand());
|
||||
|
||||
// Resolve wiki context for commands that need it
|
||||
const SKIP_RESOLUTION = new Set(["init", "registry", "use"]);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Phase 2: Read + Write + List + Search
|
||||
|
||||
**Status**: NOT STARTED
|
||||
**Status**: COMPLETE
|
||||
|
||||
**Goal**: LLM can read, write, and search wiki pages.
|
||||
|
||||
@@ -8,17 +8,17 @@
|
||||
|
||||
| File | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| `src/lib/wiki.ts` | Pending | WikiManager class: readPage, writePage, appendPage, listPages, pageExists |
|
||||
| `src/lib/search.ts` | Pending | Text search: term-frequency scoring, snippet extraction |
|
||||
| `src/commands/read.ts` | Pending | `wiki read <path>` — print page to stdout |
|
||||
| `src/commands/write.ts` | Pending | `wiki write <path>` — read stdin, write page |
|
||||
| `src/commands/append.ts` | Pending | `wiki append <path>` — read stdin, append to page |
|
||||
| `src/commands/list.ts` | Pending | `wiki list [dir] [--tree] [--json]` |
|
||||
| `src/commands/search.ts` | Pending | `wiki search <query> [--limit N] [--all] [--json]` |
|
||||
| `test/read-write.test.ts` | Pending | Read/write/append/list tests |
|
||||
| `test/search.test.ts` | Pending | Search scoring, snippets, limits |
|
||||
| `src/lib/wiki.ts` | Done | WikiManager class: readPage, writePage, appendPage, listPages, pageExists |
|
||||
| `src/lib/search.ts` | Done | Text search: term-frequency scoring, snippet extraction |
|
||||
| `src/commands/read.ts` | Done | `wiki read <path>` — print page to stdout |
|
||||
| `src/commands/write.ts` | Done | `wiki write <path>` — read stdin, write page |
|
||||
| `src/commands/append.ts` | Done | `wiki append <path>` — read stdin, append to page |
|
||||
| `src/commands/list.ts` | Done | `wiki list [dir] [--tree] [--json]` |
|
||||
| `src/commands/search.ts` | Done | `wiki search <query> [--limit N] [--all] [--json]` |
|
||||
| `test/read-write.test.ts` | Done | 15 tests passing |
|
||||
| `test/search.test.ts` | Done | 8 tests passing |
|
||||
|
||||
## Commands to Add
|
||||
## Commands Added
|
||||
|
||||
```
|
||||
wiki read <path>
|
||||
@@ -28,15 +28,17 @@ wiki list [dir] [--tree] [--json]
|
||||
wiki search <query> [--limit N] [--all] [--json]
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
## Tests
|
||||
|
||||
- `write` and `append` read from stdin (`process.stdin`)
|
||||
- `write` creates parent directories automatically
|
||||
- `list` uses glob to find all .md files
|
||||
- Search uses term-frequency scoring (upgradeable to BM25 later)
|
||||
- `--all` flag on search iterates all registered wikis
|
||||
- All structured output supports `--json`
|
||||
- 23 new tests (15 read-write + 8 search), all passing
|
||||
- 46 total tests across 3 files
|
||||
|
||||
## Entry Points to Update
|
||||
## Notes
|
||||
|
||||
- `bin/wiki.ts` — register new commands
|
||||
- `write` and `append` read from stdin via `process.stdin`
|
||||
- `write` auto-creates parent directories
|
||||
- `append` returns exit 1 if page doesn't exist
|
||||
- `list` supports `--tree` (visual tree) and `--json` output
|
||||
- Search uses term-frequency scoring with word-boundary matching
|
||||
- Search `--all` iterates all registered wikis
|
||||
- WikiManager normalizes paths to forward slashes on Windows
|
||||
|
||||
32
src/commands/append.ts
Normal file
32
src/commands/append.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Command } from "commander";
|
||||
import { WikiManager } from "../lib/wiki.ts";
|
||||
import type { WikiContext } from "../types.ts";
|
||||
|
||||
async function readStdin(): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf-8");
|
||||
}
|
||||
|
||||
export function makeAppendCommand(): Command {
|
||||
return new Command("append")
|
||||
.description("Append stdin to an existing page")
|
||||
.argument("<path>", "relative path to the page")
|
||||
.action(async function (this: Command, pagePath: string) {
|
||||
const ctx: WikiContext = this.optsWithGlobals().wikiContext;
|
||||
const content = await readStdin();
|
||||
if (!content) {
|
||||
console.error("No content provided on stdin.");
|
||||
process.exit(1);
|
||||
}
|
||||
const wiki = new WikiManager(ctx.root);
|
||||
const ok = await wiki.appendPage(pagePath, content);
|
||||
if (!ok) {
|
||||
console.error(`Page not found: ${pagePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`appended to ${pagePath}`);
|
||||
});
|
||||
}
|
||||
66
src/commands/list.ts
Normal file
66
src/commands/list.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Command } from "commander";
|
||||
import { WikiManager } from "../lib/wiki.ts";
|
||||
import type { WikiContext } from "../types.ts";
|
||||
|
||||
export function makeListCommand(): Command {
|
||||
return new Command("list")
|
||||
.description("List wiki pages")
|
||||
.argument("[dir]", "subdirectory to list")
|
||||
.option("--tree", "show as tree structure")
|
||||
.option("--json", "output as JSON array")
|
||||
.action(async function (
|
||||
this: Command,
|
||||
dir: string | undefined,
|
||||
options: { tree?: boolean; json?: boolean },
|
||||
) {
|
||||
const ctx: WikiContext = this.optsWithGlobals().wikiContext;
|
||||
const wiki = new WikiManager(ctx.root);
|
||||
const pages = await wiki.listPages(dir);
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(pages, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (pages.length === 0) {
|
||||
console.log("No pages found.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.tree) {
|
||||
printTree(pages);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const page of pages) {
|
||||
console.log(page);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function printTree(pages: string[]): void {
|
||||
const tree: Record<string, string[]> = {};
|
||||
for (const page of pages) {
|
||||
const parts = page.split("/");
|
||||
if (parts.length === 1) {
|
||||
const dir = ".";
|
||||
tree[dir] = tree[dir] ?? [];
|
||||
tree[dir]!.push(parts[0]!);
|
||||
} else {
|
||||
const dir = parts.slice(0, -1).join("/");
|
||||
const file = parts[parts.length - 1]!;
|
||||
tree[dir] = tree[dir] ?? [];
|
||||
tree[dir]!.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
const dirs = Object.keys(tree).sort();
|
||||
for (const dir of dirs) {
|
||||
console.log(`${dir}/`);
|
||||
const files = tree[dir]!;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const prefix = i === files.length - 1 ? " └── " : " ├── ";
|
||||
console.log(`${prefix}${files[i]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/commands/read.ts
Normal file
20
src/commands/read.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Command } from "commander";
|
||||
import { join } from "path";
|
||||
import { WikiManager } from "../lib/wiki.ts";
|
||||
import type { WikiContext } from "../types.ts";
|
||||
|
||||
export function makeReadCommand(): Command {
|
||||
return new Command("read")
|
||||
.description("Print page content to stdout")
|
||||
.argument("<path>", "relative path to the page")
|
||||
.action(async function (this: Command, pagePath: string) {
|
||||
const ctx: WikiContext = this.optsWithGlobals().wikiContext;
|
||||
const wiki = new WikiManager(ctx.root);
|
||||
const content = await wiki.readPage(pagePath);
|
||||
if (content === null) {
|
||||
console.error(`Page not found: ${pagePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(content);
|
||||
});
|
||||
}
|
||||
57
src/commands/search.ts
Normal file
57
src/commands/search.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Command } from "commander";
|
||||
import { WikiManager } from "../lib/wiki.ts";
|
||||
import { search } from "../lib/search.ts";
|
||||
import { loadRegistry } from "../lib/registry.ts";
|
||||
import type { WikiContext } from "../types.ts";
|
||||
import type { SearchResult } from "../lib/search.ts";
|
||||
|
||||
export function makeSearchCommand(): Command {
|
||||
return new Command("search")
|
||||
.description("Search wiki pages")
|
||||
.argument("<query>", "search terms")
|
||||
.option("-l, --limit <n>", "max results", "10")
|
||||
.option("-a, --all", "search across all registered wikis")
|
||||
.option("--json", "output as JSON")
|
||||
.action(async function (
|
||||
this: Command,
|
||||
query: string,
|
||||
options: { limit: string; all?: boolean; json?: boolean },
|
||||
) {
|
||||
const limit = parseInt(options.limit, 10);
|
||||
let results: (SearchResult & { wiki?: string })[] = [];
|
||||
|
||||
if (options.all) {
|
||||
const registry = await loadRegistry();
|
||||
for (const [id, entry] of Object.entries(registry.wikis)) {
|
||||
const wiki = new WikiManager(entry.path);
|
||||
const hits = await search(wiki, query, { limit });
|
||||
for (const hit of hits) {
|
||||
results.push({ ...hit, wiki: id });
|
||||
}
|
||||
}
|
||||
results.sort((a, b) => b.score - a.score);
|
||||
results = results.slice(0, limit);
|
||||
} else {
|
||||
const ctx: WikiContext = this.optsWithGlobals().wikiContext;
|
||||
const wiki = new WikiManager(ctx.root);
|
||||
results = await search(wiki, query, { limit });
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(results, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
console.log("No results found.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const r of results) {
|
||||
const prefix = r.wiki ? `[${r.wiki}] ` : "";
|
||||
console.log(`${prefix}${r.path} (score: ${r.score})`);
|
||||
console.log(` ${r.snippet}`);
|
||||
console.log();
|
||||
}
|
||||
});
|
||||
}
|
||||
28
src/commands/write.ts
Normal file
28
src/commands/write.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Command } from "commander";
|
||||
import { WikiManager } from "../lib/wiki.ts";
|
||||
import type { WikiContext } from "../types.ts";
|
||||
|
||||
async function readStdin(): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf-8");
|
||||
}
|
||||
|
||||
export function makeWriteCommand(): Command {
|
||||
return new Command("write")
|
||||
.description("Write stdin to a page (create or overwrite)")
|
||||
.argument("<path>", "relative path to the page")
|
||||
.action(async function (this: Command, pagePath: string) {
|
||||
const ctx: WikiContext = this.optsWithGlobals().wikiContext;
|
||||
const content = await readStdin();
|
||||
if (!content) {
|
||||
console.error("No content provided on stdin.");
|
||||
process.exit(1);
|
||||
}
|
||||
const wiki = new WikiManager(ctx.root);
|
||||
await wiki.writePage(pagePath, content);
|
||||
console.log(`wrote ${pagePath}`);
|
||||
});
|
||||
}
|
||||
79
src/lib/search.ts
Normal file
79
src/lib/search.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { WikiManager } from "./wiki.ts";
|
||||
|
||||
export interface SearchResult {
|
||||
path: string;
|
||||
score: number;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
export async function search(
|
||||
wiki: WikiManager,
|
||||
query: string,
|
||||
options?: { limit?: number; dir?: string },
|
||||
): Promise<SearchResult[]> {
|
||||
const limit = options?.limit ?? 10;
|
||||
const terms = query
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter((t) => t.length > 0);
|
||||
|
||||
if (terms.length === 0) return [];
|
||||
|
||||
const pages = await wiki.listPages(options?.dir);
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
for (const path of pages) {
|
||||
const content = await wiki.readPage(path);
|
||||
if (!content) continue;
|
||||
|
||||
const lower = content.toLowerCase();
|
||||
let score = 0;
|
||||
let firstIndex = -1;
|
||||
|
||||
for (const term of terms) {
|
||||
const regex = new RegExp(`\\b${escapeRegex(term)}\\b`, "gi");
|
||||
const matches = lower.match(regex);
|
||||
if (matches) {
|
||||
score += matches.length;
|
||||
if (firstIndex === -1) {
|
||||
firstIndex = lower.indexOf(term);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (score > 0) {
|
||||
const snippet = extractSnippet(content, firstIndex);
|
||||
results.push({ path, score, snippet });
|
||||
}
|
||||
}
|
||||
|
||||
results.sort((a, b) => b.score - a.score);
|
||||
return results.slice(0, limit);
|
||||
}
|
||||
|
||||
function extractSnippet(content: string, matchIndex: number): string {
|
||||
const snippetLen = 200;
|
||||
const half = Math.floor(snippetLen / 2);
|
||||
|
||||
let start = Math.max(0, matchIndex - half);
|
||||
let end = Math.min(content.length, matchIndex + half);
|
||||
|
||||
// Adjust to word boundaries
|
||||
if (start > 0) {
|
||||
const space = content.indexOf(" ", start);
|
||||
if (space !== -1 && space < start + 20) start = space + 1;
|
||||
}
|
||||
if (end < content.length) {
|
||||
const space = content.lastIndexOf(" ", end);
|
||||
if (space !== -1 && space > end - 20) end = space;
|
||||
}
|
||||
|
||||
let snippet = content.slice(start, end).replace(/\n+/g, " ").trim();
|
||||
if (start > 0) snippet = "..." + snippet;
|
||||
if (end < content.length) snippet = snippet + "...";
|
||||
return snippet;
|
||||
}
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
75
src/lib/wiki.ts
Normal file
75
src/lib/wiki.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { readFile, writeFile, mkdir, readdir, stat } from "fs/promises";
|
||||
import { join, relative, dirname } from "path";
|
||||
|
||||
export class WikiManager {
|
||||
constructor(public readonly root: string) {}
|
||||
|
||||
private resolve(relativePath: string): string {
|
||||
return join(this.root, relativePath);
|
||||
}
|
||||
|
||||
async readPage(relativePath: string): Promise<string | null> {
|
||||
try {
|
||||
return await readFile(this.resolve(relativePath), "utf-8");
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
"code" in err &&
|
||||
(err as NodeJS.ErrnoException).code === "ENOENT"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async writePage(relativePath: string, content: string): Promise<void> {
|
||||
const fullPath = this.resolve(relativePath);
|
||||
await mkdir(dirname(fullPath), { recursive: true });
|
||||
await writeFile(fullPath, content, "utf-8");
|
||||
}
|
||||
|
||||
async appendPage(relativePath: string, content: string): Promise<boolean> {
|
||||
const fullPath = this.resolve(relativePath);
|
||||
const existing = await this.readPage(relativePath);
|
||||
if (existing === null) return false;
|
||||
const separator = existing.endsWith("\n") ? "" : "\n";
|
||||
await writeFile(fullPath, existing + separator + content, "utf-8");
|
||||
return true;
|
||||
}
|
||||
|
||||
async pageExists(relativePath: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(this.resolve(relativePath));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async listPages(dir?: string): Promise<string[]> {
|
||||
const base = dir ? this.resolve(dir) : this.root;
|
||||
const results: string[] = [];
|
||||
await this.walkDir(base, results);
|
||||
results.sort();
|
||||
return results;
|
||||
}
|
||||
|
||||
private async walkDir(dir: string, results: string[]): Promise<void> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === ".git" || entry.name === "node_modules") continue;
|
||||
await this.walkDir(fullPath, results);
|
||||
} else if (entry.name.endsWith(".md")) {
|
||||
results.push(relative(this.root, fullPath).replace(/\\/g, "/"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
125
test/read-write.test.ts
Normal file
125
test/read-write.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdtemp, rm, writeFile, readFile, mkdir } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import { WikiManager } from "../src/lib/wiki.ts";
|
||||
|
||||
let testDir: string;
|
||||
let wiki: WikiManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await mkdtemp(join(tmpdir(), "llmwiki-rw-"));
|
||||
wiki = new WikiManager(testDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("WikiManager.writePage", () => {
|
||||
it("creates file with correct content", async () => {
|
||||
await wiki.writePage("test.md", "# Hello\n");
|
||||
const content = await readFile(join(testDir, "test.md"), "utf-8");
|
||||
expect(content).toBe("# Hello\n");
|
||||
});
|
||||
|
||||
it("creates parent directories automatically", async () => {
|
||||
await wiki.writePage("wiki/concepts/deep.md", "content");
|
||||
const content = await readFile(
|
||||
join(testDir, "wiki/concepts/deep.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).toBe("content");
|
||||
});
|
||||
|
||||
it("overwrites existing file", async () => {
|
||||
await wiki.writePage("page.md", "old");
|
||||
await wiki.writePage("page.md", "new");
|
||||
const content = await wiki.readPage("page.md");
|
||||
expect(content).toBe("new");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WikiManager.readPage", () => {
|
||||
it("returns file content", async () => {
|
||||
await writeFile(join(testDir, "hello.md"), "world", "utf-8");
|
||||
const content = await wiki.readPage("hello.md");
|
||||
expect(content).toBe("world");
|
||||
});
|
||||
|
||||
it("returns null for missing file", async () => {
|
||||
const content = await wiki.readPage("nonexistent.md");
|
||||
expect(content).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WikiManager.appendPage", () => {
|
||||
it("appends to existing file", async () => {
|
||||
await wiki.writePage("page.md", "line1\n");
|
||||
const ok = await wiki.appendPage("page.md", "line2\n");
|
||||
expect(ok).toBe(true);
|
||||
const content = await wiki.readPage("page.md");
|
||||
expect(content).toBe("line1\nline2\n");
|
||||
});
|
||||
|
||||
it("adds newline separator if missing", async () => {
|
||||
await wiki.writePage("page.md", "no-newline");
|
||||
await wiki.appendPage("page.md", "more");
|
||||
const content = await wiki.readPage("page.md");
|
||||
expect(content).toBe("no-newline\nmore");
|
||||
});
|
||||
|
||||
it("returns false for missing file", async () => {
|
||||
const ok = await wiki.appendPage("missing.md", "content");
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WikiManager.pageExists", () => {
|
||||
it("returns true for existing file", async () => {
|
||||
await wiki.writePage("exists.md", "hi");
|
||||
expect(await wiki.pageExists("exists.md")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for missing file", async () => {
|
||||
expect(await wiki.pageExists("nope.md")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WikiManager.listPages", () => {
|
||||
it("returns all .md files", async () => {
|
||||
await wiki.writePage("a.md", "a");
|
||||
await wiki.writePage("b.md", "b");
|
||||
await wiki.writePage("sub/c.md", "c");
|
||||
const pages = await wiki.listPages();
|
||||
expect(pages).toEqual(["a.md", "b.md", "sub/c.md"]);
|
||||
});
|
||||
|
||||
it("filters by subdirectory", async () => {
|
||||
await wiki.writePage("root.md", "r");
|
||||
await wiki.writePage("wiki/page.md", "p");
|
||||
await wiki.writePage("wiki/sub/deep.md", "d");
|
||||
const pages = await wiki.listPages("wiki");
|
||||
expect(pages).toEqual(["wiki/page.md", "wiki/sub/deep.md"]);
|
||||
});
|
||||
|
||||
it("returns empty array for empty dir", async () => {
|
||||
const pages = await wiki.listPages();
|
||||
expect(pages).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips non-.md files", async () => {
|
||||
await writeFile(join(testDir, "notes.txt"), "text", "utf-8");
|
||||
await wiki.writePage("page.md", "md");
|
||||
const pages = await wiki.listPages();
|
||||
expect(pages).toEqual(["page.md"]);
|
||||
});
|
||||
|
||||
it("returns sorted results", async () => {
|
||||
await wiki.writePage("z.md", "z");
|
||||
await wiki.writePage("a.md", "a");
|
||||
await wiki.writePage("m.md", "m");
|
||||
const pages = await wiki.listPages();
|
||||
expect(pages).toEqual(["a.md", "m.md", "z.md"]);
|
||||
});
|
||||
});
|
||||
87
test/search.test.ts
Normal file
87
test/search.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdtemp, rm } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import { WikiManager } from "../src/lib/wiki.ts";
|
||||
import { search } from "../src/lib/search.ts";
|
||||
|
||||
let testDir: string;
|
||||
let wiki: WikiManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await mkdtemp(join(tmpdir(), "llmwiki-search-"));
|
||||
wiki = new WikiManager(testDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("search", () => {
|
||||
it("finds pages containing query terms", async () => {
|
||||
await wiki.writePage("a.md", "The attention mechanism is important.");
|
||||
await wiki.writePage("b.md", "Unrelated content about cooking.");
|
||||
const results = await search(wiki, "attention");
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]!.path).toBe("a.md");
|
||||
});
|
||||
|
||||
it("ranks by term frequency", async () => {
|
||||
await wiki.writePage(
|
||||
"few.md",
|
||||
"Attention is used once in this document.",
|
||||
);
|
||||
await wiki.writePage(
|
||||
"many.md",
|
||||
"Attention attention attention. The attention mechanism uses attention.",
|
||||
);
|
||||
const results = await search(wiki, "attention");
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]!.path).toBe("many.md");
|
||||
expect(results[0]!.score).toBeGreaterThan(results[1]!.score);
|
||||
});
|
||||
|
||||
it("returns snippets around first match", async () => {
|
||||
await wiki.writePage("page.md", "Some preamble text. The key concept is transformers. More text after.");
|
||||
const results = await search(wiki, "transformers");
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]!.snippet).toContain("transformers");
|
||||
});
|
||||
|
||||
it("respects --limit", async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await wiki.writePage(`p${i}.md`, `This page mentions topic number ${i}.`);
|
||||
}
|
||||
const results = await search(wiki, "topic", { limit: 3 });
|
||||
expect(results).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("returns empty array for no matches", async () => {
|
||||
await wiki.writePage("page.md", "Some content here.");
|
||||
const results = await search(wiki, "nonexistent");
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles multi-word queries", async () => {
|
||||
await wiki.writePage("a.md", "Machine learning is a field of study.");
|
||||
await wiki.writePage("b.md", "This is about machine design only.");
|
||||
const results = await search(wiki, "machine learning");
|
||||
expect(results).toHaveLength(2);
|
||||
// "a.md" matches both terms, "b.md" matches only one
|
||||
expect(results[0]!.path).toBe("a.md");
|
||||
expect(results[0]!.score).toBeGreaterThan(results[1]!.score);
|
||||
});
|
||||
|
||||
it("is case insensitive", async () => {
|
||||
await wiki.writePage("page.md", "Attention and ATTENTION and attention.");
|
||||
const results = await search(wiki, "ATTENTION");
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]!.score).toBe(3);
|
||||
});
|
||||
|
||||
it("returns empty for empty query", async () => {
|
||||
await wiki.writePage("page.md", "content");
|
||||
const results = await search(wiki, " ");
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user