Skip to content
Open
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
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 changes: 31 additions & 16 deletions src/qmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down
58 changes: 58 additions & 0 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down