From 41bc3a27d8dcf83eecd9a4ba34344ceb05afe12b Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Thu, 28 May 2026 10:55:55 -0700 Subject: [PATCH] feat(get,multi-get): line-numbered + docid output, line ranges, --full-path Redesign the get/multi-get retrieval surface so callers can cite what they retrieved and request follow-up slices without piping through sed: - Output is line-numbered by default; opt out with --no-line-numbers. - Header always identifies the document by qmd:// path + #docid. The MCP get/multi_get tools default lineNumbers=true to match. - qmd get and the MCP get tool accept a :from:count suffix on a path or docid (e.g. '#abc123:120:40' reads 40 lines from line 120). Explicit --from/-l flags still override the suffix. - qmd multi-get now includes #docid in every output format (--md, --json, --csv, --xml, --files, default CLI), matching qmd search. - New --full-path flag swaps the qmd:// + docid header for the document's on-disk path (handy for piping into Read/Edit/editors); falls back to the canonical header when the file no longer exists. --- src/cli/qmd.ts | 155 ++++++++++++++++++++++++++++++++++++---------- src/mcp/server.ts | 27 +++++--- test/cli.test.ts | 134 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 274 insertions(+), 42 deletions(-) diff --git a/src/cli/qmd.ts b/src/cli/qmd.ts index 32797f2..c0d88ce 100755 --- a/src/cli/qmd.ts +++ b/src/cli/qmd.ts @@ -951,15 +951,26 @@ function contextRemove(pathArg: string): void { console.log(`${c.green}✓${c.reset} Removed context for: qmd://${detected.collectionName}/${detected.relativePath}`); } -function getDocument(filename: string, fromLine?: number, maxLines?: number, lineNumbers?: boolean): void { - // Parse :linenum suffix from filename (e.g., "file.md:100") +function getDocument(filename: string, fromLine?: number, maxLines?: number, lineNumbers?: boolean, fullPath: boolean = false): void { + // Parse :line suffix from filename. Two forms: + // "file.md:100" -> start at line 100 + // "file.md:100:40" -> start at line 100, read 40 lines + // The :// in virtual paths is never matched because we anchor digits to $. + // Explicit --from/-l flags always win over values parsed from the path. let inputPath = filename; - const colonMatch = inputPath.match(/:(\d+)$/); - if (colonMatch && !fromLine) { - const matched = colonMatch[1]; - if (matched) { - fromLine = parseInt(matched, 10); - inputPath = inputPath.slice(0, -colonMatch[0].length); + const rangeMatch = inputPath.match(/:(\d+):(\d+)$/); + if (rangeMatch) { + if (fromLine === undefined) fromLine = parseInt(rangeMatch[1]!, 10); + if (maxLines === undefined) maxLines = parseInt(rangeMatch[2]!, 10); + inputPath = inputPath.slice(0, -rangeMatch[0].length); + } else { + const colonMatch = inputPath.match(/:(\d+)$/); + if (colonMatch) { + const matched = colonMatch[1]; + if (matched) { + if (fromLine === undefined) fromLine = parseInt(matched, 10); + inputPath = inputPath.slice(0, -colonMatch[0].length); + } } } if (fromLine !== undefined) fromLine = Math.max(1, fromLine); @@ -1113,6 +1124,30 @@ function getDocument(filename: string, fromLine?: number, maxLines?: number, lin // Get context for this file const context = getContextForPath(db, doc.collectionName, doc.path); + // Resolve the docid (first 6 chars of the content hash) so callers always + // know what they retrieved and can cite it back to `get`/`multi-get`. + const hashRow = db.prepare(` + SELECT d.hash as hash + FROM documents d + WHERE d.collection = ? AND d.path = ? AND d.active = 1 + `).get(doc.collectionName, doc.path) as { hash: string } | null; + const docid = hashRow?.hash ? hashRow.hash.slice(0, 6) : undefined; + const canonicalPath = buildVirtualPath(doc.collectionName, doc.path); + + // --full-path: show the on-disk path instead of the qmd:// URL + docid, when + // the file actually exists. Fall back to the canonical header otherwise. + let header: string; + if (fullPath) { + const fsPath = resolveVirtualPath(db, canonicalPath); + if (fsPath && existsSync(fsPath)) { + header = fsPath; + } else { + header = docid ? `${canonicalPath} #${docid}` : canonicalPath; + } + } else { + header = docid ? `${canonicalPath} #${docid}` : canonicalPath; + } + let output = doc.body; const startLine = fromLine || 1; @@ -1124,21 +1159,25 @@ function getDocument(filename: string, fromLine?: number, maxLines?: number, lin output = lines.slice(start, end).join('\n'); } - // Add line numbers if requested + // Line numbers are on by default (disable with --no-line-numbers) so the + // model can cite exact lines and request follow-up ranges via path:from:count. if (lineNumbers) { output = addLineNumbers(output, startLine); } - // Output context header if exists + // Header: identify the document (path + docid, or the on-disk path with + // --full-path), then optional context. + console.log(header); if (context) { - console.log(`Folder Context: ${context}\n---\n`); + console.log(`Folder Context: ${context}`); } + console.log("---\n"); console.log(output); closeDb(); } // Multi-get: fetch multiple documents by glob pattern or comma-separated list -function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT_MULTI_GET_MAX_BYTES, format: OutputFormat = "cli"): void { +function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT_MULTI_GET_MAX_BYTES, format: OutputFormat = "cli", lineNumbers: boolean = true, fullPath: boolean = false): void { const db = getDb(); // Check if it's a comma-separated list or a glob pattern @@ -1226,7 +1265,7 @@ function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT } // Collect results for structured output - const results: { file: string; displayPath: string; title: string; body: string; context: string | null; skipped: boolean; skipReason?: string }[] = []; + const results: { file: string; displayPath: string; fsPath?: string; docid?: string; title: string; body: string; context: string | null; skipped: boolean; skipReason?: string }[] = []; for (const file of files) { // Parse virtual path to get collection info if not already available @@ -1244,11 +1283,28 @@ function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT // Get context using collection-scoped function const context = collection && path ? getContextForPath(db, collection, path) : null; + // Resolve docid (first 6 chars of content hash) so every entry can be cited. + const docidRow = collection && path ? db.prepare(` + SELECT d.hash as hash + FROM documents d + WHERE d.collection = ? AND d.path = ? AND d.active = 1 + `).get(collection, path) as { hash: string } | null : null; + const docid = docidRow?.hash ? docidRow.hash.slice(0, 6) : undefined; + + // --full-path: resolve the on-disk path when it exists (else fall back). + let fsPath: string | undefined; + if (fullPath) { + const resolved = resolveVirtualPath(db, file.filepath); + if (resolved && existsSync(resolved)) fsPath = resolved; + } + // Check size limit if (file.bodyLength > maxBytes) { results.push({ file: file.filepath, displayPath: file.displayPath, + fsPath, + docid, title: file.displayPath.split('/').pop() || file.displayPath, body: "", context, @@ -1281,9 +1337,16 @@ function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT } } + // Line numbers on by default (disable with --no-line-numbers). + if (lineNumbers) { + body = addLineNumbers(body); + } + results.push({ file: file.filepath, displayPath: file.displayPath, + fsPath, + docid, title: doc.title || file.displayPath.split('/').pop() || file.displayPath, body, context, @@ -1293,14 +1356,23 @@ function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT closeDb(); + // --full-path replaces the qmd:// path + docid with the on-disk path (when it + // resolved). Per result: pick the identifier and whether to show the docid. + const identOf = (r: typeof results[number]): string => (fullPath && r.fsPath) ? r.fsPath : r.displayPath; + const docidOf = (r: typeof results[number]): string | undefined => (fullPath && r.fsPath) ? undefined : r.docid; + // Output based on format if (format === "json") { - const output = results.map(r => ({ - file: r.displayPath, - title: r.title, - ...(r.context && { context: r.context }), - ...(r.skipped ? { skipped: true, reason: r.skipReason } : { body: r.body }), - })); + const output = results.map(r => { + const docidVal = docidOf(r); + return { + file: identOf(r), + ...(docidVal && { docid: `#${docidVal}` }), + title: r.title, + ...(r.context && { context: r.context }), + ...(r.skipped ? { skipped: true, reason: r.skipReason } : { body: r.body }), + }; + }); console.log(JSON.stringify(output, null, 2)); } else if (format === "csv") { const escapeField = (val: string | null | undefined): string => { @@ -1311,19 +1383,24 @@ function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT } return str; }; - console.log("file,title,context,skipped,body"); + console.log("docid,file,title,context,skipped,body"); for (const r of results) { - console.log([r.displayPath, r.title, r.context, r.skipped ? "true" : "false", r.skipped ? r.skipReason : r.body].map(escapeField).join(",")); + const docidVal = docidOf(r); + console.log([docidVal ? `#${docidVal}` : "", identOf(r), r.title, r.context, r.skipped ? "true" : "false", r.skipped ? r.skipReason : r.body].map(escapeField).join(",")); } } else if (format === "files") { for (const r of results) { + const docidVal = docidOf(r); + const id = docidVal ? `#${docidVal} ` : ""; const ctx = r.context ? `,"${r.context.replace(/"/g, '""')}"` : ""; const status = r.skipped ? "[SKIPPED]" : ""; - console.log(`${r.displayPath}${ctx}${status ? `,${status}` : ""}`); + console.log(`${id}${identOf(r)}${ctx}${status ? `,${status}` : ""}`); } } else if (format === "md") { for (const r of results) { - console.log(`## ${r.displayPath}\n`); + const docidVal = docidOf(r); + console.log(`## ${identOf(r)}\n`); + if (docidVal) console.log(`**docid:** \`#${docidVal}\`\n`); if (r.title && r.title !== r.displayPath) console.log(`**Title:** ${r.title}\n`); if (r.context) console.log(`**Context:** ${r.context}\n`); if (r.skipped) { @@ -1338,8 +1415,10 @@ function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT console.log(''); console.log(""); for (const r of results) { - console.log(" "); - console.log(` ${escapeXml(r.displayPath)}`); + const docidVal = docidOf(r); + const docidAttr = docidVal ? ` docid="#${docidVal}"` : ""; + console.log(` `); + console.log(` ${escapeXml(identOf(r))}`); console.log(` ${escapeXml(r.title)}`); if (r.context) console.log(` ${escapeXml(r.context)}`); if (r.skipped) { @@ -1354,8 +1433,10 @@ function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT } else { // CLI format (default) for (const r of results) { + const docidVal = docidOf(r); + const id = docidVal ? ` #${docidVal}` : ""; console.log(`\n${'='.repeat(60)}`); - console.log(`File: ${r.displayPath}`); + console.log(`File: ${identOf(r)}${id}`); console.log(`${'='.repeat(60)}\n`); if (r.skipped) { @@ -2726,7 +2807,9 @@ function parseCLI() { l: { type: "string" }, // max lines from: { type: "string" }, // start line "max-bytes": { type: "string" }, // max bytes for multi-get - "line-numbers": { type: "boolean" }, // add line numbers to output + "line-numbers": { type: "boolean" }, // add line numbers to output (search; default on for get/multi-get) + "no-line-numbers": { type: "boolean" }, // disable line numbers for get/multi-get + "full-path": { type: "boolean" }, // get/multi-get: show on-disk path instead of qmd:// + docid // Query options "candidate-limit": { type: "string", short: "C" }, "no-rerank": { type: "boolean", default: false }, @@ -3222,7 +3305,7 @@ function showHelp(): void { console.log(" qmd query 'lex:..\\nvec:...' - Structured query document (you provide lex/vec/hyde lines)"); console.log(" qmd search - Full-text BM25 keywords (no LLM)"); console.log(" qmd vsearch - Vector similarity only"); - console.log(" qmd get [:line] [-l N] - Show a single document, optional line slice"); + console.log(" qmd get [:from[:count]] - Show a document (line-numbered; #docid in header)"); console.log(" qmd multi-get - Batch fetch via glob or comma-separated list"); console.log(" qmd skills list/get/path - List and retrieve bundled runtime skills"); console.log(" qmd skill show/install - Show or install the QMD skill"); @@ -3297,7 +3380,9 @@ function showHelp(): void { console.log(" -C, --candidate-limit - Max candidates to rerank (default 40, lower = faster)"); console.log(" --no-rerank - Skip LLM reranking (use RRF scores only, much faster on CPU)"); console.log(" --no-gpu - Force CPU mode for llama.cpp operations (same as QMD_FORCE_CPU=1)"); - console.log(" --line-numbers - Include line numbers in output"); + console.log(" --line-numbers - Include line numbers (search; get/multi-get are on by default)"); + console.log(" --no-line-numbers - Disable line numbers for get/multi-get"); + console.log(" --full-path - get/multi-get: show on-disk path instead of qmd:// + docid (if file exists)"); console.log(" --explain - Include retrieval score traces (query --json/CLI)"); console.log(" --files | --json | --csv | --md | --xml - Output format"); console.log(" -c, --collection - Filter by one or more collections"); @@ -4023,24 +4108,28 @@ if (isMain) { case "get": { if (!cli.args[0]) { - console.error("Usage: qmd get [:line] [--from ] [-l ] [--line-numbers]"); + console.error("Usage: qmd get [:from[:count]] [--from ] [-l ] [--no-line-numbers] [--full-path]"); process.exit(1); } const fromLine = cli.values.from ? parseInt(cli.values.from as string, 10) : undefined; const maxLines = cli.values.l ? parseInt(cli.values.l as string, 10) : undefined; - getDocument(cli.args[0], fromLine, maxLines, cli.opts.lineNumbers); + // Line numbers default ON for get; opt out with --no-line-numbers. + const getLineNumbers = !cli.values["no-line-numbers"]; + getDocument(cli.args[0], fromLine, maxLines, getLineNumbers, !!cli.values["full-path"]); break; } case "multi-get": { if (!cli.args[0]) { - console.error("Usage: qmd multi-get [-l ] [--max-bytes ] [--json|--csv|--md|--xml|--files]"); + console.error("Usage: qmd multi-get [-l ] [--max-bytes ] [--no-line-numbers] [--full-path] [--json|--csv|--md|--xml|--files]"); console.error(" pattern: glob (e.g., 'journals/2025-05*.md') or comma-separated list"); process.exit(1); } const maxLinesMulti = cli.values.l ? parseInt(cli.values.l as string, 10) : undefined; const maxBytes = cli.values["max-bytes"] ? parseInt(cli.values["max-bytes"] as string, 10) : DEFAULT_MULTI_GET_MAX_BYTES; - multiGet(cli.args[0], maxLinesMulti, maxBytes, cli.opts.format); + // Line numbers default ON for multi-get; opt out with --no-line-numbers. + const mgLineNumbers = !cli.values["no-line-numbers"]; + multiGet(cli.args[0], maxLinesMulti, maxBytes, cli.opts.format, mgLineNumbers, !!cli.values["full-path"]); break; } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index c18fe86..46e9040 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -372,20 +372,29 @@ Intent-aware lex (C++ performance, not sports): description: "Retrieve the full content of a document by its file path or docid. Use paths or docids (#abc123) from search results. Suggests similar files if not found.", annotations: { readOnlyHint: true, openWorldHint: false }, inputSchema: { - file: z.string().describe("File path or docid from search results (e.g., 'pages/meeting.md', '#abc123', or 'pages/meeting.md:100' to start at line 100)"), + file: z.string().describe("File path or docid from search results. Supports a line-range suffix: 'pages/meeting.md:100' starts at line 100; 'pages/meeting.md:100:40' (or '#abc123:100:40') reads 40 lines from line 100."), fromLine: z.number().optional().describe("Start from this line number (1-indexed)"), maxLines: z.number().optional().describe("Maximum number of lines to return"), - lineNumbers: z.boolean().optional().default(false).describe("Add line numbers to output (format: 'N: content')"), + lineNumbers: z.boolean().optional().default(true).describe("Add line numbers to output (format: 'N: content'). On by default; set false for raw content."), }, }, async ({ file, fromLine, maxLines, lineNumbers }) => { - // Support :line suffix in `file` (e.g. "foo.md:120") when fromLine isn't provided + // Support :line and :from:count suffixes in `file` (e.g. "foo.md:120" or + // "foo.md:120:40"). Explicit fromLine/maxLines args take precedence. let parsedFromLine = fromLine; + let parsedMaxLines = maxLines; let lookup = file; - const colonMatch = lookup.match(/:(\d+)$/); - if (colonMatch && colonMatch[1] && parsedFromLine === undefined) { - parsedFromLine = parseInt(colonMatch[1], 10); - lookup = lookup.slice(0, -colonMatch[0].length); + const rangeMatch = lookup.match(/:(\d+):(\d+)$/); + if (rangeMatch) { + if (parsedFromLine === undefined) parsedFromLine = parseInt(rangeMatch[1]!, 10); + if (parsedMaxLines === undefined) parsedMaxLines = parseInt(rangeMatch[2]!, 10); + lookup = lookup.slice(0, -rangeMatch[0].length); + } else { + const colonMatch = lookup.match(/:(\d+)$/); + if (colonMatch && colonMatch[1] && parsedFromLine === undefined) { + parsedFromLine = parseInt(colonMatch[1], 10); + lookup = lookup.slice(0, -colonMatch[0].length); + } } if (parsedFromLine !== undefined) parsedFromLine = Math.max(1, parsedFromLine); @@ -402,7 +411,7 @@ Intent-aware lex (C++ performance, not sports): }; } - const body = await store.getDocumentBody(result.filepath, { fromLine: parsedFromLine, maxLines }) ?? ""; + const body = await store.getDocumentBody(result.filepath, { fromLine: parsedFromLine, maxLines: parsedMaxLines }) ?? ""; let text = body; if (lineNumbers) { const startLine = parsedFromLine || 1; @@ -441,7 +450,7 @@ Intent-aware lex (C++ performance, not sports): pattern: z.string().describe("Glob pattern or comma-separated list of file paths"), maxLines: z.number().optional().describe("Maximum lines per file"), maxBytes: z.number().optional().default(10240).describe("Skip files larger than this (default: 10240 = 10KB)"), - lineNumbers: z.boolean().optional().default(false).describe("Add line numbers to output (format: 'N: content')"), + lineNumbers: z.boolean().optional().default(true).describe("Add line numbers to output (format: 'N: content'). On by default; set false for raw content."), }, }, async ({ pattern, maxLines, maxBytes, lineNumbers }) => { diff --git a/test/cli.test.ts b/test/cli.test.ts index 2f0eca7..bad9045 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -867,6 +867,54 @@ describe("CLI Multi-Get Command", () => { expect(stdout).toContain("Test Project"); expect(stdout).toContain("Team Meeting"); }); + + test("--md output includes a #docid for each file", async () => { + const { stdout, exitCode } = await runQmd(["multi-get", "notes/*.md", "--md"], { dbPath: localDbPath }); + expect(exitCode).toBe(0); + // Every result carries a docid line, consistent with `search --md`. + expect(stdout).toMatch(/\*\*docid:\*\* `#[a-f0-9]{6}`/); + }); + + test("--json output includes a #docid for each file", async () => { + const { stdout, exitCode } = await runQmd(["multi-get", "notes/*.md", "--json"], { dbPath: localDbPath }); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout); + expect(parsed.length).toBeGreaterThan(0); + for (const entry of parsed) { + expect(entry.docid).toMatch(/^#[a-f0-9]{6}$/); + } + }); + + test("shows line numbers by default and --no-line-numbers disables them", async () => { + const withNums = await runQmd(["multi-get", "README.md"], { dbPath: localDbPath }); + expect(withNums.exitCode).toBe(0); + expect(withNums.stdout).toMatch(/^1: /m); + + const raw = await runQmd(["multi-get", "README.md", "--no-line-numbers"], { dbPath: localDbPath }); + expect(raw.exitCode).toBe(0); + expect(raw.stdout).not.toMatch(/^1: /m); + }); + + test("--full-path --md shows on-disk paths and drops the docid", async () => { + const { stdout, exitCode } = await runQmd(["multi-get", "notes/*.md", "--md", "--full-path"], { dbPath: localDbPath }); + expect(exitCode).toBe(0); + // Headings are absolute filesystem paths under the fixtures dir. + expect(stdout).toContain(`## ${fixturesDir}`); + expect(stdout).toContain("notes/meeting.md"); + expect(stdout).not.toContain("qmd://"); + expect(stdout).not.toMatch(/\*\*docid:\*\*/); + }); + + test("--full-path --json puts the fs path in `file` and omits docid", async () => { + const { stdout, exitCode } = await runQmd(["multi-get", "notes/*.md", "--json", "--full-path"], { dbPath: localDbPath }); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout); + expect(parsed.length).toBeGreaterThan(0); + for (const entry of parsed) { + expect(entry.file.startsWith(fixturesDir)).toBe(true); + expect(entry.docid).toBeUndefined(); + } + }); }); describe("CLI Update Command", () => { @@ -1719,6 +1767,92 @@ describe("get command path normalization", () => { // Should start from line 3, not line 1 expect(stdout).not.toMatch(/^# Test Document 1$/m); }); + + test("get with path:from:count format reads a bounded range", async () => { + // Lines: 1 "# Test Document 1", 5 "It has multiple lines...", + // 6 "Line 6 is here.", 7 "Line 7 is here." + const { stdout, exitCode } = await runQmd(["get", `${collName}/test1.md:5:2`], { dbPath: localDbPath, configDir: localConfigDir }); + expect(exitCode).toBe(0); + expect(stdout).toContain("It has multiple lines"); + expect(stdout).toContain("Line 6 is here."); + // Bounded to 2 lines: must not include the start of the file or line 7 + expect(stdout).not.toMatch(/^# Test Document 1$/m); + expect(stdout).not.toContain("Line 7 is here."); + }); + + test("get with qmd://path:from:count format reads a bounded range", async () => { + const { stdout, exitCode } = await runQmd(["get", `qmd://${collName}/test1.md:5:2`], { dbPath: localDbPath, configDir: localConfigDir }); + expect(exitCode).toBe(0); + expect(stdout).toContain("It has multiple lines"); + expect(stdout).toContain("Line 6 is here."); + expect(stdout).not.toMatch(/^# Test Document 1$/m); + expect(stdout).not.toContain("Line 7 is here."); + }); + + test("explicit -l overrides the :count in path:from:count", async () => { + const { stdout, exitCode } = await runQmd(["get", `${collName}/test1.md:5:2`, "-l", "1"], { dbPath: localDbPath, configDir: localConfigDir }); + expect(exitCode).toBe(0); + expect(stdout).toContain("It has multiple lines"); + expect(stdout).not.toContain("Line 6 is here."); + }); + + test("get header includes canonical qmd:// path and a #docid", async () => { + const { stdout, exitCode } = await runQmd(["get", `${collName}/test1.md`], { dbPath: localDbPath, configDir: localConfigDir }); + expect(exitCode).toBe(0); + // First line of output identifies the document by path + docid. + expect(stdout).toMatch(new RegExp(`^qmd://${collName}/test1\\.md\\s+#[a-f0-9]{6}`, "m")); + }); + + test("get shows line numbers by default", async () => { + const { stdout, exitCode } = await runQmd(["get", `${collName}/test1.md`], { dbPath: localDbPath, configDir: localConfigDir }); + expect(exitCode).toBe(0); + expect(stdout).toMatch(/^1: # Test Document 1$/m); + expect(stdout).toMatch(/^6: Line 6 is here\.$/m); + }); + + test("get --no-line-numbers returns raw content", async () => { + const { stdout, exitCode } = await runQmd(["get", `${collName}/test1.md`, "--no-line-numbers"], { dbPath: localDbPath, configDir: localConfigDir }); + expect(exitCode).toBe(0); + expect(stdout).not.toMatch(/^1: /m); + expect(stdout).toMatch(/^# Test Document 1$/m); + }); + + test("get line numbers reflect the start line of a range", async () => { + const { stdout, exitCode } = await runQmd(["get", `${collName}/test1.md:5:2`], { dbPath: localDbPath, configDir: localConfigDir }); + expect(exitCode).toBe(0); + // Numbering starts at the requested line, not at 1. + expect(stdout).toMatch(/^5: It has multiple lines/m); + expect(stdout).not.toMatch(/^1: /m); + }); + + test("get --full-path shows the on-disk path instead of qmd:// + docid", async () => { + const { stdout, exitCode } = await runQmd(["get", `${collName}/test1.md`, "--full-path"], { dbPath: localDbPath, configDir: localConfigDir }); + expect(exitCode).toBe(0); + // Header is an absolute filesystem path ending in the file; no qmd:// URL, no docid. + // (Use a loose match so macOS /var → /private/var symlink normalization is fine.) + expect(stdout).toMatch(/^\/.+\/test1\.md$/m); + expect(stdout).toContain("test1.md"); + expect(stdout).not.toContain("qmd://"); + expect(stdout).not.toMatch(/#[a-f0-9]{6}/); + // Body still present and line-numbered. + expect(stdout).toMatch(/^1: # Test Document 1$/m); + }); + + test("get --full-path falls back to qmd:// + docid when the file is gone", async () => { + // Index a doc, then delete the underlying file so the fs path no longer exists. + const env = await createIsolatedTestEnv("full-path-fallback"); + const collectionDir = join(testDir, `gone-fixtures-${Date.now()}`); + await mkdir(collectionDir, { recursive: true }); + const gonePath = join(collectionDir, "gone.md"); + await writeFile(gonePath, "# Gone\n\nbody line\n"); + const add = await runQmd(["collection", "add", collectionDir, "--name", "gonecoll"], { dbPath: env.dbPath, configDir: env.configDir }); + expect(add.exitCode).toBe(0); + await rm(gonePath); + + const { stdout, exitCode } = await runQmd(["get", "gonecoll/gone.md", "--full-path"], { dbPath: env.dbPath, configDir: env.configDir }); + expect(exitCode).toBe(0); + expect(stdout).toMatch(new RegExp(`^qmd://gonecoll/gone\\.md\\s+#[a-f0-9]{6}`, "m")); + }); }); // =============================================================================