/** * Shared formatters for search/vsearch commands. * Centralizes output formatting to avoid duplication. * * @module src/cli/format/searchResults */ import { pathToFileURL } from "node:url"; import type { SearchResults } from "../../pipeline/types"; // ───────────────────────────────────────────────────────────────────────────── // Types // ───────────────────────────────────────────────────────────────────────────── export interface FormatOptions { full?: boolean; lineNumbers?: boolean; format: "terminal" | "json" | "files" | "csv" | "md" | "xml"; terminalLinks?: { isTTY: boolean; editorUriTemplate?: string | null; }; } // ───────────────────────────────────────────────────────────────────────────── // Constants // ───────────────────────────────────────────────────────────────────────────── const SNIPPET_LIMIT_TERMINAL = 200; const SNIPPET_LIMIT_STRUCTURED = 500; // ───────────────────────────────────────────────────────────────────────────── // Main Formatter // ───────────────────────────────────────────────────────────────────────────── /** * Format search results based on output format and options. */ export function formatSearchResults( data: SearchResults, options: FormatOptions ): string { switch (options.format) { case "json": return JSON.stringify(data, null, 2); case "files": return formatFiles(data); case "csv": return formatCsv(data); case "md": return formatMarkdown(data, options); case "xml": return formatXml(data, options); default: return formatTerminal(data, options); } } // ───────────────────────────────────────────────────────────────────────────── // Format Helpers // ───────────────────────────────────────────────────────────────────────────── /** * Format as line protocol per spec. * Output: #docid,score,gno://collection/path */ function formatFiles(data: SearchResults): string { return data.results .map((r) => { // Defensive: ensure docid starts with # const docid = r.docid.startsWith("#") ? r.docid : `#${r.docid}`; return `${docid},${r.score.toFixed(4)},${r.uri}`; }) .join("\n"); } function formatTerminal(data: SearchResults, options: FormatOptions): string { if (data.results.length === 0) { return "No results found."; } const lines: string[] = []; for (const r of data.results) { lines.push( `[${r.docid}] ${formatTerminalUri(r, options)} (score: ${r.score.toFixed(2)})` ); if (r.title) { lines.push(` ${r.title}`); } if (r.snippet) { const content = options.full ? r.snippet : truncate(r.snippet, SNIPPET_LIMIT_TERMINAL); // For --full, snippetRange is undefined; start at line 1 const startLine = r.snippetRange?.startLine ?? 1; const formatted = options.lineNumbers ? addLineNumbers(content, startLine) : content; // Indent multiline snippets lines.push(` ${formatted.replace(/\n/g, "\n ")}`); } lines.push(""); } lines.push( `${data.meta.totalResults} result(s) for "${data.meta.query}" (${data.meta.mode})` ); return lines.join("\n"); } function formatTerminalUri( result: SearchResults["results"][number], options: FormatOptions ): string { const links = options.terminalLinks; if (!links?.isTTY || !result.source.absPath) { return result.uri; } const target = buildTerminalLinkTarget( result.source.absPath, result.line ?? result.snippetRange?.startLine, links.editorUriTemplate ?? undefined ); if (!target) { return result.uri; } return wrapOsc8(target, result.uri); } function buildTerminalLinkTarget( absPath: string, line: number | undefined, template?: string ): string | null { if (template) { if (line === undefined && template.includes("{line}")) { return null; } const withPath = template.replaceAll("{path}", absPath); const withLine = withPath.replaceAll( "{line}", line !== undefined ? String(line) : "" ); return withLine.replaceAll("{col}", line !== undefined ? "1" : ""); } return pathToFileURL(absPath).toString(); } function wrapOsc8(target: string, label: string): string { const OSC = "\u001B]8;;"; const BEL = "\u0007"; return `${OSC}${target}${BEL}${label}${OSC}${BEL}`; } function formatMarkdown(data: SearchResults, options: FormatOptions): string { const modeLabel = data.meta.mode === "vector" ? "Vector " : ""; if (data.results.length === 0) { return `# ${modeLabel}Search Results\n\nNo results found for "${data.meta.query}".`; } const lines: string[] = []; lines.push(`# ${modeLabel}Search Results for "${data.meta.query}"`); lines.push(""); lines.push(`*${data.meta.totalResults} result(s)*`); lines.push(""); for (const r of data.results) { lines.push(`## ${r.title || r.source.relPath}`); lines.push(""); lines.push(`- **URI**: \`${r.uri}\``); lines.push(`- **Score**: ${r.score.toFixed(2)}`); lines.push(`- **DocID**: \`${r.docid}\``); if (r.snippet) { const content = options.full ? r.snippet : truncate(r.snippet, SNIPPET_LIMIT_STRUCTURED); const startLine = r.snippetRange?.startLine ?? 1; const formatted = options.lineNumbers ? addLineNumbers(content, startLine) : content; lines.push(""); lines.push("```"); lines.push(formatted); lines.push("```"); } lines.push(""); } return lines.join("\n"); } function formatCsv(data: SearchResults): string { const lines: string[] = []; lines.push("docid,score,uri,title,relPath"); for (const r of data.results) { const title = escapeCsv(r.title ?? ""); const relPath = escapeCsv(r.source.relPath); lines.push( `"${r.docid}",${r.score.toFixed(4)},"${r.uri}","${title}","${relPath}"` ); } return lines.join("\n"); } function formatXml(data: SearchResults, options: FormatOptions): string { const lines: string[] = []; lines.push(''); lines.push(""); lines.push( ` ` ); for (const r of data.results) { lines.push(" "); lines.push(` ${escapeXml(r.docid)}`); lines.push(` ${r.score}`); lines.push(` ${escapeXml(r.uri)}`); if (r.title) { lines.push(` ${escapeXml(r.title)}`); } lines.push(` ${escapeXml(r.source.relPath)}`); if (r.snippet) { const content = options.full ? r.snippet : truncate(r.snippet, SNIPPET_LIMIT_STRUCTURED); const startLine = r.snippetRange?.startLine ?? 1; const formatted = options.lineNumbers ? addLineNumbers(content, startLine) : content; lines.push(` ${escapeXml(formatted)}`); } lines.push(" "); } lines.push(""); return lines.join("\n"); } // ───────────────────────────────────────────────────────────────────────────── // Utility Functions // ───────────────────────────────────────────────────────────────────────────── function addLineNumbers(text: string, startLine: number): string { return text .split("\n") .map((line, i) => `${startLine + i}: ${line}`) .join("\n"); } function truncate(text: string, limit: number): string { return text.length > limit ? `${text.slice(0, limit)}...` : text; } function escapeCsv(str: string): string { return str.replace(/"/g, '""'); } function escapeXml(str: string): string { return str .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); }