diff --git a/CHANGELOG.md b/CHANGELOG.md index 212be32..fc4526a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +### Fixes + +- CLI: return format-safe empty outputs for `search`, `vsearch`, and `query` + when no results are found (or when `--min-score` filters all results): + `[]` for `--json`, CSV header-only for `--csv`, `` for + `--xml`, and empty output for `--md`/`--files`. #183 + ## [1.0.8] - 2026-02-19 QMD now speaks in **query documents** — structured multi-line queries where each line is typed (`lex:`, `vec:`, `hyde:`, `expand:`), combining keyword precision with semantic recall. A single plain query still works exactly as before. AI agents using the MCP tool get significantly richer search capability: lex now supports quoted phrases and negation (`"C++ performance" -sports -athlete`), making intent-aware disambiguation practical. @@ -358,4 +365,3 @@ notes, journals, and meeting transcripts. [Unreleased]: https://github.com/tobi/qmd/compare/v1.0.0...HEAD [1.0.0]: https://github.com/tobi/qmd/releases/tag/v1.0.0 [0.9.0]: https://github.com/tobi/qmd/compare/v0.8.0...v0.9.0 - diff --git a/src/qmd.ts b/src/qmd.ts index d57b7e8..df3b80e 100755 --- a/src/qmd.ts +++ b/src/qmd.ts @@ -1783,11 +1783,38 @@ function shortPath(dirpath: string): string { return dirpath; } +type EmptySearchReason = "no_results" | "min_score"; + +// Emit format-safe empty output for search commands. +function printEmptySearchResults(format: OutputFormat, reason: EmptySearchReason = "no_results"): void { + if (format === "json") { + console.log("[]"); + return; + } + if (format === "csv") { + console.log("docid,score,file,title,context,line,snippet"); + return; + } + if (format === "xml") { + console.log(""); + return; + } + if (format === "md" || format === "files") { + return; + } + + if (reason === "min_score") { + console.log("No results found above minimum score threshold."); + return; + } + console.log("No results found."); +} + function outputResults(results: { file: string; displayPath: string; title: string; body: string; score: number; context?: string | null; chunkPos?: number; hash?: string; docid?: string }[], query: string, opts: OutputOptions): void { const filtered = results.filter(r => r.score >= opts.minScore).slice(0, opts.limit); if (filtered.length === 0) { - console.log("No results found above minimum score threshold."); + printEmptySearchResults(opts.format, "min_score"); return; } @@ -2022,11 +2049,7 @@ function search(query: string, opts: OutputOptions): void { closeDb(); if (resultsWithContext.length === 0) { - if (opts.format === "json") { - console.log("[]"); - } else { - console.log("No results found."); - } + printEmptySearchResults(opts.format); return; } outputResults(resultsWithContext, query, opts); @@ -2081,11 +2104,7 @@ async function vectorSearch(query: string, opts: OutputOptions, _model: string = closeDb(); if (results.length === 0) { - if (opts.format === "json") { - console.log("[]"); - } else { - console.log("No results found."); - } + printEmptySearchResults(opts.format); return; } @@ -2198,11 +2217,7 @@ async function querySearch(query: string, opts: OutputOptions, _embedModel: stri closeDb(); if (results.length === 0) { - if (opts.format === "json") { - console.log("[]"); - } else { - console.log("No results found."); - } + printEmptySearchResults(opts.format); return; } diff --git a/test/cli.test.ts b/test/cli.test.ts index b723e7d..658c7f4 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -314,6 +314,64 @@ describe("CLI Search Command", () => { expect(stdout).toContain("No results"); }); + test("returns empty JSON array for non-matching query with --json", async () => { + const { stdout, exitCode } = await runQmd(["search", "xyznonexistent123", "--json"]); + expect(exitCode).toBe(0); + expect(JSON.parse(stdout)).toEqual([]); + }); + + test("returns CSV header only for non-matching query with --csv", async () => { + const { stdout, exitCode } = await runQmd(["search", "xyznonexistent123", "--csv"]); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe("docid,score,file,title,context,line,snippet"); + }); + + test("returns empty XML container for non-matching query with --xml", async () => { + const { stdout, exitCode } = await runQmd(["search", "xyznonexistent123", "--xml"]); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe(""); + }); + + test("returns empty output for non-matching query with --md", async () => { + const { stdout, exitCode } = await runQmd(["search", "xyznonexistent123", "--md"]); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe(""); + }); + + test("returns empty output for non-matching query with --files", async () => { + const { stdout, exitCode } = await runQmd(["search", "xyznonexistent123", "--files"]); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe(""); + }); + + test("returns min-score threshold message for default CLI output", async () => { + const { stdout, exitCode } = await runQmd(["search", "test", "--min-score", "2"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("No results found above minimum score threshold."); + }); + + test("returns format-safe empty output when --min-score filters all results", async () => { + const json = await runQmd(["search", "test", "--json", "--min-score", "2"]); + expect(json.exitCode).toBe(0); + expect(JSON.parse(json.stdout)).toEqual([]); + + const csv = await runQmd(["search", "test", "--csv", "--min-score", "2"]); + expect(csv.exitCode).toBe(0); + expect(csv.stdout.trim()).toBe("docid,score,file,title,context,line,snippet"); + + const xml = await runQmd(["search", "test", "--xml", "--min-score", "2"]); + expect(xml.exitCode).toBe(0); + expect(xml.stdout.trim()).toBe(""); + + const md = await runQmd(["search", "test", "--md", "--min-score", "2"]); + expect(md.exitCode).toBe(0); + expect(md.stdout.trim()).toBe(""); + + const files = await runQmd(["search", "test", "--files", "--min-score", "2"]); + expect(files.exitCode).toBe(0); + expect(files.stdout.trim()).toBe(""); + }); + test("requires query argument", async () => { const { stdout, stderr, exitCode } = await runQmd(["search"]); expect(exitCode).toBe(1);