export type OutputFormat = 'table' | 'json' | 'id-only'; /** Read a column, following dots into nested objects. */ function readColumn(data: Record, column: string): unknown { let value: unknown = data; for (const key of column.split('.')) { if (typeof value !== 'object' || value === null) return undefined; value = (value as Record)[key]; } return value; } /** * Header label of each column: the last segment of a dotted path. Two paths can * end in the same segment, and one `NAME` header beside another tells the reader * nothing, so a segment that is not unique keeps its full path. */ function columnLabels(columns: string[]): Record { const leaf = (column: string) => column.slice(column.lastIndexOf('.') + 1); const counts = new Map(); for (const column of columns) { const name = leaf(column); counts.set(name, (counts.get(name) ?? 0) + 1); } const labels: Record = {}; for (const column of columns) { const name = leaf(column); labels[column] = counts.get(name) === 1 ? name : column; } return labels; } /** Pick specific columns from a record for table display. */ function pickColumns(data: Record, columns: string[]): Record { const result: Record = {}; for (const col of columns) { result[col] = readColumn(data, col); } return result; } /** * Format a value for table display. Long strings are shortened to keep the * columns of a multi-row table aligned. A key-value view has nothing to align, * so it asks for the full value. */ function formatValue(value: unknown, full = false): string { if (value === null || value === undefined) return '-'; if (typeof value === 'boolean') return value ? 'true' : 'false'; if (typeof value === 'string') { if (full || value.length <= 60) return value; return value.slice(0, 57) + '...'; } if (typeof value === 'number') return String(value); if (typeof value === 'object') return JSON.stringify(value); return typeof value === 'bigint' ? String(value) : JSON.stringify(value); } /** Render a list of records as an aligned text table. */ function renderTable( data: Array>, columns?: string[], noHeader?: boolean, ): string { if (data.length === 0) return 'No results.'; const cols = columns ?? Object.keys(data[0]); const rows = data.map((row) => pickColumns(row, cols)); const labels = columnLabels(cols); // Calculate column widths const widths: Record = {}; for (const col of cols) { widths[col] = labels[col].length; for (const row of rows) { const len = formatValue(row[col]).length; if (len > widths[col]) widths[col] = len; } } // Rows const lines = rows.map((row) => cols.map((c) => formatValue(row[c]).padEnd(widths[c])).join(' '), ); if (noHeader) { return lines.join('\n'); } // Header const header = cols.map((c) => labels[c].toUpperCase().padEnd(widths[c])).join(' '); const separator = cols.map((c) => '-'.repeat(widths[c])).join(' '); return [header, separator, ...lines].join('\n'); } export interface OutputOptions { format: OutputFormat; columns?: string[]; idField?: string; noHeader?: boolean; } /** Format data for output based on the selected format. */ export function formatOutput(data: unknown, options: OutputOptions): string { const { format, columns, idField = 'id', noHeader } = options; switch (format) { case 'json': return JSON.stringify(data, null, 2); case 'id-only': { if (Array.isArray(data)) { return data .map((item: Record) => { const val = item[idField]; return val !== null && val !== undefined ? `${val as string | number}` : ''; }) .join('\n'); } if (typeof data === 'object' && data !== null && idField in data) { return String((data as Record)[idField]); } return ''; } case 'table': default: { if (Array.isArray(data)) { return renderTable(data as Array>, columns, noHeader); } // Single object: render as key-value pairs if (typeof data === 'object' && data !== null) { const entries = Object.entries(data as Record); if (noHeader) { return entries.map(([, v]) => formatValue(v, true)).join('\n'); } const maxKeyLen = Math.max(...entries.map(([k]) => k.length)); return entries .map(([k, v]) => `${k.padEnd(maxKeyLen)} ${formatValue(v, true)}`) .join('\n'); } return String(data); } } } // ─── jq-style filter ──────────────────────────────────────────── type JqSegment = | { type: 'field'; name: string } | { type: 'index'; index: number } | { type: 'iterate' }; function parseJqPath(expr: string): JqSegment[] { const segments: JqSegment[] = []; let i = 0; // Skip leading dot if (expr[0] === '.') i++; while (i < expr.length) { if (expr[i] === '[') { i++; // skip [ if (expr[i] === ']') { segments.push({ type: 'iterate' }); i++; // skip ] } else { let numStr = ''; while (i < expr.length && expr[i] !== ']') { numStr += expr[i]; i++; } segments.push({ type: 'index', index: parseInt(numStr, 10) }); i++; // skip ] } } else if (expr[i] === '.') { i++; // skip dot separator } else { let name = ''; while (i < expr.length && expr[i] !== '.' && expr[i] !== '[') { name += expr[i]; i++; } if (name) { segments.push({ type: 'field', name }); } } } return segments; } function evaluateJq(data: unknown, segments: JqSegment[], startIdx: number): unknown { let current: unknown = data; for (let i = startIdx; i < segments.length; i++) { const seg = segments[i]; if (current === null || current === undefined) return current; switch (seg.type) { case 'field': if (typeof current === 'object' && current !== null) { current = (current as Record)[seg.name]; } else { return undefined; } break; case 'index': if (Array.isArray(current)) { current = current[seg.index]; } else { return undefined; } break; case 'iterate': if (Array.isArray(current)) { return current.map((item) => evaluateJq(item, segments, i + 1)); } return undefined; } } return current; } /** * Apply a basic jq-style filter expression to data. * * Supported syntax: * . identity (return data as-is) * .field object property access * .a.b.c nested property access * .[0] array index * .[] iterate array (maps remaining path over each element) * .[].field extract field from each array element */ export function applyJqFilter(data: unknown, expression: string): unknown { const expr = expression.trim(); if (expr === '.') return data; const segments = parseJqPath(expr); return evaluateJq(data, segments, 0); }