fix(cli): prevent parser breakage on empty results across output formats
- --json: [] - --csv: header only - --xml: <results></results> - --md/--files: empty output - default CLI keeps human-readable message
This commit is contained in:
parent
6ac7c6837e
commit
7579a2c017
@ -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`, `<results></results>` 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
|
||||
|
||||
|
||||
47
src/qmd.ts
47
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("<results></results>");
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -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("<results></results>");
|
||||
});
|
||||
|
||||
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("<results></results>");
|
||||
|
||||
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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user