src / wikiSearch.ts
import * as path from "node:path";
import * as fs from "node:fs/promises";
import { BACKUP_DIR, WikiError, WikiLayout, ensureWikiInitialized } from "./wikiPaths";
export type SearchScope = "pages" | "sources" | "all";
export interface SearchArgs {
pattern: string;
scope?: SearchScope;
ignore_case?: boolean;
max_results?: number;
}
export interface SearchResult {
pattern: string;
scope: SearchScope;
matches: Array<{ kind: "page" | "source"; file: string; line: number; content: string }>;
files_scanned: number;
truncated: boolean;
}
export async function searchWiki(
layout: WikiLayout,
args: SearchArgs,
ctx: { maxSearchResults: number; maxSearchFiles: number },
): Promise<SearchResult> {
await ensureWikiInitialized(layout);
let regex: RegExp;
try {
regex = new RegExp(args.pattern, args.ignore_case ? "i" : "");
} catch (e) {
throw new WikiError(`Invalid regex: ${e instanceof Error ? e.message : String(e)}`);
}
const scope: SearchScope = args.scope ?? "all";
const limit = Math.min(args.max_results ?? ctx.maxSearchResults, ctx.maxSearchResults);
const fileLimit = ctx.maxSearchFiles;
const matches: SearchResult["matches"] = [];
let filesScanned = 0;
let truncated = false;
const targets: Array<{ dir: string; kind: "page" | "source" }> = [];
if (scope === "pages" || scope === "all") {
targets.push({ dir: layout.pagesDir, kind: "page" });
}
if (scope === "sources" || scope === "all") {
targets.push({ dir: layout.sourcesDir, kind: "source" });
}
for (const { dir, kind } of targets) {
if (matches.length >= limit || filesScanned >= fileLimit) break;
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
continue;
}
for (const e of entries) {
if (matches.length >= limit || filesScanned >= fileLimit) {
truncated = true;
break;
}
if (e.name === BACKUP_DIR) continue;
if (kind === "source" && e.name.endsWith(".meta.json")) continue;
if (!e.isFile()) continue;
const full = path.join(dir, e.name);
filesScanned++;
let content: string;
try {
const buf = await fs.readFile(full);
if (buf.includes(0)) continue;
content = buf.toString("utf-8");
} catch {
continue;
}
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
if (regex.test(lines[i])) {
matches.push({ kind, file: e.name, line: i + 1, content: lines[i].slice(0, 300) });
if (matches.length >= limit) {
truncated = true;
break;
}
}
}
}
}
return { pattern: args.pattern, scope, matches, files_scanned: filesScanned, truncated };
}