/**
* formatter.ts - Output formatting utilities for QMD
*
* Provides methods to format search results and documents into various output formats:
* JSON, CSV, XML, Markdown, files list, and CLI (colored terminal output).
*/
import { extractSnippet } from "./store.js";
import type { SearchResult, MultiGetFile, MultiGetResult, DocumentResult } from "./store.js";
// =============================================================================
// Types
// =============================================================================
// Re-export store types for convenience
export type { SearchResult, MultiGetFile, MultiGetResult, DocumentResult };
export type OutputFormat = "cli" | "csv" | "md" | "xml" | "files" | "json";
export type FormatOptions = {
full?: boolean; // Show full document content instead of snippet
query?: string; // Query for snippet extraction and highlighting
useColor?: boolean; // Enable terminal colors (default: false for non-CLI)
};
// =============================================================================
// Escape Helpers
// =============================================================================
export function escapeCSV(value: string | null | number): string {
if (value === null || value === undefined) return "";
const str = String(value);
if (str.includes(",") || str.includes('"') || str.includes("\n")) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
export function escapeXml(str: string): string {
return str
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
// =============================================================================
// Search Results Formatters
// =============================================================================
/**
* Format search results as JSON
*/
export function searchResultsToJson(
results: SearchResult[],
opts: FormatOptions = {}
): string {
const output = results.map(row => ({
score: Math.round(row.score * 100) / 100,
file: row.displayPath,
title: row.title,
...(row.context && { context: row.context }),
...(opts.full && { body: row.body }),
...(!opts.full && opts.query && { snippet: extractSnippet(row.body, opts.query, 300, row.chunkPos).snippet }),
}));
return JSON.stringify(output, null, 2);
}
/**
* Format search results as CSV
*/
export function searchResultsToCsv(
results: SearchResult[],
opts: FormatOptions = {}
): string {
const query = opts.query || "";
const header = "score,file,title,context,line,snippet";
const rows = results.map(row => {
const { line, snippet } = extractSnippet(row.body, query, 500, row.chunkPos);
const content = opts.full ? row.body : snippet;
return [
row.score.toFixed(4),
escapeCSV(row.displayPath),
escapeCSV(row.title),
escapeCSV(row.context || ""),
line,
escapeCSV(content),
].join(",");
});
return [header, ...rows].join("\n");
}
/**
* Format search results as simple files list (score,filepath,context)
*/
export function searchResultsToFiles(results: SearchResult[]): string {
return results.map(row => {
const ctx = row.context ? `,"${row.context.replace(/"/g, '""')}"` : "";
return `${row.score.toFixed(2)},${row.displayPath}${ctx}`;
}).join("\n");
}
/**
* Format search results as Markdown
*/
export function searchResultsToMarkdown(
results: SearchResult[],
opts: FormatOptions = {}
): string {
const query = opts.query || "";
return results.map(row => {
const heading = row.title || row.displayPath;
if (opts.full) {
return `---\n# ${heading}\n\n${row.body}\n`;
} else {
const { snippet } = extractSnippet(row.body, query, 500, row.chunkPos);
return `---\n# ${heading}\n\n${snippet}\n`;
}
}).join("\n");
}
/**
* Format search results as XML
*/
export function searchResultsToXml(
results: SearchResult[],
opts: FormatOptions = {}
): string {
const query = opts.query || "";
const items = results.map(row => {
const titleAttr = row.title ? ` title="${escapeXml(row.title)}"` : "";
const content = opts.full ? row.body : extractSnippet(row.body, query, 500, row.chunkPos).snippet;
return `\n${escapeXml(content)}\n`;
});
return items.join("\n\n");
}
/**
* Format search results for MCP (simpler CSV format with pre-extracted snippets)
*/
export function searchResultsToMcpCsv(
results: { file: string; title: string; score: number; context: string | null; snippet: string }[]
): string {
const header = "file,title,score,context,snippet";
const rows = results.map(r =>
[r.file, r.title, r.score, r.context || "", r.snippet].map(escapeCSV).join(",")
);
return [header, ...rows].join("\n");
}
// =============================================================================
// Document Formatters (for multi-get using MultiGetFile from store)
// =============================================================================
/**
* Format documents as JSON
*/
export function documentsToJson(results: MultiGetFile[]): string {
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 }),
}));
return JSON.stringify(output, null, 2);
}
/**
* Format documents as CSV
*/
export function documentsToCsv(results: MultiGetFile[]): string {
const header = "file,title,context,skipped,body";
const rows = results.map(r =>
[
r.displayPath,
r.title,
r.context || "",
r.skipped ? "true" : "false",
r.skipped ? (r.skipReason || "") : r.body
].map(escapeCSV).join(",")
);
return [header, ...rows].join("\n");
}
/**
* Format documents as files list
*/
export function documentsToFiles(results: MultiGetFile[]): string {
return results.map(r => {
const ctx = r.context ? `,"${r.context.replace(/"/g, '""')}"` : "";
const status = r.skipped ? ",[SKIPPED]" : "";
return `${r.displayPath}${ctx}${status}`;
}).join("\n");
}
/**
* Format documents as Markdown
*/
export function documentsToMarkdown(results: MultiGetFile[]): string {
return results.map(r => {
let md = `## ${r.displayPath}\n\n`;
if (r.title && r.title !== r.displayPath) md += `**Title:** ${r.title}\n\n`;
if (r.context) md += `**Context:** ${r.context}\n\n`;
if (r.skipped) {
md += `> ${r.skipReason}\n`;
} else {
md += "```\n" + r.body + "\n```\n";
}
return md;
}).join("\n");
}
/**
* Format documents as XML
*/
export function documentsToXml(results: MultiGetFile[]): string {
const items = results.map(r => {
let xml = " \n";
xml += ` ${escapeXml(r.displayPath)}\n`;
xml += ` ${escapeXml(r.title)}\n`;
if (r.context) xml += ` ${escapeXml(r.context)}\n`;
if (r.skipped) {
xml += ` true\n`;
xml += ` ${escapeXml(r.skipReason || "")}\n`;
} else {
xml += ` ${escapeXml(r.body)}\n`;
}
xml += " ";
return xml;
});
return `\n\n${items.join("\n")}\n`;
}
// =============================================================================
// Single Document Formatters
// =============================================================================
/**
* Format a single DocumentResult as JSON
*/
export function documentToJson(doc: DocumentResult): string {
return JSON.stringify({
file: doc.displayPath,
title: doc.title,
...(doc.context && { context: doc.context }),
hash: doc.hash,
modifiedAt: doc.modifiedAt,
bodyLength: doc.bodyLength,
...(doc.body !== undefined && { body: doc.body }),
}, null, 2);
}
/**
* Format a single DocumentResult as Markdown
*/
export function documentToMarkdown(doc: DocumentResult): string {
let md = `# ${doc.title || doc.displayPath}\n\n`;
if (doc.context) md += `**Context:** ${doc.context}\n\n`;
md += `**File:** ${doc.displayPath}\n`;
md += `**Modified:** ${doc.modifiedAt}\n\n`;
if (doc.body !== undefined) {
md += "---\n\n" + doc.body + "\n";
}
return md;
}
/**
* Format a single DocumentResult as XML
*/
export function documentToXml(doc: DocumentResult): string {
let xml = `\n\n`;
xml += ` ${escapeXml(doc.displayPath)}\n`;
xml += ` ${escapeXml(doc.title)}\n`;
if (doc.context) xml += ` ${escapeXml(doc.context)}\n`;
xml += ` ${escapeXml(doc.hash)}\n`;
xml += ` ${escapeXml(doc.modifiedAt)}\n`;
xml += ` ${doc.bodyLength}\n`;
if (doc.body !== undefined) {
xml += ` ${escapeXml(doc.body)}\n`;
}
xml += ``;
return xml;
}
/**
* Format a single document to the specified format
*/
export function formatDocument(doc: DocumentResult, format: OutputFormat): string {
switch (format) {
case "json":
return documentToJson(doc);
case "md":
return documentToMarkdown(doc);
case "xml":
return documentToXml(doc);
default:
// Default to markdown for CLI and other formats
return documentToMarkdown(doc);
}
}
// =============================================================================
// Universal Format Function
// =============================================================================
/**
* Format search results to the specified output format
*/
export function formatSearchResults(
results: SearchResult[],
format: OutputFormat,
opts: FormatOptions = {}
): string {
switch (format) {
case "json":
return searchResultsToJson(results, opts);
case "csv":
return searchResultsToCsv(results, opts);
case "files":
return searchResultsToFiles(results);
case "md":
return searchResultsToMarkdown(results, opts);
case "xml":
return searchResultsToXml(results, opts);
case "cli":
// CLI format should be handled separately with colors
// Return a simple text version as fallback
return searchResultsToMarkdown(results, opts);
default:
return searchResultsToJson(results, opts);
}
}
/**
* Format documents to the specified output format
*/
export function formatDocuments(
results: MultiGetFile[],
format: OutputFormat
): string {
switch (format) {
case "json":
return documentsToJson(results);
case "csv":
return documentsToCsv(results);
case "files":
return documentsToFiles(results);
case "md":
return documentsToMarkdown(results);
case "xml":
return documentsToXml(results);
case "cli":
// CLI format should be handled separately with colors
return documentsToMarkdown(results);
default:
return documentsToJson(results);
}
}