From ef22ec586b5a2f33f638c98d8d5b8f794bf79ca1 Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Thu, 2 Apr 2026 14:39:40 -0400 Subject: [PATCH] output formats --- src/factory.ts | 12 +++- src/output.ts | 90 ++++++++++++++++++++++++ test/factory.test.ts | 163 +++++++++++++++++++++++++++++++++++++++++++ test/output.test.ts | 150 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 src/output.ts create mode 100644 test/output.test.ts diff --git a/src/factory.ts b/src/factory.ts index 6f29e33c..69120b79 100644 --- a/src/factory.ts +++ b/src/factory.ts @@ -11,6 +11,7 @@ import type { ResolvedConfig } from './config/types.ts' import { getResolvedConfig } from './config/store.ts' import { extractSchemaArgs, validateSchemaArgs } from './lib/schema-args.ts' import type { SchemaArgDefinition } from './lib/schema-args.ts' +import { renderText } from './output.ts' /** pre-built schema for coercing string → number, reused per option invocation */ const numberSchema = z.coerce.number() @@ -121,6 +122,13 @@ export interface CommandConfig { * stdin or file, validates against the schema, then passes the typed result to the handler. */ input?: T + /** + * optional text renderer for non-JSON output mode. + * when provided, called with the handler result and the full parsed result to produce a string + * written to stdout. when omitted, the factory auto-renders via {@link renderText}. + * never called when `--format=json` is active. + */ + formatOutput?: (result: JsonValue, parsed: ParsedResult>) => string } /** @@ -530,8 +538,10 @@ export function defineCommand(config: CommandConfig): Op assert(handlerResult !== undefined, `command ${JSON.stringify(config.name)}: handler must return a JsonValue`) if (fmt === 'json') { process.stdout.write(JSON.stringify(handlerResult) + '\n') + } else if (config.formatOutput !== undefined) { + process.stdout.write(config.formatOutput(handlerResult, parsed)) } else { - process.stdout.write(JSON.stringify(handlerResult, null, 2) + '\n') + process.stdout.write(renderText(handlerResult)) } }) diff --git a/src/output.ts b/src/output.ts new file mode 100644 index 00000000..ca5c4547 --- /dev/null +++ b/src/output.ts @@ -0,0 +1,90 @@ +/** + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { JsonValue } from './factory.ts' + +/** A flat object whose values are all JSON primitives — renderable as a table row. */ +type FlatRecord = Record + +/** Returns true when `val` is a non-null, non-array object with only primitive values. */ +function isFlatObject(val: JsonValue): val is FlatRecord { + if (val === null || typeof val !== 'object' || Array.isArray(val)) return false + return Object.values(val).every((v) => v === null || typeof v !== 'object') +} + +/** Returns true when `val` is a JSON primitive (string, number, boolean, or null). */ +function isPrimitive(val: JsonValue): val is string | number | boolean | null { + return val === null || typeof val !== 'object' +} + +/** + * Renders an array of flat objects as a plain-text column-aligned table. + * + * Column widths are the maximum of the header length and the widest cell value. + * Columns are separated by two spaces. Trailing whitespace is trimmed from each line. + * Returns an empty string for an empty array. + * + * @example + * ```ts + * renderTable([{ name: 'foo', count: 3 }, { name: 'bar', count: 12 }]) + * // name count + * // ---- ----- + * // foo 3 + * // bar 12 + * ``` + */ +export function renderTable(rows: FlatRecord[]): string { + if (rows.length === 0) return '' + + const headers = Object.keys(rows[0]!) + const colWidths = headers.map((h) => { + const maxVal = Math.max(...rows.map((r) => String(r[h] ?? '').length)) + return Math.max(h.length, maxVal) + }) + + const formatRow = (cells: string[]): string => + cells.map((cell, i) => cell.padEnd(colWidths[i]!)).join(' ').trimEnd() + + const headerRow = formatRow(headers) + const separator = colWidths.map((w) => '-'.repeat(w)).join(' ').trimEnd() + const dataRows = rows.map((row) => + formatRow(headers.map((h) => String(row[h] ?? ''))) + ) + + return [headerRow, separator, ...dataRows].join('\n') + '\n' +} + +/** + * Auto-renders a `JsonValue` as human-readable terminal text. + * + * Rendering rules (simplest match wins): + * - **Primitives** (`string | number | boolean | null`): printed as their string representation + * - **Array of flat objects** (all values are primitives): rendered as a column-aligned table via {@link renderTable} + * - **Array of primitives**: one item per line + * - **Empty array**: single newline + * - **Everything else**: falls back to pretty-printed JSON + * + * Command handlers that need richer control should supply a `formatOutput` function + * on their `CommandConfig` rather than relying on this auto-renderer. + */ +export function renderText(value: JsonValue): string { + if (isPrimitive(value)) { + return String(value) + '\n' + } + + if (Array.isArray(value)) { + if (value.length === 0) return '\n' + + if (value.every(isFlatObject)) { + return renderTable(value) + } + + if (value.every(isPrimitive)) { + return value.map((v) => String(v)).join('\n') + '\n' + } + } + + return JSON.stringify(value, null, 2) + '\n' +} diff --git a/test/factory.test.ts b/test/factory.test.ts index 0e622e42..3d04233c 100644 --- a/test/factory.test.ts +++ b/test/factory.test.ts @@ -1613,7 +1613,170 @@ describe('defineCommand', () => { }) }) +describe('text output rendering', () => { + async function captureOutput(fn: () => Promise): Promise { + let out = '' + const orig = process.stdout.write.bind(process.stdout) + process.stdout.write = (chunk: unknown) => { out += String(chunk); return true } + try { await fn() } finally { process.stdout.write = orig } + return out + } + + async function invokeText(cmd: OpaqueCommandHandle, cmdArgv: string[] = []): Promise { + const { Command } = await import('commander') + const prog = new Command('elastic') + prog.option('--format ', 'output format') + prog.addCommand(cmd) + prog.exitOverride() + cmd.exitOverride() + return captureOutput(() => prog.parseAsync([cmd.name(), ...cmdArgv], { from: 'user' })) + } + + describe('formatOutput callback', () => { + it('is called with the handler result in text mode', async () => { + const received: unknown[] = [] + const cmd = defineCommand({ + name: 'status', + description: 'Status', + handler: () => ({ ok: true }), + formatOutput: (result) => { received.push(result); return 'custom\n' }, + }) + await invokeText(cmd) + assert.equal(received.length, 1) + assert.deepEqual(received[0], { ok: true }) + }) + + it('is called with the parsed result in text mode', async () => { + const receivedParsed: unknown[] = [] + const cmd = defineCommand({ + name: 'status', + description: 'Status', + options: [{ long: 'verbose', type: 'boolean', description: 'Verbose' }], + handler: () => ({ ok: true }), + formatOutput: (result, parsed) => { receivedParsed.push(parsed); return 'ok\n' }, + }) + await invokeText(cmd, ['--verbose']) + assert.equal(receivedParsed.length, 1) + const p = receivedParsed[0] as ParsedResult + assert.equal(p.options['verbose'], true) + }) + + it('output written is the string returned by formatOutput', async () => { + const cmd = defineCommand({ + name: 'status', + description: 'Status', + handler: () => ({ ok: true }), + formatOutput: () => 'custom output line\n', + }) + const out = await invokeText(cmd) + assert.equal(out, 'custom output line\n') + }) + + it('is NOT called when --format=json is provided', async () => { + let called = false + const cmd = defineCommand({ + name: 'status', + description: 'Status', + handler: () => ({ ok: true }), + formatOutput: () => { called = true; return 'custom\n' }, + }) + const { Command } = await import('commander') + const prog = new Command('elastic') + prog.option('--format ', 'output format') + prog.addCommand(cmd) + prog.exitOverride() + cmd.exitOverride() + await captureOutput(() => prog.parseAsync(['--format', 'json', cmd.name()], { from: 'user' })) + assert.equal(called, false, 'formatOutput must not be called in JSON mode') + }) + + it('JSON mode still writes compact JSON even when formatOutput is defined', async () => { + const cmd = defineCommand({ + name: 'status', + description: 'Status', + handler: () => ({ ok: true }), + formatOutput: () => 'custom\n', + }) + const { Command } = await import('commander') + const prog = new Command('elastic') + prog.option('--format ', 'output format') + prog.addCommand(cmd) + prog.exitOverride() + cmd.exitOverride() + const out = await captureOutput(() => prog.parseAsync(['--format', 'json', cmd.name()], { from: 'user' })) + assert.deepEqual(JSON.parse(out), { ok: true }) + }) + }) + + describe('auto-rendering (no formatOutput provided)', () => { + it('renders a string result as plain text', async () => { + const cmd = defineCommand({ + name: 'echo', + description: 'Echo', + handler: () => 'hello world', + }) + const out = await invokeText(cmd) + assert.equal(out, 'hello world\n') + }) + + it('renders a number result as its string form', async () => { + const cmd = defineCommand({ + name: 'count', + description: 'Count', + handler: () => 42, + }) + const out = await invokeText(cmd) + assert.equal(out, '42\n') + }) + + it('renders an array of primitives one per line', async () => { + const cmd = defineCommand({ + name: 'list', + description: 'List', + handler: () => ['alpha', 'beta', 'gamma'], + }) + const out = await invokeText(cmd) + assert.equal(out, 'alpha\nbeta\ngamma\n') + }) + + it('renders an array of flat objects as a table', async () => { + const cmd = defineCommand({ + name: 'list', + description: 'List', + handler: () => [ + { name: 'foo', status: 'ok' }, + { name: 'bar', status: 'error' }, + ], + }) + const out = await invokeText(cmd) + assert.match(out, /name/) + assert.match(out, /status/) + assert.match(out, /foo/) + assert.match(out, /bar/) + assert.match(out, /^-+/m) + }) + it('falls back to pretty-printed JSON for a plain object', async () => { + const cmd = defineCommand({ + name: 'status', + description: 'Status', + handler: () => ({ ok: true, count: 3 }), + }) + const out = await invokeText(cmd) + assert.equal(out, JSON.stringify({ ok: true, count: 3 }, null, 2) + '\n') + }) + + it('falls back to pretty-printed JSON for nested arrays', async () => { + const cmd = defineCommand({ + name: 'status', + description: 'Status', + handler: () => [{ name: 'foo', tags: ['a', 'b'] }], + }) + const out = await invokeText(cmd) + assert.equal(out, JSON.stringify([{ name: 'foo', tags: ['a', 'b'] }], null, 2) + '\n') + }) + }) +}) describe('defineGroup', () => { describe('skeleton', () => { diff --git a/test/output.test.ts b/test/output.test.ts new file mode 100644 index 00000000..59e03401 --- /dev/null +++ b/test/output.test.ts @@ -0,0 +1,150 @@ +/** + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { renderText, renderTable } from '../src/output.ts' + +describe('renderTable', () => { + it('returns empty string for an empty array', () => { + assert.equal(renderTable([]), '') + }) + + it('renders a single row with a header and separator', () => { + const out = renderTable([{ name: 'foo', count: 3 }]) + const lines = out.trimEnd().split('\n') + assert.equal(lines.length, 3) + assert.match(lines[0]!, /name/) + assert.match(lines[0]!, /count/) + assert.match(lines[1]!, /^-/) + assert.match(lines[2]!, /foo/) + assert.match(lines[2]!, /3/) + }) + + it('renders multiple rows', () => { + const out = renderTable([ + { name: 'foo', count: 3 }, + { name: 'bar', count: 12 }, + ]) + const lines = out.trimEnd().split('\n') + assert.equal(lines.length, 4) + assert.match(lines[2]!, /foo/) + assert.match(lines[3]!, /bar/) + }) + + it('aligns columns to the widest value', () => { + const out = renderTable([ + { name: 'short', count: 1 }, + { name: 'a-much-longer-name', count: 999 }, + ]) + const lines = out.trimEnd().split('\n') + const headerWidth = lines[0]!.indexOf('count') + const dataRow1Width = lines[2]!.indexOf('1') + const dataRow2Width = lines[3]!.indexOf('999') + assert.equal(headerWidth, dataRow1Width, 'count column starts at same position in both data rows') + assert.equal(dataRow1Width, dataRow2Width) + }) + + it('uses the first row keys as column headers', () => { + const out = renderTable([{ alpha: 'x', beta: 'y' }]) + assert.match(out, /alpha/) + assert.match(out, /beta/) + }) + + it('treats null values as empty strings', () => { + const out = renderTable([{ name: 'foo', value: null }]) + const lines = out.trimEnd().split('\n') + assert.match(lines[2]!, /foo/) + }) + + it('separator line uses dashes matching column widths', () => { + const out = renderTable([{ id: 'abc' }]) + const lines = out.trimEnd().split('\n') + assert.match(lines[1]!, /^---/) + }) + + it('does not have trailing spaces on each line', () => { + const out = renderTable([{ a: 'x', bb: 'y', ccc: 'z' }]) + for (const line of out.split('\n').filter((l) => l.length > 0)) { + assert.doesNotMatch(line, / $/, `line has trailing space: ${JSON.stringify(line)}`) + } + }) +}) + +describe('renderText', () => { + describe('primitives', () => { + it('renders a string as itself with a newline', () => { + assert.equal(renderText('hello'), 'hello\n') + }) + + it('renders a number as its string form with a newline', () => { + assert.equal(renderText(42), '42\n') + }) + + it('renders a boolean as its string form with a newline', () => { + assert.equal(renderText(true), 'true\n') + assert.equal(renderText(false), 'false\n') + }) + + it('renders null as "null" with a newline', () => { + assert.equal(renderText(null), 'null\n') + }) + }) + + describe('arrays of primitives', () => { + it('renders each primitive on its own line', () => { + assert.equal(renderText(['alpha', 'beta', 'gamma']), 'alpha\nbeta\ngamma\n') + }) + + it('renders an array of numbers one per line', () => { + assert.equal(renderText([1, 2, 3]), '1\n2\n3\n') + }) + + it('renders an empty array as a single newline', () => { + assert.equal(renderText([]), '\n') + }) + }) + + describe('arrays of flat objects', () => { + it('renders an array of flat objects as a table', () => { + const out = renderText([ + { name: 'foo', status: 'ok' }, + { name: 'bar', status: 'error' }, + ]) + assert.match(out, /name/) + assert.match(out, /status/) + assert.match(out, /foo/) + assert.match(out, /bar/) + }) + + it('table output has a separator line', () => { + const out = renderText([{ name: 'foo' }]) + const lines = out.trimEnd().split('\n') + assert.match(lines[1]!, /^-+$/) + }) + }) + + describe('complex types — fall back to pretty JSON', () => { + it('renders a plain object as pretty-printed JSON', () => { + const val = { key: 'value', nested: { x: 1 } } + assert.equal(renderText(val), JSON.stringify(val, null, 2) + '\n') + }) + + it('renders an array of nested objects as pretty-printed JSON', () => { + const val = [{ name: 'foo', tags: ['a', 'b'] }] + assert.equal(renderText(val), JSON.stringify(val, null, 2) + '\n') + }) + + it('renders a mixed array (primitives and objects) as pretty-printed JSON', () => { + const val = ['hello', { key: 1 }] + assert.equal(renderText(val as never), JSON.stringify(val, null, 2) + '\n') + }) + + it('renders a flat object (not an array) as pretty-printed JSON', () => { + const val = { status: 'ok', count: 3 } + assert.equal(renderText(val), JSON.stringify(val, null, 2) + '\n') + }) + }) +})