diff --git a/src/e2e/indexer.e2e.test.ts b/src/e2e/indexer.e2e.test.ts index 595f71b9a9..3255863b51 100644 --- a/src/e2e/indexer.e2e.test.ts +++ b/src/e2e/indexer.e2e.test.ts @@ -2,8 +2,8 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { SafeJSON } from "@app/utils/json"; import { runTool, stripAnsi } from "@app/utils/e2e/helpers"; +import { SafeJSON } from "@app/utils/json"; const TEST_PREFIX = `test_e2e_${Date.now()}`; let tempDir: string; diff --git a/src/indexer/commands/rebuild.ts b/src/indexer/commands/rebuild.ts index be25f46861..764d4bf110 100644 --- a/src/indexer/commands/rebuild.ts +++ b/src/indexer/commands/rebuild.ts @@ -1,3 +1,4 @@ +import { isInteractive } from "@app/utils/cli"; import { formatDuration } from "@app/utils/format"; import * as p from "@clack/prompts"; import type { Command } from "commander"; @@ -24,7 +25,7 @@ export function registerRebuildCommand(program: Command): void { return; } - if (process.stdout.isTTY && process.stdin.isTTY) { + if (isInteractive()) { const selected = await p.select({ message: "Select index to rebuild", options: names.map((n) => ({ value: n, label: n })), @@ -44,13 +45,9 @@ export function registerRebuildCommand(program: Command): void { p.intro(pc.bgCyan(pc.white(` rebuild ${targetName} `))); - const metas = manager.listIndexes(); - const meta = metas.find((m) => m.name === targetName); - const chunkCount = meta?.stats.totalChunks ?? 0; - - if (process.stdout.isTTY && chunkCount > 0) { + if (isInteractive()) { const confirmed = await p.confirm({ - message: `Rebuild "${targetName}" (${chunkCount.toLocaleString()} chunks)? This will re-scan all source files.`, + message: `Rebuild "${targetName}"? This will re-scan all source files and re-chunk.`, }); if (p.isCancel(confirmed) || !confirmed) { diff --git a/src/indexer/commands/search.ts b/src/indexer/commands/search.ts index 29d6625107..5c8eaa48ab 100644 --- a/src/indexer/commands/search.ts +++ b/src/indexer/commands/search.ts @@ -2,58 +2,38 @@ import { toToon } from "@app/json/lib/toon"; import { SafeJSON } from "@app/utils/json"; import type { SearchResult } from "@app/utils/search/types"; import { truncateText } from "@app/utils/string"; -import { formatTable } from "@app/utils/table"; import * as p from "@clack/prompts"; import type { Command } from "commander"; -import pc from "picocolors"; +import { normalizeConfidence } from "../lib/confidence"; +import { formatChunkDisplayName } from "../lib/display-name"; +import { parseQueryWords } from "../lib/highlight"; import { IndexerManager } from "../lib/manager"; -import { detectMode, type SearchMode } from "../lib/search-mode"; +import { anyHaveEmbeddings, detectModeMulti, resolveSearchMode, type SearchMode } from "../lib/search-mode"; +import { type FormattedSearchResult, formatSearchResults, type OutputFormat } from "../lib/search-output"; import type { ChunkRecord } from "../lib/types"; interface SearchCommandOptions { index?: string; - mode?: SearchMode; + mode?: string; limit?: number; - format?: "table" | "json" | "toon"; + format?: "pretty" | "simple" | "table" | "json" | "toon"; file?: string; + confidence?: number; } -/** Minimum cosine score below which vector/hybrid results are likely noise */ -const VECTOR_MIN_SCORE = 0.55; -/** RRF scores are tiny — equivalent threshold for hybrid mode */ -const HYBRID_MIN_SCORE = VECTOR_MIN_SCORE * (1 / 60); +type IndexResult = { indexName: string; result: SearchResult }; -function truncatePreview(text: string, maxLen: number): string { - const collapsed = text.replace(/\n/g, " ").replace(/\s+/g, " ").trim(); - return truncateText(collapsed, maxLen); -} - -/** - * Apply a minimum score filter so irrelevant results don't pollute output. - * Only applied to vector/hybrid modes where low-score noise is common. - */ -function filterByMinScore( - results: Array<{ indexName: string; result: SearchResult }>, - mode: SearchMode -): Array<{ indexName: string; result: SearchResult }> { - if (mode === "fulltext") { - return results; - } - - const threshold = mode === "hybrid" ? HYBRID_MIN_SCORE : VECTOR_MIN_SCORE; - return results.filter((r) => r.result.score >= threshold); -} - -async function searchIndexes( +async function searchAndCollect( manager: IndexerManager, - indexNames: string[], + names: string[], query: string, mode: SearchMode, - limit: number -): Promise }>> { - let allResults: Array<{ indexName: string; result: SearchResult }> = []; + limit: number, + fileFilter?: string +): Promise { + let allResults: IndexResult[] = []; - for (const name of indexNames) { + for (const name of names) { const indexer = await manager.getIndex(name); const results = await indexer.search(query, { mode, limit }); @@ -63,10 +43,12 @@ async function searchIndexes( } allResults.sort((a, b) => b.result.score - a.result.score); - allResults = filterByMinScore(allResults, mode); - allResults = allResults.slice(0, limit); - return allResults; + if (fileFilter) { + allResults = allResults.filter((r) => r.result.doc.filePath.includes(fileFilter)); + } + + return allResults.slice(0, limit); } export function registerSearchCommand(program: Command): void { @@ -77,61 +59,64 @@ export function registerSearchCommand(program: Command): void { .option("-i, --index ", "Search specific index (default: all)") .option("-m, --mode ", "Search mode: fulltext, vector, hybrid (default: auto-detect)") .option("-l, --limit ", "Max results", parseInt, 20) - .option("-f, --file ", "Filter results to files matching this substring (e.g. '.ts', 'src/utils')") - .option("--format ", "Output format: table, json, toon (default: table)") + .option("-f, --file ", "Filter results to files matching this substring") + .option("--format ", "Output format: pretty, simple, table, json, toon (default: pretty)") + .option("-c, --confidence ", "Minimum confidence % (0-100)", parseInt) .action(async (query: string, opts: SearchCommandOptions) => { const manager = await IndexerManager.load(); try { + const validFormats = ["pretty", "simple", "table", "json", "toon"] as const; const limit = opts.limit ?? 20; - const format = opts.format ?? "table"; + const format = opts.format ?? (process.stdout.isTTY ? "pretty" : "simple"); - const names: string[] = []; + if (!validFormats.includes(format as (typeof validFormats)[number])) { + p.log.error(`Unknown format: "${format}". Valid: ${validFormats.join(", ")}`); + return; + } - if (opts.index) { - names.push(opts.index); - } else { - const all = manager.getIndexNames(); + const confidence = opts.confidence; - if (all.length === 0) { - p.log.info("No indexes configured. Run: tools indexer add "); - return; - } + if (confidence !== undefined && (!Number.isFinite(confidence) || confidence < 0 || confidence > 100)) { + p.log.error(`Invalid confidence: "${opts.confidence}". Expected 0-100.`); + return; + } + + const names = opts.index ? [opts.index] : manager.getIndexNames(); - names.push(...all); + if (names.length === 0) { + p.log.info("No indexes configured. Run: tools indexer add "); + return; } - const firstIndexer = await manager.getIndex(names[0]); + // Load all indexers upfront for mode detection across all indexes + const indexers = await Promise.all(names.map((n) => manager.getIndex(n))); let mode: SearchMode; if (opts.mode) { - mode = opts.mode; + const resolved = resolveSearchMode(opts.mode); + + if (!resolved) { + p.log.error(`Unknown search mode: "${opts.mode}". Valid: fulltext, vector, hybrid, semantic`); + return; + } + + mode = resolved; } else { - mode = detectMode(firstIndexer); + mode = detectModeMulti(indexers); } let effectiveMode = mode; - const fetchLimit = opts.file ? limit * 3 : limit; - let allResults = await searchIndexes(manager, names, query, mode, fetchLimit); - if (opts.file) { - const filter = opts.file; - allResults = allResults.filter((r) => r.result.doc.filePath.includes(filter)); - allResults = allResults.slice(0, limit); - } + let allResults = await searchAndCollect(manager, names, query, mode, fetchLimit, opts.file); + // Auto-fallback: only when mode was auto-detected (not explicitly requested) if (allResults.length === 0 && mode === "fulltext" && !opts.mode) { - const info = firstIndexer.getConsistencyInfo(); - - if (info.embeddingCount > 0) { - allResults = await searchIndexes(manager, names, query, "hybrid", limit); + if (anyHaveEmbeddings(indexers)) { effectiveMode = "hybrid"; - - if (allResults.length > 0) { - p.log.info(pc.dim("No keyword matches — showing semantic results instead")); - } + allResults = await searchAndCollect(manager, names, query, "hybrid", fetchLimit, opts.file); } } @@ -140,50 +125,61 @@ export function registerSearchCommand(program: Command): void { return; } - function toOutputRow(r: { indexName: string; result: SearchResult }) { - return { + const maxBm25Score = allResults.reduce( + (max, r) => (r.result.method === "bm25" ? Math.max(max, r.result.score) : max), + 0 + ); + + const formatted: FormattedSearchResult[] = allResults.map((r) => ({ + filePath: r.result.doc.filePath, + displayName: formatChunkDisplayName( + r.result.doc.name, + r.result.doc.startLine, + r.result.doc.endLine, + r.result.doc.kind + ), + language: r.result.doc.language ?? null, + content: r.result.doc.content, + confidence: normalizeConfidence(r.result.score, r.result.method, maxBm25Score), + method: r.result.method, + indexName: r.indexName, + startLine: r.result.doc.startLine, + endLine: r.result.doc.endLine, + })); + + const filtered = + confidence !== undefined ? formatted.filter((r) => r.confidence >= confidence) : formatted; + + if (format === "json" || format === "toon") { + const output = filtered.map((r) => ({ index: r.indexName, - file: r.result.doc.filePath, - name: r.result.doc.name ?? "", - kind: r.result.doc.kind, - score: r.result.score, - method: r.result.method, - lines: `${r.result.doc.startLine}-${r.result.doc.endLine}`, - preview: truncatePreview(r.result.doc.content, 200), - }; - } - - if (format === "json") { - const output = allResults.map(toOutputRow); - console.log(SafeJSON.stringify(output, null, 2)); - return; - } + file: r.filePath, + name: r.displayName, + language: r.language, + confidence: r.confidence, + method: r.method, + lines: r.startLine != null && r.endLine != null ? `${r.startLine}-${r.endLine}` : null, + preview: truncateText(r.content.replace(/\n/g, " ").replace(/\s+/g, " ").trim(), 200), + })); + + if (format === "json") { + console.log(SafeJSON.stringify(output, null, 2)); + } else { + console.log(toToon(output)); + } - if (format === "toon") { - const output = allResults.map(toOutputRow); - console.log(toToon(output)); return; } - const headers = ["File", "Name/Kind", "Score", "Method", "Preview"]; - const rows = allResults.map((r) => { - const filePath = r.result.doc.filePath; - const shortPath = filePath.length > 40 ? `...${filePath.slice(-37)}` : filePath; - - return [ - shortPath, - r.result.doc.name ?? r.result.doc.kind, - r.result.score.toFixed(3), - r.result.method, - truncatePreview(r.result.doc.content, 80), - ]; + const words = parseQueryWords(query); + const output = formatSearchResults({ + results: filtered, + format: format as OutputFormat, + query, + mode: effectiveMode, + highlightWords: words, }); - - console.log(""); - console.log(pc.dim(`${allResults.length} results for "${query}" (${effectiveMode})`)); - console.log(""); - console.log(formatTable(rows, headers, { alignRight: [2] })); - console.log(""); + console.log(output); } finally { await manager.close(); } diff --git a/src/indexer/lib/confidence.test.ts b/src/indexer/lib/confidence.test.ts new file mode 100644 index 0000000000..167e120f3f --- /dev/null +++ b/src/indexer/lib/confidence.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test"; +import { normalizeConfidence } from "./confidence"; + +describe("normalizeConfidence", () => { + describe("cosine", () => { + test("1.0 → 100", () => { + expect(normalizeConfidence(1.0, "cosine")).toBe(100); + }); + + test("0.75 → 75", () => { + expect(normalizeConfidence(0.75, "cosine")).toBe(75); + }); + + test("0 → 0", () => { + expect(normalizeConfidence(0, "cosine")).toBe(0); + }); + + test("negative → 0", () => { + expect(normalizeConfidence(-0.5, "cosine")).toBe(0); + }); + }); + + describe("rrf", () => { + test("max theoretical (2/61) → 100", () => { + expect(normalizeConfidence(2 / 61, "rrf")).toBe(100); + }); + + test("typical top result 0.016 → ~49", () => { + const result = normalizeConfidence(0.016, "rrf"); + expect(result).toBeGreaterThanOrEqual(48); + expect(result).toBeLessThanOrEqual(50); + }); + + test("0 → 0", () => { + expect(normalizeConfidence(0, "rrf")).toBe(0); + }); + }); + + describe("bm25", () => { + test("with maxScore 30: 30 → 100", () => { + expect(normalizeConfidence(30, "bm25", 30)).toBe(100); + }); + + test("with maxScore 30: 15 → 50", () => { + expect(normalizeConfidence(15, "bm25", 30)).toBe(50); + }); + + test("with maxScore 0: falls back to heuristic", () => { + const low = normalizeConfidence(1, "bm25", 0); + const mid = normalizeConfidence(10, "bm25", 0); + const high = normalizeConfidence(25, "bm25", 0); + + expect(low).toBeGreaterThanOrEqual(0); + expect(low).toBeLessThanOrEqual(100); + expect(mid).toBeGreaterThanOrEqual(0); + expect(mid).toBeLessThanOrEqual(100); + expect(high).toBeGreaterThanOrEqual(0); + expect(high).toBeLessThanOrEqual(100); + + expect(low).toBeLessThan(mid); + expect(mid).toBeLessThan(high); + }); + + test("without maxScore: returns 0-100 range", () => { + const low = normalizeConfidence(1, "bm25"); + const mid = normalizeConfidence(10, "bm25"); + const high = normalizeConfidence(25, "bm25"); + + expect(low).toBeGreaterThanOrEqual(0); + expect(low).toBeLessThanOrEqual(100); + + expect(mid).toBeGreaterThanOrEqual(0); + expect(mid).toBeLessThanOrEqual(100); + + expect(high).toBeGreaterThanOrEqual(0); + expect(high).toBeLessThanOrEqual(100); + + expect(low).toBeLessThan(mid); + expect(mid).toBeLessThan(high); + }); + }); +}); diff --git a/src/indexer/lib/confidence.ts b/src/indexer/lib/confidence.ts new file mode 100644 index 0000000000..70f89b8f20 --- /dev/null +++ b/src/indexer/lib/confidence.ts @@ -0,0 +1,27 @@ +export type ScoreMethod = "cosine" | "rrf" | "bm25"; + +const RRF_THEORETICAL_MAX = 2 / 61; + +export function normalizeConfidence(score: number, method: ScoreMethod, maxScore?: number): number { + let normalized: number; + + switch (method) { + case "cosine": + normalized = score * 100; + break; + + case "rrf": + normalized = (score / RRF_THEORETICAL_MAX) * 100; + break; + + case "bm25": + if (maxScore !== undefined && maxScore > 0) { + normalized = (score / maxScore) * 100; + } else { + normalized = Math.min(score * 5, 100); + } + break; + } + + return Math.round(Math.max(0, Math.min(100, normalized))); +} diff --git a/src/indexer/lib/display-name.test.ts b/src/indexer/lib/display-name.test.ts new file mode 100644 index 0000000000..7016fc57eb --- /dev/null +++ b/src/indexer/lib/display-name.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "bun:test"; +import { formatChunkDisplayName, stripPartSuffixes } from "./display-name"; + +describe("stripPartSuffixes", () => { + it("strips single part suffix", () => { + expect(stripPartSuffixes("MyClass (part 2)")).toBe("MyClass"); + }); + + it("only strips the trailing part suffix", () => { + expect(stripPartSuffixes("MyClass (part 3) (part 8)")).toBe("MyClass (part 3)"); + }); + + it("preserves internal (part N) in real symbol names", () => { + expect(stripPartSuffixes("RFC (part 2) parser")).toBe("RFC (part 2) parser"); + }); + + it("leaves names without suffixes unchanged", () => { + expect(stripPartSuffixes("MyClass")).toBe("MyClass"); + }); +}); + +describe("formatChunkDisplayName", () => { + it("shows name:lines for named chunk", () => { + expect(formatChunkDisplayName("MyClass", 10, 45)).toBe("MyClass:10-45"); + }); + + it("strips only trailing part suffix", () => { + expect(formatChunkDisplayName("MyClass (part 3) (part 8)", 100, 150)).toBe("MyClass (part 3):100-150"); + }); + + it("strips single part suffix", () => { + expect(formatChunkDisplayName("MyClass (part 2)", 50, 80)).toBe("MyClass:50-80"); + }); + + it("falls back to kind:lines when no name", () => { + expect(formatChunkDisplayName(undefined, 1, 30, "function")).toBe("function:1-30"); + }); + + it("falls back to just lines when no name or kind", () => { + expect(formatChunkDisplayName(undefined, 1, 30)).toBe("L1-30"); + }); + + it("handles single-line chunk", () => { + expect(formatChunkDisplayName("handler", 42, 42)).toBe("handler:42"); + }); + + it("falls back to cleaned name when lines are undefined", () => { + expect(formatChunkDisplayName("MyClass (part 2)", undefined, undefined, "function")).toBe("MyClass"); + }); + + it("falls back when lines are NaN", () => { + expect(formatChunkDisplayName(undefined, Number.NaN, 10, "function")).toBe("function"); + expect(formatChunkDisplayName(undefined, Number.NaN, Number.NaN)).toBe("chunk"); + }); +}); diff --git a/src/indexer/lib/display-name.ts b/src/indexer/lib/display-name.ts new file mode 100644 index 0000000000..03dde498ea --- /dev/null +++ b/src/indexer/lib/display-name.ts @@ -0,0 +1,29 @@ +export function stripPartSuffixes(name: string): string { + return name.replace(/\s*\(part\s+\d+\)\s*$/i, "").trim(); +} + +export function formatChunkDisplayName( + name: string | undefined, + startLine: number | undefined, + endLine: number | undefined, + kind?: string +): string { + const cleanName = name ? stripPartSuffixes(name) : ""; + const hasLines = startLine != null && endLine != null && !Number.isNaN(startLine) && !Number.isNaN(endLine); + + if (!hasLines) { + return cleanName || kind || "chunk"; + } + + const lineRange = startLine === endLine ? `${startLine}` : `${startLine}-${endLine}`; + + if (cleanName) { + return `${cleanName}:${lineRange}`; + } + + if (kind) { + return `${kind}:${lineRange}`; + } + + return `L${lineRange}`; +} diff --git a/src/indexer/lib/highlight.test.ts b/src/indexer/lib/highlight.test.ts new file mode 100644 index 0000000000..77f19400a8 --- /dev/null +++ b/src/indexer/lib/highlight.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "bun:test"; +import { stripAnsi } from "@app/utils/string"; +import { createColors } from "picocolors"; +import { highlightQueryWords, parseQueryWords } from "./highlight"; + +const colorsOn = createColors(true); +const colorsOff = createColors(false); + +describe("parseQueryWords", () => { + it("splits query into lowercase words", () => { + expect(parseQueryWords("Telegram bot Notification")).toEqual(["telegram", "bot", "notification"]); + }); + + it("deduplicates words", () => { + expect(parseQueryWords("test test Test")).toEqual(["test"]); + }); + + it("filters short words (<=2 chars)", () => { + const result = parseQueryWords("how is the AI working"); + expect(result).toContain("how"); + expect(result).toContain("the"); + expect(result).toContain("working"); + expect(result).not.toContain("is"); + expect(result).not.toContain("ai"); + }); +}); + +describe("highlightQueryWords", () => { + describe("with colors", () => { + it("wraps matching words with ANSI codes", () => { + const result = highlightQueryWords("Send telegram notification", ["telegram", "notification"], colorsOn); + const plain = stripAnsi(result); + expect(plain).toBe("Send telegram notification"); + expect(result.length).toBeGreaterThan(plain.length); + }); + + it("is case-insensitive", () => { + const result = highlightQueryWords("Telegram TELEGRAM telegram", ["telegram"], colorsOn); + const plain = stripAnsi(result); + expect(plain).toBe("Telegram TELEGRAM telegram"); + // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ANSI escape code matching + const matches = result.match(/\x1b\[/g); + expect(matches!.length).toBeGreaterThanOrEqual(3); + }); + + it("prefers longer matches over shorter overlapping ones", () => { + const result = highlightQueryWords("mapped map mapper", ["map", "mapped"], colorsOn); + const plain = stripAnsi(result); + expect(plain).toBe("mapped map mapper"); + // "mapped" should be highlighted as a whole, not just the "map" prefix + expect(result).toContain(colorsOn.bold(colorsOn.yellow("mapped"))); + expect(result).toContain(colorsOn.bold(colorsOn.yellow("map"))); + }); + }); + + describe("without colors", () => { + it("returns text unchanged when colors are disabled", () => { + const result = highlightQueryWords("Send telegram notification", ["telegram", "notification"], colorsOff); + expect(result).toBe("Send telegram notification"); + }); + + it("is case-insensitive even without colors", () => { + const result = highlightQueryWords("Telegram TELEGRAM telegram", ["telegram"], colorsOff); + expect(result).toBe("Telegram TELEGRAM telegram"); + }); + }); + + it("handles no matches gracefully", () => { + const result = highlightQueryWords("no match here", ["xyz"]); + expect(result).toBe("no match here"); + }); + + it("handles special regex chars in query", () => { + const result = highlightQueryWords("cost is $100 (total)", ["$100", "(total)"], colorsOff); + expect(result).toBe("cost is $100 (total)"); + }); + + it("returns empty string for empty input", () => { + expect(highlightQueryWords("", ["test"])).toBe(""); + }); +}); diff --git a/src/indexer/lib/highlight.ts b/src/indexer/lib/highlight.ts new file mode 100644 index 0000000000..1117804000 --- /dev/null +++ b/src/indexer/lib/highlight.ts @@ -0,0 +1,48 @@ +import pc from "picocolors"; + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function parseQueryWords(query: string): string[] { + const seen = new Set(); + const result: string[] = []; + + for (const raw of query.split(/\s+/)) { + const word = raw.toLowerCase(); + + if (word.length <= 2) { + continue; + } + + if (seen.has(word)) { + continue; + } + + seen.add(word); + result.push(word); + } + + return result; +} + +interface HighlightColors { + bold: (s: string) => string; + yellow: (s: string) => string; +} + +export function highlightQueryWords(text: string, words: string[], colors?: HighlightColors): string { + if (!text || words.length === 0) { + return text; + } + + const { bold, yellow } = colors ?? pc; + + const pattern = [...words] + .sort((a, b) => b.length - a.length) + .map(escapeRegex) + .join("|"); + const regex = new RegExp(`(${pattern})`, "gi"); + + return text.replace(regex, (match) => bold(yellow(match))); +} diff --git a/src/indexer/lib/search-mode.test.ts b/src/indexer/lib/search-mode.test.ts new file mode 100644 index 0000000000..f078c5b883 --- /dev/null +++ b/src/indexer/lib/search-mode.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "bun:test"; +import type { Indexer } from "./indexer"; +import { anyHaveEmbeddings, detectMode, detectModeMulti, resolveSearchMode } from "./search-mode"; + +function fakeIndexer(embeddingCount: number): Indexer { + return { + getStore: () => ({ getEmbeddingCount: () => embeddingCount }), + } as unknown as Indexer; +} + +describe("resolveSearchMode", () => { + it("passes through valid modes unchanged", () => { + expect(resolveSearchMode("fulltext")).toBe("fulltext"); + expect(resolveSearchMode("vector")).toBe("vector"); + expect(resolveSearchMode("hybrid")).toBe("hybrid"); + }); + + it("maps 'semantic' to 'vector'", () => { + expect(resolveSearchMode("semantic")).toBe("vector"); + }); + + it("returns undefined for unknown modes", () => { + expect(resolveSearchMode("banana")).toBeUndefined(); + }); +}); + +describe("detectMode", () => { + it("returns 'hybrid' when embeddings exist", () => { + expect(detectMode(fakeIndexer(100))).toBe("hybrid"); + }); + + it("returns 'fulltext' when no embeddings", () => { + expect(detectMode(fakeIndexer(0))).toBe("fulltext"); + }); +}); + +describe("detectModeMulti", () => { + it("returns 'hybrid' when any index has embeddings", () => { + expect(detectModeMulti([fakeIndexer(0), fakeIndexer(50)])).toBe("hybrid"); + }); + + it("returns 'fulltext' when no indexes have embeddings", () => { + expect(detectModeMulti([fakeIndexer(0), fakeIndexer(0)])).toBe("fulltext"); + }); +}); + +describe("anyHaveEmbeddings", () => { + it("returns true when at least one index has embeddings", () => { + expect(anyHaveEmbeddings([fakeIndexer(0), fakeIndexer(10)])).toBe(true); + }); + + it("returns false when none have embeddings", () => { + expect(anyHaveEmbeddings([fakeIndexer(0)])).toBe(false); + }); +}); diff --git a/src/indexer/lib/search-mode.ts b/src/indexer/lib/search-mode.ts index 447320e921..112171798c 100644 --- a/src/indexer/lib/search-mode.ts +++ b/src/indexer/lib/search-mode.ts @@ -2,6 +2,10 @@ import type { Indexer } from "./indexer"; export type SearchMode = "fulltext" | "vector" | "hybrid"; +const MODE_ALIASES: Record = { + semantic: "vector", +}; + /** * Detect the best search mode for an index. * If the index has embeddings -> hybrid (combines BM25 + semantic). @@ -10,3 +14,22 @@ export type SearchMode = "fulltext" | "vector" | "hybrid"; export function detectMode(indexer: Indexer): SearchMode { return indexer.getStore().getEmbeddingCount() > 0 ? "hybrid" : "fulltext"; } + +/** Check if any of the given indexers have embeddings. */ +export function anyHaveEmbeddings(indexers: Indexer[]): boolean { + return indexers.some((idx) => idx.getStore().getEmbeddingCount() > 0); +} + +/** Detect mode across multiple indexes — hybrid if any have embeddings. */ +export function detectModeMulti(indexers: Indexer[]): SearchMode { + return anyHaveEmbeddings(indexers) ? "hybrid" : "fulltext"; +} + +/** Resolve a user-provided mode string to a canonical SearchMode. Returns undefined for unknown modes. */ +export function resolveSearchMode(input: string): SearchMode | undefined { + if (input === "fulltext" || input === "vector" || input === "hybrid") { + return input; + } + + return MODE_ALIASES[input]; +} diff --git a/src/indexer/lib/search-output-formats.test.ts b/src/indexer/lib/search-output-formats.test.ts new file mode 100644 index 0000000000..31eae57b75 --- /dev/null +++ b/src/indexer/lib/search-output-formats.test.ts @@ -0,0 +1,414 @@ +import { describe, expect, mock, test } from "bun:test"; +import { createColors } from "picocolors"; + +// Force colors on for all imports in this test file +mock.module("picocolors", () => { + const forced = createColors(true); + return { default: forced, createColors }; +}); + +import { stripAnsi } from "@app/utils/string"; +import { type FormattedSearchResult, formatSearchResults } from "./search-output"; + +function makeResult(overrides: Partial = {}): FormattedSearchResult { + return { + filePath: "/project/app/Services/BookingService.php", + displayName: "createReservation:45-120", + language: "php", + content: [ + "public function createReservation(array $data): Reservation", + "{", + " $reservation = new Reservation($data);", + " $reservation->save();", + " event(new ReservationCreated($reservation));", + " return $reservation;", + "}", + ].join("\n"), + confidence: 85, + method: "rrf" as const, + indexName: "TestIndex", + startLine: 45, + endLine: 51, + ...overrides, + }; +} + +const defaultOpts = { + query: "create reservation", + mode: "hybrid", +} as const; + +describe("formatSearchResults", () => { + describe("pretty format", () => { + test("contains fenced code block with correct language marker", () => { + const output = formatSearchResults({ + results: [makeResult()], + format: "pretty", + ...defaultOpts, + }); + const plain = stripAnsi(output); + + expect(plain).toContain("```php"); + expect(plain).toMatch(/```\s*$/); + }); + + test("shows confidence as percentage", () => { + const output = formatSearchResults({ + results: [makeResult({ confidence: 85 })], + format: "pretty", + ...defaultOpts, + }); + const plain = stripAnsi(output); + + expect(plain).toContain("85%"); + }); + + test("shows file path and display name in header", () => { + const output = formatSearchResults({ + results: [makeResult()], + format: "pretty", + ...defaultOpts, + }); + const plain = stripAnsi(output); + + expect(plain).toContain("/project/app/Services/BookingService.php"); + expect(plain).toContain("createReservation:45-120"); + }); + + test("renders multiple results", () => { + const results = [ + makeResult({ displayName: "methodA:1-10", confidence: 90 }), + makeResult({ displayName: "methodB:20-30", confidence: 60 }), + ]; + const output = formatSearchResults({ + results, + format: "pretty", + ...defaultOpts, + }); + const plain = stripAnsi(output); + + expect(plain).toContain("methodA:1-10"); + expect(plain).toContain("methodB:20-30"); + expect(plain).toContain("90%"); + expect(plain).toContain("60%"); + }); + + test("handles null language with empty code fence marker", () => { + const output = formatSearchResults({ + results: [makeResult({ language: null })], + format: "pretty", + ...defaultOpts, + }); + const plain = stripAnsi(output); + + expect(plain).toContain("```\n"); + expect(plain).not.toContain("```php"); + }); + + test("shows result count and query in header", () => { + const output = formatSearchResults({ + results: [makeResult(), makeResult({ filePath: "/other/file.ts" })], + format: "pretty", + ...defaultOpts, + }); + const plain = stripAnsi(output); + + expect(plain).toContain("2 results"); + expect(plain).toContain('"create reservation"'); + expect(plain).toContain("(hybrid)"); + }); + + test("shows singular 'result' for single result", () => { + const output = formatSearchResults({ + results: [makeResult()], + format: "pretty", + ...defaultOpts, + }); + const plain = stripAnsi(output); + + expect(plain).toContain("1 result"); + expect(plain).not.toContain("1 results"); + }); + }); + + describe("simple format", () => { + test("shows file path as heading with confidence", () => { + const output = formatSearchResults({ + results: [makeResult()], + format: "simple", + ...defaultOpts, + }); + const plain = stripAnsi(output); + + expect(plain).toContain("/project/app/Services/BookingService.php"); + expect(plain).toContain("85%"); + }); + + test("shows line numbers", () => { + const output = formatSearchResults({ + results: [makeResult({ startLine: 45 })], + format: "simple", + ...defaultOpts, + }); + const plain = stripAnsi(output); + + expect(plain).toContain("45|"); + expect(plain).toContain("46|"); + expect(plain).toContain("47|"); + }); + + test("highlights query words with ANSI codes", () => { + const withHighlight = formatSearchResults({ + results: [makeResult()], + format: "simple", + ...defaultOpts, + highlightWords: ["reservation"], + }); + const withoutHighlight = formatSearchResults({ + results: [makeResult()], + format: "simple", + ...defaultOpts, + }); + + // Highlighting adds extra ANSI bytes around matched words + expect(withHighlight.length).toBeGreaterThan(withoutHighlight.length); + expect(stripAnsi(withHighlight)).toContain("reservation"); + }); + }); + + describe("table format", () => { + test("contains column headers: File, Symbol, Confidence, Method", () => { + const output = formatSearchResults({ + results: [makeResult()], + format: "table", + ...defaultOpts, + }); + const plain = stripAnsi(output); + + expect(plain).toContain("File"); + expect(plain).toContain("Symbol"); + expect(plain).toContain("Confidence"); + expect(plain).toContain("Method"); + }); + + test("shows percentage in confidence column", () => { + const output = formatSearchResults({ + results: [makeResult({ confidence: 72 })], + format: "table", + ...defaultOpts, + }); + const plain = stripAnsi(output); + + expect(plain).toContain("72%"); + }); + + test("truncates long file paths with ellipsis", () => { + const longPath = "/very/long/deeply/nested/project/structure/src/modules/services/BookingService.php"; + const output = formatSearchResults({ + results: [makeResult({ filePath: longPath })], + format: "table", + ...defaultOpts, + }); + const plain = stripAnsi(output); + + expect(plain).toContain("..."); + expect(plain).not.toContain(longPath); + }); + + test("shows separator line with box-drawing character", () => { + const output = formatSearchResults({ + results: [makeResult()], + format: "table", + ...defaultOpts, + }); + const plain = stripAnsi(output); + + expect(plain).toContain("\u2500"); // ─ + }); + }); + + describe("edge cases", () => { + test("empty results returns 'No results' message", () => { + const output = formatSearchResults({ + results: [], + format: "pretty", + ...defaultOpts, + }); + + expect(output).toContain("No results"); + }); + + test("single-line chunk renders in pretty format without crashing", () => { + const result = makeResult({ + content: "const x = 1;", + startLine: 1, + endLine: 1, + }); + const output = formatSearchResults({ + results: [result], + format: "pretty", + ...defaultOpts, + }); + + expect(output).toBeTruthy(); + expect(stripAnsi(output)).toContain("const x = 1;"); + }); + + test("single-line chunk renders in simple format without crashing", () => { + const result = makeResult({ + content: "const x = 1;", + startLine: 1, + endLine: 1, + }); + const output = formatSearchResults({ + results: [result], + format: "simple", + ...defaultOpts, + }); + + expect(output).toBeTruthy(); + expect(stripAnsi(output)).toContain("const x = 1;"); + }); + + test("single-line chunk renders in table format without crashing", () => { + const result = makeResult({ + content: "const x = 1;", + startLine: 1, + endLine: 1, + }); + const output = formatSearchResults({ + results: [result], + format: "table", + ...defaultOpts, + }); + + expect(output).toBeTruthy(); + }); + + test("content with special characters does not crash", () => { + const result = makeResult({ + content: 'const regex = /[a-z]+/g;\nconst html = "
&
";', + }); + + for (const format of ["pretty", "simple", "table"] as const) { + const output = formatSearchResults({ + results: [result], + format, + ...defaultOpts, + }); + + expect(output).toBeTruthy(); + } + }); + + test("very long content (5000 chars) does not crash", () => { + const longContent = "x".repeat(5000); + const result = makeResult({ content: longContent }); + + for (const format of ["pretty", "simple", "table"] as const) { + const output = formatSearchResults({ + results: [result], + format, + ...defaultOpts, + }); + + expect(output).toBeTruthy(); + } + }); + + test("all three formats produce different output shapes", () => { + const results = [makeResult()]; + + const pretty = formatSearchResults({ + results, + format: "pretty", + ...defaultOpts, + }); + const simple = formatSearchResults({ + results, + format: "simple", + ...defaultOpts, + }); + const table = formatSearchResults({ + results, + format: "table", + ...defaultOpts, + }); + + expect(pretty).not.toBe(simple); + expect(pretty).not.toBe(table); + expect(simple).not.toBe(table); + + const prettyPlain = stripAnsi(pretty); + const simplePlain = stripAnsi(simple); + const tablePlain = stripAnsi(table); + + expect(prettyPlain).toContain("```"); + expect(simplePlain).toContain("45|"); + expect(tablePlain).toContain("File"); + }); + }); + + describe("coloring verification", () => { + test("simple format with highlightWords produces ANSI codes", () => { + const withHighlight = formatSearchResults({ + results: [makeResult()], + format: "simple", + ...defaultOpts, + highlightWords: ["reservation"], + }); + const withoutHighlight = formatSearchResults({ + results: [makeResult()], + format: "simple", + ...defaultOpts, + }); + + expect(withHighlight.length).toBeGreaterThan(withoutHighlight.length); + }); + + test("high confidence (>=70) produces different ANSI than low confidence (<40)", () => { + const highConf = formatSearchResults({ + results: [makeResult({ confidence: 90 })], + format: "simple", + ...defaultOpts, + }); + const lowConf = formatSearchResults({ + results: [makeResult({ confidence: 20 })], + format: "simple", + ...defaultOpts, + }); + + const highPlain = stripAnsi(highConf); + const lowPlain = stripAnsi(lowConf); + + expect(highConf.length).toBeGreaterThan(highPlain.length); + expect(lowConf.length).toBeGreaterThan(lowPlain.length); + + const highAnsiOnly = highConf.replace(highPlain.replace("90%", "XX%"), ""); + const lowAnsiOnly = lowConf.replace(lowPlain.replace("20%", "XX%"), ""); + + expect(highAnsiOnly).not.toBe(lowAnsiOnly); + }); + + test("medium confidence (40-69) uses different color than high and low", () => { + const makeOutput = (confidence: number) => + formatSearchResults({ + results: [makeResult({ confidence })], + format: "pretty", + ...defaultOpts, + }); + + const high = makeOutput(80); + const medium = makeOutput(55); + const low = makeOutput(30); + + expect(high.length).toBeGreaterThan(stripAnsi(high).length); + expect(medium.length).toBeGreaterThan(stripAnsi(medium).length); + expect(low.length).toBeGreaterThan(stripAnsi(low).length); + + expect(high).toContain("\x1b[32m"); // green for >=70 + expect(medium).toContain("\x1b[33m"); // yellow for 40-69 + expect(low).toContain("\x1b[31m"); // red for <40 + }); + }); +}); diff --git a/src/indexer/lib/search-output.test.ts b/src/indexer/lib/search-output.test.ts new file mode 100644 index 0000000000..5f5cc18e35 --- /dev/null +++ b/src/indexer/lib/search-output.test.ts @@ -0,0 +1,307 @@ +import { describe, expect, it, mock } from "bun:test"; +import { createColors } from "picocolors"; + +// Force colors on for all imports in this test file (including search-output.ts and highlight.ts) +mock.module("picocolors", () => { + const forced = createColors(true); + return { default: forced, createColors }; +}); + +import { stripAnsi } from "@app/utils/string"; +import { type FormattedSearchResult, formatSearchResults } from "./search-output"; + +function makeResult(overrides?: Partial): FormattedSearchResult { + return { + filePath: "/Users/dev/project/src/services/telegram/notification-handler.ts", + displayName: "sendNotification:45-72", + language: "typescript", + content: + "export async function sendNotification(userId: string) {\n const client = getClient();\n await client.send(userId, 'hello');\n}", + confidence: 87, + method: "cosine", + indexName: "my-index", + startLine: 45, + endLine: 48, + ...overrides, + }; +} + +describe("formatSearchResults", () => { + describe("empty results", () => { + it("returns 'No results found.' for empty array", () => { + const result = formatSearchResults({ + results: [], + format: "pretty", + query: "telegram", + mode: "hybrid", + }); + expect(result).toBe("No results found."); + }); + + it("returns same message regardless of format", () => { + for (const format of ["pretty", "simple", "table"] as const) { + const result = formatSearchResults({ + results: [], + format, + query: "test", + mode: "bm25", + }); + expect(result).toBe("No results found."); + } + }); + }); + + describe("pretty format", () => { + it("contains fenced code block with language marker", () => { + const result = formatSearchResults({ + results: [makeResult({ language: "php" })], + format: "pretty", + query: "notification", + mode: "hybrid", + }); + const plain = stripAnsi(result); + expect(plain).toContain("```php"); + expect(plain).toMatch(/```\n|```$/); + }); + + it("contains confidence as percentage", () => { + const result = formatSearchResults({ + results: [makeResult({ confidence: 87 })], + format: "pretty", + query: "notification", + mode: "hybrid", + }); + const plain = stripAnsi(result); + expect(plain).toContain("87%"); + }); + + it("contains file path and display name", () => { + const result = formatSearchResults({ + results: [makeResult()], + format: "pretty", + query: "notification", + mode: "hybrid", + }); + const plain = stripAnsi(result); + expect(plain).toContain("notification-handler.ts"); + expect(plain).toContain("sendNotification:45-72"); + }); + + it("shows result count and query in header", () => { + const result = formatSearchResults({ + results: [makeResult(), makeResult({ displayName: "other:10-20" })], + format: "pretty", + query: "telegram bot", + mode: "cosine", + }); + const plain = stripAnsi(result); + expect(plain).toContain("2 results"); + expect(plain).toContain('"telegram bot"'); + expect(plain).toContain("cosine"); + }); + + it("groups multiple results from the same file", () => { + const r1 = makeResult({ displayName: "funcA:10-20", startLine: 10, endLine: 20 }); + const r2 = makeResult({ displayName: "funcB:30-40", startLine: 30, endLine: 40 }); + const result = formatSearchResults({ + results: [r1, r2], + format: "pretty", + query: "test", + mode: "hybrid", + }); + const plain = stripAnsi(result); + const pathOccurrences = plain.split("notification-handler.ts").length - 1; + expect(pathOccurrences).toBe(1); + }); + + it("highlights query words in content", () => { + const result = formatSearchResults({ + results: [makeResult()], + format: "pretty", + query: "sendNotification", + mode: "hybrid", + highlightWords: ["sendNotification"], + }); + const plain = stripAnsi(result); + expect(result.length).toBeGreaterThan(plain.length); + }); + }); + + describe("simple format", () => { + it("contains file path", () => { + const result = formatSearchResults({ + results: [makeResult()], + format: "simple", + query: "notification", + mode: "hybrid", + }); + const plain = stripAnsi(result); + expect(plain).toContain("notification-handler.ts"); + }); + + it("contains line numbers", () => { + const result = formatSearchResults({ + results: [makeResult({ startLine: 45 })], + format: "simple", + query: "notification", + mode: "hybrid", + }); + const plain = stripAnsi(result); + expect(plain).toContain("45|"); + }); + + it("contains confidence percentage", () => { + const result = formatSearchResults({ + results: [makeResult({ confidence: 92 })], + format: "simple", + query: "notification", + mode: "hybrid", + }); + const plain = stripAnsi(result); + expect(plain).toContain("92%"); + }); + + it("highlighted words produce ANSI codes", () => { + const result = formatSearchResults({ + results: [makeResult()], + format: "simple", + query: "sendNotification", + mode: "hybrid", + highlightWords: ["sendNotification"], + }); + const plain = stripAnsi(result); + expect(result.length).toBeGreaterThan(plain.length); + }); + }); + + describe("table format", () => { + it("contains column headers", () => { + const result = formatSearchResults({ + results: [makeResult()], + format: "table", + query: "notification", + mode: "hybrid", + }); + const plain = stripAnsi(result); + expect(plain).toContain("File"); + expect(plain).toContain("Symbol"); + expect(plain).toContain("Confidence"); + expect(plain).toContain("Method"); + }); + + it("contains percentage in confidence column", () => { + const result = formatSearchResults({ + results: [makeResult({ confidence: 87 })], + format: "table", + query: "notification", + mode: "hybrid", + }); + const plain = stripAnsi(result); + expect(plain).toContain("87%"); + }); + + it("contains display name", () => { + const result = formatSearchResults({ + results: [makeResult()], + format: "table", + query: "notification", + mode: "hybrid", + }); + const plain = stripAnsi(result); + expect(plain).toContain("sendNotification:45-72"); + }); + + it("truncates long file paths with ellipsis", () => { + const result = formatSearchResults({ + results: [ + makeResult({ + filePath: + "/Users/dev/project/src/very/deeply/nested/directory/structure/handlers/notification-handler.ts", + }), + ], + format: "table", + query: "notification", + mode: "hybrid", + }); + const plain = stripAnsi(result); + expect(plain).toContain("..."); + }); + + it("contains separator line", () => { + const result = formatSearchResults({ + results: [makeResult()], + format: "table", + query: "notification", + mode: "hybrid", + }); + expect(result).toContain("─"); + }); + }); + + describe("edge cases", () => { + it("single-line chunk renders in all formats", () => { + const singleLine = makeResult({ + content: "const x = 42;", + startLine: 1, + endLine: 1, + }); + + for (const format of ["pretty", "simple", "table"] as const) { + const result = formatSearchResults({ + results: [singleLine], + format, + query: "test", + mode: "bm25", + }); + expect(result.length).toBeGreaterThan(0); + } + }); + + it("content with special chars does not crash", () => { + const special = makeResult({ + content: "const regex = /[.*+?^${}()|[\\]\\\\]/g;\nconst tmpl = `${foo}&`;", + }); + + for (const format of ["pretty", "simple", "table"] as const) { + expect(() => + formatSearchResults({ + results: [special], + format, + query: "regex", + mode: "hybrid", + }) + ).not.toThrow(); + } + }); + + it("all three formats produce different output shapes", () => { + const r = makeResult(); + const outputs = (["pretty", "simple", "table"] as const).map((format) => + stripAnsi( + formatSearchResults({ + results: [r], + format, + query: "test", + mode: "hybrid", + }) + ) + ); + + expect(outputs[0]).not.toBe(outputs[1]); + expect(outputs[1]).not.toBe(outputs[2]); + expect(outputs[0]).not.toBe(outputs[2]); + }); + + it("null language uses plain code block in pretty format", () => { + const result = formatSearchResults({ + results: [makeResult({ language: null })], + format: "pretty", + query: "test", + mode: "hybrid", + }); + const plain = stripAnsi(result); + expect(plain).toContain("```\n"); + expect(plain).not.toContain("```null"); + }); + }); +}); diff --git a/src/indexer/lib/search-output.ts b/src/indexer/lib/search-output.ts new file mode 100644 index 0000000000..74faae70bb --- /dev/null +++ b/src/indexer/lib/search-output.ts @@ -0,0 +1,159 @@ +import { formatTable } from "@app/utils/table"; +import pc from "picocolors"; +import { highlightQueryWords } from "./highlight"; + +export interface FormattedSearchResult { + filePath: string; + displayName: string; + language: string | null; + content: string; + confidence: number; + method: "bm25" | "cosine" | "rrf"; + indexName: string; + startLine: number; + endLine: number; +} + +export type OutputFormat = "pretty" | "simple" | "table"; + +interface FormatOptions { + results: FormattedSearchResult[]; + format: OutputFormat; + query: string; + mode: string; + highlightWords?: string[]; +} + +function shortenPath(filePath: string, maxLen: number): string { + if (filePath.length <= maxLen) { + return filePath; + } + + return `...${filePath.slice(-(maxLen - 3))}`; +} + +function colorConfidence(confidence: number): string { + const label = `${confidence}%`; + + if (confidence >= 70) { + return pc.green(label); + } + + if (confidence >= 40) { + return pc.yellow(label); + } + + return pc.red(label); +} + +function formatHeader(count: number, query: string, mode: string): string { + const noun = count === 1 ? "result" : "results"; + return `${pc.bold(`${count} ${noun}`)} for ${pc.cyan(`"${query}"`)} ${pc.dim(`(${mode})`)}`; +} + +function highlightContent(content: string, words: string[] | undefined): string { + if (!words || words.length === 0) { + return content; + } + + return highlightQueryWords(content, words); +} + +function formatPretty(opts: FormatOptions): string { + const lines: string[] = [formatHeader(opts.results.length, opts.query, opts.mode), ""]; + + const groups = new Map(); + + for (const r of opts.results) { + const existing = groups.get(r.filePath); + + if (existing) { + existing.push(r); + } else { + groups.set(r.filePath, [r]); + } + } + + let groupIndex = 0; + + for (const [filePath, results] of groups) { + if (groupIndex > 0) { + lines.push(""); + } + + lines.push(pc.cyan(shortenPath(filePath, 60))); + lines.push(""); + + for (const r of results) { + const header = [pc.bold(r.displayName), colorConfidence(r.confidence), pc.dim(r.method)].join(" "); + lines.push(header); + + const langMarker = r.language ?? ""; + lines.push(`\`\`\`${langMarker}`); + + const highlighted = highlightContent(r.content, opts.highlightWords); + lines.push(highlighted); + + lines.push("```"); + lines.push(""); + } + + groupIndex++; + } + + return lines.join("\n").trimEnd(); +} + +function formatSimple(opts: FormatOptions): string { + const parts: string[] = []; + + for (const r of opts.results) { + const shortPath = shortenPath(r.filePath, 60); + const header = `${pc.cyan(pc.bold(shortPath))} ${r.displayName} ${colorConfidence(r.confidence)} ${pc.dim(r.method)}`; + parts.push(header); + + const highlighted = highlightContent(r.content, opts.highlightWords); + const contentLines = highlighted.split("\n"); + const hasLineNums = r.startLine != null && !Number.isNaN(r.startLine); + + for (let i = 0; i < contentLines.length; i++) { + if (hasLineNums) { + const lineNum = r.startLine + i; + parts.push(`${pc.dim(`${lineNum}|`)}${contentLines[i]}`); + } else { + parts.push(`${pc.dim("|")}${contentLines[i]}`); + } + } + + parts.push(""); + } + + return parts.join("\n").trimEnd(); +} + +function formatTableOutput(opts: FormatOptions): string { + const header = formatHeader(opts.results.length, opts.query, opts.mode); + + const rows = opts.results.map((r) => [shortenPath(r.filePath, 40), r.displayName, `${r.confidence}%`, r.method]); + + const table = formatTable(rows, ["File", "Symbol", "Confidence", "Method"], { + alignRight: [2], + }); + + return `${header}\n\n${table}`; +} + +export function formatSearchResults(opts: FormatOptions): string { + if (opts.results.length === 0) { + return "No results found."; + } + + switch (opts.format) { + case "pretty": + return formatPretty(opts); + case "simple": + return formatSimple(opts); + case "table": + return formatTableOutput(opts); + } +} diff --git a/src/utils/search/stores/sqlite-vector-store.ts b/src/utils/search/stores/sqlite-vector-store.ts index 4a05d0e80e..6b9ff21b52 100644 --- a/src/utils/search/stores/sqlite-vector-store.ts +++ b/src/utils/search/stores/sqlite-vector-store.ts @@ -39,8 +39,8 @@ export class SqliteVectorStore implements VectorStore { if (!this.warnedLargeScan && rows.length > 10_000) { console.warn( - `[SqliteVectorStore] Brute-force scanning ${rows.length} vectors. ` + - `Consider using sqlite-vec or LanceDB for better performance.` + `[indexer] Brute-force vector scan (${rows.length} vectors). ` + + `Run "tools indexer rebuild" to migrate to sqlite-vec for faster search.` ); this.warnedLargeScan = true; }