From 1df8f76312b041ac21467f1e67539256344ab7ec Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 9 Aug 2026 03:55:11 +0300 Subject: [PATCH] fix(cli): accept --path for scoped find Agent: vitruvius --- src/cli/find-path-cli.test.ts | 114 ++++++++++++++++++++++++++++++++++ src/cli/local.ts | 8 ++- src/lib/local/find.ts | 37 ++++++++--- 3 files changed, 150 insertions(+), 9 deletions(-) create mode 100644 src/cli/find-path-cli.test.ts diff --git a/src/cli/find-path-cli.test.ts b/src/cli/find-path-cli.test.ts new file mode 100644 index 0000000..d97ff81 --- /dev/null +++ b/src/cli/find-path-cli.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +interface CliResult { + stdout: string; + stderr: string; + exitCode: number; +} + +async function runCli(args: string[], env: Record): Promise { + const proc = Bun.spawn(["bun", "run", "src/cli/index.tsx", ...args], { + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, ...env }, + }); + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + await proc.exited; + return { stdout, stderr, exitCode: proc.exitCode ?? 0 }; +} + +function testEnv(dir: string): Record { + const indexDbPath = join(dir, "index.db"); + return { + SEARCH_DB_PATH: join(dir, "data.db"), + SEARCH_INDEX_DB_PATH: indexDbPath, + HASNA_SEARCH_INDEX_DB_PATH: indexDbPath, + }; +} + +describe("search find --path", () => { + test("advertises the option on the command that accepts it", async () => { + const dir = mkdtempSync(join(tmpdir(), "search-find-path-help-")); + try { + const result = await runCli(["find", "--help"], testEnv(dir)); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("-p, --path "); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("accepts the documented option without hiding an unconfigured index", async () => { + const dir = mkdtempSync(join(tmpdir(), "search-find-path-empty-")); + try { + const result = await runCli(["find", "executor", "--path", dir, "--json", "--no-refresh"], testEnv(dir)); + + expect(result.stderr).toBe(""); + expect(result.exitCode).not.toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + indexed: false, + roots: 0, + total: 0, + results: [], + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("limits results and reported root health to the requested indexed path", async () => { + const dir = mkdtempSync(join(tmpdir(), "search-find-path-scope-")); + const positiveRoot = join(dir, "positive"); + const negativeRoot = join(dir, "negative"); + mkdirSync(positiveRoot, { recursive: true }); + mkdirSync(negativeRoot, { recursive: true }); + writeFileSync(join(positiveRoot, "executor-positive.txt"), "executor lives here\n"); + writeFileSync(join(negativeRoot, "unrelated.txt"), "nothing relevant\n"); + const env = testEnv(dir); + + try { + const positiveAdded = await runCli(["index", "add", positiveRoot, "--json"], env); + const negativeAdded = await runCli(["index", "add", negativeRoot, "--json"], env); + expect(positiveAdded.exitCode).toBe(0); + expect(negativeAdded.exitCode).toBe(0); + + const positive = await runCli( + ["find", "executor", "--path", positiveRoot, "--json", "--no-refresh"], + env, + ); + expect(positive.stderr).toBe(""); + expect(positive.exitCode).toBe(0); + const positivePayload = JSON.parse(positive.stdout); + expect(positivePayload.indexed).toBe(true); + expect(positivePayload.roots).toBe(1); + expect(positivePayload.rootHealth).toEqual([ + expect.objectContaining({ path: positiveRoot, health: "ready" }), + ]); + expect(positivePayload.results).toHaveLength(1); + expect(positivePayload.results[0].path).toBe(join(positiveRoot, "executor-positive.txt")); + + const negative = await runCli( + ["find", "executor", "--path", negativeRoot, "--json", "--no-refresh"], + env, + ); + expect(negative.stderr).toBe(""); + expect(negative.exitCode).toBe(0); + const negativePayload = JSON.parse(negative.stdout); + expect(negativePayload.indexed).toBe(true); + expect(negativePayload.roots).toBe(1); + expect(negativePayload.rootHealth).toEqual([ + expect.objectContaining({ path: negativeRoot, health: "ready" }), + ]); + expect(negativePayload.total).toBe(0); + expect(negativePayload.results).toEqual([]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, 60_000); +}); diff --git a/src/cli/local.ts b/src/cli/local.ts index 0d9ae4c..a4fc333 100644 --- a/src/cli/local.ts +++ b/src/cli/local.ts @@ -109,6 +109,7 @@ export function registerLocalCommands(program: Command): void { .argument("", "What to look for") .option("-k, --kind ", "Match kind: file, content, both", "both") .option("-r, --root ", "Limit to one index root (name, path, or id)") + .option("-p, --path ", "Limit to one index root by filesystem path") .option("-e, --ext ", "Filter by file extension") .option("-d, --dir ", "Filter by directory substring") .option("-l, --limit ", "Max results", "20") @@ -120,11 +121,16 @@ export function registerLocalCommands(program: Command): void { .option("--verbose", "Show full paths, snippets, and match lines") .action((queryParts: string[], opts) => { const query = queryParts.join(" "); + if (opts.root && opts.path && opts.root !== opts.path) { + console.error(chalk.red("Error: use either --root or --path, not both")); + process.exitCode = 1; + return; + } let response; try { response = findLocal(query, { kind: opts.kind as FindKind, - root: opts.root, + root: opts.path ?? opts.root, ext: opts.ext, dir: opts.dir, limit: parsePositiveInt(opts.limit, "--limit"), diff --git a/src/lib/local/find.ts b/src/lib/local/find.ts index d3fbc31..cffd2fe 100644 --- a/src/lib/local/find.ts +++ b/src/lib/local/find.ts @@ -1,7 +1,7 @@ import type { Database } from "bun:sqlite"; import { - hasReadyRoot, autoRefreshStaleRoots, + getRoot, scheduleAutoRefreshStaleRoots, listRoots, rootHealth, @@ -71,6 +71,26 @@ export interface FindResponse { rootHealth?: Array<{ name: string; path: string; health: RootHealth }>; } +/** + * Resolve the population a query is allowed to search. + * + * With no configured roots, keep the ordinary `indexed:false` response even + * when a caller supplied a path. Once roots exist, a scoped query must name a + * real root and must report only that root in its population metadata. + */ +function queryRoots(rootRef: string | undefined, db?: Database): IndexRoot[] { + const roots = listRoots(db); + if (!rootRef || roots.length === 0) return roots; + + const root = getRoot(rootRef, db); + if (!root) throw new Error(`Index root not found: ${rootRef}`); + return [root]; +} + +function hasReadyQueryRoot(roots: IndexRoot[]): boolean { + return roots.some((root) => root.status === "ready"); +} + /** Human-readable reason a set of roots cannot answer a query. */ function describeUnusableRoots(roots: IndexRoot[]): string { if (roots.length === 0) { @@ -119,9 +139,9 @@ export function findLocal(query: string, opts: FindOptions = {}, db?: Database): throw new Error(`Invalid kind "${kind}" — use file, content, or both.`); } const limit = clampLimit(opts.limit); - const roots = listRoots(db); + let roots = queryRoots(opts.root, db); - if (!hasReadyRoot(db)) { + if (!hasReadyQueryRoot(roots)) { // Kick recovery before giving up: a wedged root is exactly the case the // refresh path can repair, and the old early return meant a wedged sole // root never reached the scheduler at all — the query gate and the recovery @@ -130,23 +150,24 @@ export function findLocal(query: string, opts: FindOptions = {}, db?: Database): if (opts.refresh === true) autoRefreshStaleRoots(db); else if (opts.refresh !== false) scheduleAutoRefreshStaleRoots(db); - if (hasReadyRoot(db)) return findLocal(query, { ...opts, refresh: false }, db); + roots = queryRoots(opts.root, db); + if (hasReadyQueryRoot(roots)) return findLocal(query, { ...opts, refresh: false }, db); - const current = listRoots(db); return { query, kind, indexed: false, - roots: current.length, + roots: roots.length, total: 0, results: [], - error: describeUnusableRoots(current), - rootHealth: current.map((r) => ({ name: r.name, path: r.path, health: rootHealth(r) })), + error: describeUnusableRoots(roots), + rootHealth: roots.map((r) => ({ name: r.name, path: r.path, health: rootHealth(r) })), }; } if (opts.refresh === true) autoRefreshStaleRoots(db); else if (opts.refresh !== false) scheduleAutoRefreshStaleRoots(db); + roots = queryRoots(opts.root, db); const queryOpts: LocalQueryOptions = { root: opts.root,