Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ program
.option('--config-file <path>', 'path to a config file (default: ~/.elasticrc.yml)')
.option('--use-context <name>', 'override the active context from the config file')
.option('--json', 'output as JSON')
.option('--output-fields <list>', 'comma-separated list of fields to include in output (dot-notation supported)')
.option('--output-template <string>', '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
Expand Down
21 changes: 16 additions & 5 deletions src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -764,12 +765,22 @@ export function defineCommand<T extends z.ZodType> (config: CommandConfig<T>): 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))
}
}
})

Expand Down
94 changes: 94 additions & 0 deletions src/lib/output-transform.ts
Original file line number Diff line number Diff line change
@@ -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<string, JsonValue> = {}
for (const field of fields) {
const val = getNestedValue(value, field)
if (val !== undefined) {
setNestedValue(result, field, val)
}
}
return result
}

function getNestedValue (obj: Record<string, JsonValue>, 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<string, JsonValue>)[part]
if (next === undefined) return undefined
current = next
}
return current
}

function setNestedValue (obj: Record<string, JsonValue>, path: string, value: JsonValue): void {
const parts = path.split('.')
let current: Record<string, JsonValue> = 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<string, JsonValue>
}
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<string, JsonValue>, field)
if (val === undefined || val === null) return ''
if (typeof val === 'object') return JSON.stringify(val)
return String(val)
}) + '\n'
}
181 changes: 181 additions & 0 deletions test/lib/output-transform.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})