diff --git a/src/cli.ts b/src/cli.ts index 49a66af6..32af5d9f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -21,6 +21,8 @@ program .option('--config-file ', 'path to a config file (default: ~/.elasticrc.yml)') .option('--use-context ', 'override the active context from the config file') .option('--json', 'output as JSON') + .option('--output-fields ', 'comma-separated list of fields to include in output (dot-notation supported)') + .option('--output-template ', 'Mustache-like template for custom text output (e.g. "{{id}}: {{name}}")') // Before every sub-command action, load and resolve the config file. // On error, print a structured message and exit -- never let a config failure diff --git a/src/factory.ts b/src/factory.ts index 45542d61..841711ec 100644 --- a/src/factory.ts +++ b/src/factory.ts @@ -13,6 +13,7 @@ import { extractSchemaArgs, validateSchemaArgs } from './lib/schema-args.ts' import type { SchemaArgDefinition } from './lib/schema-args.ts' import { simplifyZodIssues, formatIssuesText } from './lib/zod-error.ts' import { renderText, formatHandlerError } from './output.ts' +import { pickFields, parseFieldList, applyTemplate } from './lib/output-transform.ts' /** pre-built schema for coercing string → number, reused per option invocation */ const numberSchema = z.coerce.number() @@ -764,12 +765,22 @@ export function defineCommand (config: CommandConfig): O process.stderr.write(`Error: ${formatHandlerError(handlerResult)}\n`) } process.exitCode = 1 - } else if (jsonFormat === true) { - process.stdout.write(JSON.stringify(handlerResult) + '\n') - } else if (config.formatOutput !== undefined) { - process.stdout.write(config.formatOutput(handlerResult, parsed)) } else { - process.stdout.write(renderText(handlerResult)) + const fieldsRaw = allRaw.outputFields as string | undefined + const templateRaw = allRaw.outputTemplate as string | undefined + let output = handlerResult + if (fieldsRaw != null) { + output = pickFields(output, parseFieldList(fieldsRaw)) + } + if (templateRaw != null) { + process.stdout.write(applyTemplate(output, templateRaw)) + } else if (jsonFormat === true) { + process.stdout.write(JSON.stringify(output) + '\n') + } else if (config.formatOutput !== undefined) { + process.stdout.write(config.formatOutput(output, parsed)) + } else { + process.stdout.write(renderText(output)) + } } }) diff --git a/src/lib/output-transform.ts b/src/lib/output-transform.ts new file mode 100644 index 00000000..0e54eca7 --- /dev/null +++ b/src/lib/output-transform.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { JsonValue } from '../factory.ts' + +/** + * Picks the specified fields from a JSON value, supporting dot-notation for nested access. + * + * - For objects: returns a new object with only the selected fields + * - For arrays of objects: picks fields from each element + * - For primitives/non-objects: returns the value unchanged (nothing to pick from) + * + * Dot-notation (e.g. `"hits.total"`) descends into nested objects. + * Missing fields are silently omitted. + */ +export function pickFields (value: JsonValue, fields: string[]): JsonValue { + if (value === null || typeof value !== 'object') return value + + if (Array.isArray(value)) { + return value.map((item) => pickFields(item, fields)) + } + + const result: Record = {} + for (const field of fields) { + const val = getNestedValue(value, field) + if (val !== undefined) { + setNestedValue(result, field, val) + } + } + return result +} + +function getNestedValue (obj: Record, path: string): JsonValue | undefined { + const parts = path.split('.') + let current: JsonValue = obj + for (const part of parts) { + if (current === null || typeof current !== 'object' || Array.isArray(current)) return undefined + const next: JsonValue | undefined = (current as Record)[part] + if (next === undefined) return undefined + current = next + } + return current +} + +function setNestedValue (obj: Record, path: string, value: JsonValue): void { + const parts = path.split('.') + let current: Record = obj + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]! + if (current[part] === undefined || typeof current[part] !== 'object' || current[part] === null || Array.isArray(current[part])) { + current[part] = {} + } + current = current[part] as Record + } + current[parts[parts.length - 1]!] = value +} + +/** + * Parses a comma-separated field list into individual field names. + * Trims whitespace from each field and drops empties. + */ +export function parseFieldList (raw: string): string[] { + return raw.split(',').map((f) => f.trim()).filter((f) => f.length > 0) +} + +/** + * Renders a value using a Mustache-like template string. + * + * Supported syntax: + * - `{{field}}` — replaced with the field value (dot-notation supported) + * - `{{field}}` on missing fields — replaced with empty string + * + * For arrays: renders one line per element. + * For primitives: returns the template with `{{.}}` replaced by the value. + */ +export function applyTemplate (value: JsonValue, template: string): string { + if (value === null || typeof value !== 'object') { + return template.replace(/\{\{\s*\.?\s*\}\}/g, String(value)) + '\n' + } + + if (Array.isArray(value)) { + return value.map((item) => applyTemplate(item, template)).join('') + } + + return template.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, field: string) => { + if (field === '.') return JSON.stringify(value) + const val = getNestedValue(value as Record, field) + if (val === undefined || val === null) return '' + if (typeof val === 'object') return JSON.stringify(val) + return String(val) + }) + '\n' +} diff --git a/test/lib/output-transform.test.ts b/test/lib/output-transform.test.ts new file mode 100644 index 00000000..352e0496 --- /dev/null +++ b/test/lib/output-transform.test.ts @@ -0,0 +1,181 @@ +/* + * 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 { pickFields, parseFieldList, applyTemplate } from '../../src/lib/output-transform.ts' + +// --------------------------------------------------------------------------- +// parseFieldList +// --------------------------------------------------------------------------- + +describe('parseFieldList', () => { + it('splits comma-separated fields', () => { + assert.deepEqual(parseFieldList('id,name,score'), ['id', 'name', 'score']) + }) + + it('trims whitespace around fields', () => { + assert.deepEqual(parseFieldList('id , name , score'), ['id', 'name', 'score']) + }) + + it('drops empty entries from trailing commas', () => { + assert.deepEqual(parseFieldList('id,name,'), ['id', 'name']) + }) + + it('handles a single field', () => { + assert.deepEqual(parseFieldList('id'), ['id']) + }) +}) + +// --------------------------------------------------------------------------- +// pickFields — flat objects +// --------------------------------------------------------------------------- + +describe('pickFields — flat objects', () => { + it('picks specified top-level keys', () => { + const input = { id: 1, name: 'foo', status: 'ok', extra: true } + assert.deepEqual(pickFields(input, ['id', 'name']), { id: 1, name: 'foo' }) + }) + + it('silently omits missing fields', () => { + const input = { id: 1, name: 'foo' } + assert.deepEqual(pickFields(input, ['id', 'missing']), { id: 1 }) + }) + + it('returns empty object when no fields match', () => { + assert.deepEqual(pickFields({ a: 1 }, ['x', 'y']), {}) + }) +}) + +// --------------------------------------------------------------------------- +// pickFields — dot-notation (nested) +// --------------------------------------------------------------------------- + +describe('pickFields — dot-notation', () => { + it('picks nested fields with dot-notation', () => { + const input = { hits: { total: 42, max_score: 1.5 }, took: 10 } + assert.deepEqual(pickFields(input, ['hits.total', 'took']), { hits: { total: 42 }, took: 10 }) + }) + + it('handles deeply nested paths', () => { + const input = { a: { b: { c: { d: 'deep' } } } } + assert.deepEqual(pickFields(input, ['a.b.c.d']), { a: { b: { c: { d: 'deep' } } } }) + }) + + it('omits dot-paths where intermediate is missing', () => { + const input = { a: 1 } + assert.deepEqual(pickFields(input, ['a.b.c']), {}) + }) +}) + +// --------------------------------------------------------------------------- +// pickFields — arrays +// --------------------------------------------------------------------------- + +describe('pickFields — arrays', () => { + it('picks fields from each element in an array', () => { + const input = [ + { id: 1, name: 'a', extra: true }, + { id: 2, name: 'b', extra: false }, + ] + assert.deepEqual(pickFields(input, ['id', 'name']), [ + { id: 1, name: 'a' }, + { id: 2, name: 'b' }, + ]) + }) + + it('returns empty objects for array elements with no matching fields', () => { + const input = [{ x: 1 }, { x: 2 }] + assert.deepEqual(pickFields(input, ['z']), [{}, {}]) + }) +}) + +// --------------------------------------------------------------------------- +// pickFields — primitives (pass-through) +// --------------------------------------------------------------------------- + +describe('pickFields — primitives', () => { + it('returns string unchanged', () => { + assert.equal(pickFields('hello', ['id']), 'hello') + }) + + it('returns number unchanged', () => { + assert.equal(pickFields(42, ['id']), 42) + }) + + it('returns null unchanged', () => { + assert.equal(pickFields(null, ['id']), null) + }) +}) + +// --------------------------------------------------------------------------- +// applyTemplate — objects +// --------------------------------------------------------------------------- + +describe('applyTemplate — objects', () => { + it('substitutes top-level fields', () => { + const input = { id: 1, name: 'foo', score: 9.5 } + assert.equal(applyTemplate(input, '{{id}}: {{name}} ({{score}})'), '1: foo (9.5)\n') + }) + + it('substitutes nested fields with dot-notation', () => { + const input = { hit: { id: 'abc', source: { title: 'Hello' } } } + assert.equal(applyTemplate(input, '{{hit.id}} — {{hit.source.title}}'), 'abc — Hello\n') + }) + + it('replaces missing fields with empty string', () => { + const input = { id: 1 } + assert.equal(applyTemplate(input, '{{id}} {{missing}}'), '1 \n') + }) + + it('serializes object fields as JSON', () => { + const input = { id: 1, meta: { a: 2 } } + assert.equal(applyTemplate(input, '{{id}} {{meta}}'), '1 {"a":2}\n') + }) +}) + +// --------------------------------------------------------------------------- +// applyTemplate — arrays (one line per element) +// --------------------------------------------------------------------------- + +describe('applyTemplate — arrays', () => { + it('renders one line per array element', () => { + const input = [ + { id: 1, name: 'a' }, + { id: 2, name: 'b' }, + ] + assert.equal(applyTemplate(input, '{{id}}: {{name}}'), '1: a\n2: b\n') + }) + + it('handles empty arrays', () => { + assert.equal(applyTemplate([], '{{id}}'), '') + }) +}) + +// --------------------------------------------------------------------------- +// applyTemplate — primitives +// --------------------------------------------------------------------------- + +describe('applyTemplate — primitives', () => { + it('replaces {{.}} with the primitive value', () => { + assert.equal(applyTemplate('hello', 'value={{.}}'), 'value=hello\n') + }) + + it('replaces bare {{}} with the primitive value', () => { + assert.equal(applyTemplate(42, 'num={{}}'), 'num=42\n') + }) +}) + +// --------------------------------------------------------------------------- +// pickFields + applyTemplate combined +// --------------------------------------------------------------------------- + +describe('pickFields + applyTemplate combined', () => { + it('fields narrow data before template renders it', () => { + const input = { id: 1, name: 'foo', secret: 'hidden' } + const picked = pickFields(input, ['id', 'name']) + assert.equal(applyTemplate(picked, '{{id}}: {{name}} {{secret}}'), '1: foo \n') + }) +})