Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions src/cli/find-path-cli.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>): Promise<CliResult> {
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<string, string> {
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 <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);
});
8 changes: 7 additions & 1 deletion src/cli/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export function registerLocalCommands(program: Command): void {
.argument("<query...>", "What to look for")
.option("-k, --kind <kind>", "Match kind: file, content, both", "both")
.option("-r, --root <root>", "Limit to one index root (name, path, or id)")
.option("-p, --path <path>", "Limit to one index root by filesystem path")
.option("-e, --ext <ext>", "Filter by file extension")
.option("-d, --dir <dir>", "Filter by directory substring")
.option("-l, --limit <n>", "Max results", "20")
Expand All @@ -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"),
Expand Down
37 changes: 29 additions & 8 deletions src/lib/local/find.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Database } from "bun:sqlite";
import {
hasReadyRoot,
autoRefreshStaleRoots,
getRoot,
scheduleAutoRefreshStaleRoots,
listRoots,
rootHealth,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
Loading