diff --git a/src/utils/formatter.ts b/src/utils/formatter.ts index f63c75f..89431a7 100644 --- a/src/utils/formatter.ts +++ b/src/utils/formatter.ts @@ -40,7 +40,7 @@ export function getOutputFormat(command: { optsWithGlobals: () => Record]{idname}:\n 1Task 1 * @@ -72,7 +72,9 @@ export function formatOutput(data: any, options: FormatterOptions): string { /** * Format data as TOON (Token-Oriented Object Notation) * - * Uses tab delimiter for maximum token efficiency (58.9% savings vs JSON). + * Uses tab delimiter for token efficiency: ~37% savings vs JSON, measured + * with the o200k/cl100k tokenizers on TaskView-shaped list output (10-200 + * rows). Savings on single-item detail views with long notes are ~6%. * Powered by @pleaseai/cli-toolkit. * * @param data - The data to format @@ -100,51 +102,73 @@ function formatJson(data: any): string { * @returns Plain text formatted string */ function formatPlain(data: any, colors: boolean): string { - // This will be implemented based on the specific data structure - // For now, return a basic representation if (Array.isArray(data)) { - return formatPlainArray(data, colors) + return formatPlainArray(data, colors, '') } if (typeof data === 'object' && data !== null) { - return formatPlainObject(data, colors) + return formatPlainObject(data, colors, '') } return String(data) } /** - * Format array as plain text + * Format array as plain text. Each item starts with a `- ` marker and every + * continuation line is indented to align under the marker. */ -function formatPlainArray(data: any[], colors: boolean): string { - const lines: string[] = [] - - for (const item of data) { - if (typeof item === 'object' && item !== null) { - lines.push(formatPlainObject(item, colors)) - } - else { - lines.push(String(item)) - } - } - - return lines.join('\n') +function formatPlainArray(data: any[], colors: boolean, indent: string): string { + const itemIndent = `${indent} ` + + return data + .map((item) => { + if (Array.isArray(item)) { + const body = formatPlainArray(item, colors, itemIndent) + return `${indent}- ${body.slice(itemIndent.length)}` + } + if (typeof item === 'object' && item !== null) { + const body = formatPlainObject(item, colors, itemIndent) + // Replace the first line's indent with the `- ` marker. + return `${indent}- ${body.slice(itemIndent.length)}` + } + // Indent continuation lines of multiline strings under the marker. + const scalar = formatPlainScalar(item, colors).split('\n').join(`\n${indent} `) + return `${indent}- ${scalar}` + }) + .join('\n') } /** - * Format object as plain text with key-value pairs + * Format object as plain text with key-value pairs. Keys with `undefined` or + * `null` values are omitted — plain output is human-facing, and empty fields + * are noise there (json keeps null for scripting). */ -function formatPlainObject(data: Record, colors: boolean): string { +function formatPlainObject(data: Record, colors: boolean, indent: string): string { const lines: string[] = [] for (const [key, value] of Object.entries(data)) { + if (value === undefined || value === null) { + continue + } const keyLabel = colors ? chalk.bold(key) : key if (Array.isArray(value)) { - lines.push(`${keyLabel}:`) - lines.push(...value.map(item => ` ${formatPlainValue(item, colors)}`)) + const body = formatPlainArray(value, colors, `${indent} `) + lines.push(`${indent}${keyLabel}:`) + if (body) { + lines.push(body) + } + } + else if (typeof value === 'object') { + const body = formatPlainObject(value, colors, `${indent} `) + lines.push(`${indent}${keyLabel}:`) + if (body) { + lines.push(body) + } } else { - lines.push(`${keyLabel}: ${formatPlainValue(value, colors)}`) + // Indent continuation lines of multiline strings under their key. + const scalar = formatPlainScalar(value, colors).split('\n').join(`\n${indent} `) + lines.push(`${indent}${keyLabel}: ${scalar}`) } } @@ -152,18 +176,11 @@ function formatPlainObject(data: Record, colors: boolean): string { } /** - * Format a single value for plain text output + * Format a scalar value for plain text output */ -function formatPlainValue(value: any, colors: boolean): string { - if (typeof value === 'boolean') { - if (colors) { - return value ? chalk.green('true') : chalk.yellow('false') - } - return String(value) - } - - if (typeof value === 'object' && value !== null) { - return formatPlainObject(value, colors) +function formatPlainScalar(value: any, colors: boolean): string { + if (typeof value === 'boolean' && colors) { + return value ? chalk.green('true') : chalk.yellow('false') } return String(value) diff --git a/test/utils/formatter.test.ts b/test/utils/formatter.test.ts index 4572ea1..a99db39 100644 --- a/test/utils/formatter.test.ts +++ b/test/utils/formatter.test.ts @@ -23,7 +23,7 @@ describe('formatOutput', () => { } const result = formatOutput(data, { format: 'toon' }) - // cli-toolkit uses tab delimiter (not comma) for 58.9% token savings + // cli-toolkit uses tab delimiter (not comma) for ~37% token savings vs JSON expect(result).toContain('\t') // Should have tabular structure with field names separated by tabs expect(result).toMatch(/\{[^\t}]*\t[^}]*\}/) @@ -169,6 +169,117 @@ describe('formatOutput', () => { expect(result).toContain('john@example.com') }) + test('should omit undefined values instead of printing "undefined"', () => { + const data = { name: 'John', due_on: undefined } + const result = formatOutput(data, { format: 'plain', colors: false }) + + expect(result).not.toContain('undefined') + expect(result).not.toContain('due_on') + expect(result).toContain('name: John') + }) + + test('should omit null values instead of printing "null"', () => { + const data = { name: 'John', assignee: null } + const result = formatOutput(data, { format: 'plain', colors: false }) + + expect(result).not.toContain('null') + expect(result).not.toContain('assignee') + expect(result).toContain('name: John') + }) + + test('should indent every line of array items and mark item boundaries', () => { + const data = { + tasks: [ + { gid: '123', name: 'Task 1' }, + { gid: '456', name: 'Task 2' }, + ], + } + const result = formatOutput(data, { format: 'plain', colors: false }) + + expect(result.split('\n')).toEqual([ + 'tasks:', + ' - gid: 123', + ' name: Task 1', + ' - gid: 456', + ' name: Task 2', + ]) + }) + + test('should not emit a blank line for empty arrays or objects', () => { + const data = { tasks: [], meta: {}, name: 'John' } + const result = formatOutput(data, { format: 'plain', colors: false }) + + expect(result.split('\n')).toEqual([ + 'tasks:', + 'meta:', + 'name: John', + ]) + }) + + test('should correctly format and indent nested arrays', () => { + const data = { + matrix: [ + [1, 2], + [3, 4], + ], + } + const result = formatOutput(data, { format: 'plain', colors: false }) + + expect(result.split('\n')).toEqual([ + 'matrix:', + ' - - 1', + ' - 2', + ' - - 3', + ' - 4', + ]) + }) + + test('should indent nested object lines under their parent key', () => { + const data = { + user: { + name: 'John', + email: 'john@example.com', + }, + } + const result = formatOutput(data, { format: 'plain', colors: false }) + + expect(result.split('\n')).toEqual([ + 'user:', + ' name: John', + ' email: john@example.com', + ]) + }) + + test('should correctly indent multiline strings in plain format', () => { + const data = { + description: 'Line 1\nLine 2', + tasks: [ + { name: 'Task 1\nDetail 1' }, + ], + } + const result = formatOutput(data, { format: 'plain', colors: false }) + + expect(result.split('\n')).toEqual([ + 'description: Line 1', + ' Line 2', + 'tasks:', + ' - name: Task 1', + ' Detail 1', + ]) + }) + + test('should indent multiline scalar array items under their marker', () => { + const data = { tags: ['line1\nline2', 'single'] } + const result = formatOutput(data, { format: 'plain', colors: false }) + + expect(result.split('\n')).toEqual([ + 'tags:', + ' - line1', + ' line2', + ' - single', + ]) + }) + test('should not throw error with colors enabled in plain format', () => { const data = { name: 'John', completed: true }