diff --git a/packages/cli/src/command-tree.test.ts b/packages/cli/src/command-tree.test.ts index 3f4f486c..54dec54b 100644 --- a/packages/cli/src/command-tree.test.ts +++ b/packages/cli/src/command-tree.test.ts @@ -16,12 +16,14 @@ describe('walkCommandTree', () => { name: 'root', description: 'the root', aliases: [], + arguments: [], children: [ { name: 'child', description: 'a child', aliases: ['c', 'ch'], - children: [{ name: 'grand', description: 'a grandchild', aliases: [], children: [] }], + arguments: [], + children: [{ name: 'grand', description: 'a grandchild', aliases: [], arguments: [], children: [] }], }, ], }); @@ -33,6 +35,7 @@ describe('walkCommandTree', () => { name: 'leaf', description: 'alone', aliases: [], + arguments: [], children: [], }); }); @@ -41,41 +44,64 @@ describe('walkCommandTree', () => { const command = new Command('bare'); expect(walkCommandTree(command).description).toBe(''); }); + + it('captures required, optional, and variadic arguments', () => { + const command = new Command('scan') + .argument('', 'KTX connection id') + .argument('[schemas...]', 'Schemas'); + + expect(walkCommandTree(command).arguments).toEqual(['', '[schemas...]']); + }); }); describe('formatCommandTree', () => { it('renders a single node with no children', () => { - const node = { name: 'solo', description: 'just me', aliases: [], children: [] }; - expect(formatCommandTree(node)).toBe('solo — just me\n'); + const node = { name: 'solo', description: 'just me', aliases: [], arguments: [], children: [] }; + expect(formatCommandTree(node)).toMatch(/^solo\s+just me\n$/); }); it('renders aliases in parentheses before the description', () => { - const node = { name: 'cmd', description: 'does things', aliases: ['c', 'co'], children: [] }; - expect(formatCommandTree(node)).toBe('cmd (c, co) — does things\n'); + const node = { name: 'cmd', description: 'does things', aliases: ['c', 'co'], arguments: [], children: [] }; + expect(formatCommandTree(node)).toMatch(/^cmd \(c, co\)\s+does things\n$/); + }); + + it('renders command arguments after the command name', () => { + const node = { + name: 'test', + description: 'Test a configured connection', + aliases: [], + arguments: [''], + children: [], + }; + expect(formatCommandTree(node)).toMatch(/^test \s+Test a configured connection\n$/); }); it('omits the dash when description is empty', () => { - const node = { name: 'bare', description: '', aliases: [], children: [] }; + const node = { name: 'bare', description: '', aliases: [], arguments: [], children: [] }; expect(formatCommandTree(node)).toBe('bare\n'); }); - it('indents children by two spaces per depth level and sorts siblings alphabetically', () => { + it('renders tree connectors and preserves sibling registration order', () => { const tree = { name: 'root', description: 'top', aliases: [], + arguments: [], children: [ - { name: 'beta', description: 'b', aliases: [], children: [] }, + { name: 'beta', description: 'b', aliases: [], arguments: [], children: [] }, { name: 'alpha', description: 'a', aliases: ['al'], - children: [{ name: 'inner', description: 'i', aliases: [], children: [] }], + arguments: [''], + children: [{ name: 'inner', description: 'i', aliases: [], arguments: [], children: [] }], }, ], }; - expect(formatCommandTree(tree)).toBe( - 'root — top\n' + ' alpha (al) — a\n' + ' inner — i\n' + ' beta — b\n', - ); + const lines = formatCommandTree(tree).trimEnd().split('\n'); + expect(lines[0]).toMatch(/^root\s+top$/); + expect(lines[1]).toMatch(/^ ├── beta\s+b$/); + expect(lines[2]).toMatch(/^ └── alpha \(al\)\s+a$/); + expect(lines[3]).toMatch(/^ └── inner\s+i$/); }); }); diff --git a/packages/cli/src/command-tree.ts b/packages/cli/src/command-tree.ts index f70ce803..65e3f5b2 100644 --- a/packages/cli/src/command-tree.ts +++ b/packages/cli/src/command-tree.ts @@ -1,35 +1,58 @@ -import type { Command } from '@commander-js/extra-typings'; +import type { Argument, CommandUnknownOpts } from '@commander-js/extra-typings'; + +const DESCRIPTION_COLUMN = 42; export interface CommandTreeNode { name: string; description: string; aliases: string[]; + arguments: string[]; children: CommandTreeNode[]; } -export function walkCommandTree(command: Command): CommandTreeNode { +export function walkCommandTree(command: CommandUnknownOpts): CommandTreeNode { return { name: command.name(), description: command.description(), aliases: command.aliases(), - children: command.commands.map((child) => walkCommandTree(child as Command)), + arguments: command.registeredArguments.map(formatArgumentDeclaration), + children: command.commands.map((child) => walkCommandTree(child)), }; } export function formatCommandTree(node: CommandTreeNode): string { const lines: string[] = []; - appendNode(node, 0, lines); + appendNode(node, '', '', lines); return `${lines.join('\n')}\n`; } -function appendNode(node: CommandTreeNode, depth: number, lines: string[]): void { - const indent = ' '.repeat(depth); - const aliasPart = node.aliases.length > 0 ? ` (${node.aliases.join(', ')})` : ''; - const descriptionPart = node.description.length > 0 ? ` — ${node.description}` : ''; - lines.push(`${indent}${node.name}${aliasPart}${descriptionPart}`); - - const sortedChildren = [...node.children].sort((a, b) => a.name.localeCompare(b.name)); - for (const child of sortedChildren) { - appendNode(child, depth + 1, lines); - } +function formatArgumentDeclaration(argument: Argument): string { + const name = `${argument.name()}${argument.variadic ? '...' : ''}`; + return argument.required ? `<${name}>` : `[${name}]`; +} + +function appendNode(node: CommandTreeNode, prefix: string, connector: string, lines: string[]): void { + const label = formatLabel(node); + lines.push(formatLine(`${prefix}${connector}${label}`, node.description)); + + node.children.forEach((child, index) => { + const isLast = index === node.children.length - 1; + const childConnector = isLast ? '└── ' : '├── '; + const childPrefix = connector === '' ? ' ' : `${prefix}${isLast ? ' ' : '│ '}`; + appendNode(child, childPrefix, childConnector, lines); + }); +} + +function formatLabel(node: CommandTreeNode): string { + const argumentPart = node.arguments.length > 0 ? ` ${node.arguments.join(' ')}` : ''; + const aliasPart = node.aliases.length > 0 ? ` (${node.aliases.join(', ')})` : ''; + return `${node.name}${argumentPart}${aliasPart}`; +} + +function formatLine(label: string, description: string): string { + if (description.length === 0) { + return label; + } + const padding = label.length >= DESCRIPTION_COLUMN ? ' ' : ' '.repeat(DESCRIPTION_COLUMN - label.length); + return `${label}${padding}${description}`; } diff --git a/packages/cli/src/print-command-tree.test.ts b/packages/cli/src/print-command-tree.test.ts index 77679aa7..c50ee9a3 100644 --- a/packages/cli/src/print-command-tree.test.ts +++ b/packages/cli/src/print-command-tree.test.ts @@ -6,13 +6,17 @@ describe('renderKtxCommandTree', () => { const output = renderKtxCommandTree(); const lines = output.split('\n'); - expect(lines[0]).toMatch(/^ktx( |$|\s—)/); + expect(lines[0]).toMatch(/^ktx( |$)/); - const topLevel = lines.filter((line) => /^ {2}\S/.test(line)).map((line) => line.trim().split(' ')[0]); + const topLevel = lines + .filter((line) => /^ {2}[├└]── \S/.test(line)) + .map((line) => line.replace(/^ {2}[├└]── /, '').trim().split(' ')[0]); for (const expected of ['setup', 'connection', 'ingest', 'sl', 'dev']) { expect(topLevel).toContain(expected); } + + expect(output).toContain('│ ├── test '); }); it('ends with a single trailing newline', () => {