import { describe, expect, test } from 'bun:test'; import { COMMANDS } from '@celilo/core'; import { getArg, getFlag, hasFlag, parseArguments, validateFlags, validateRequiredArgs, } from './parser'; describe('CLI Parser', () => { describe('parseArguments', () => { test('should parse command only', () => { const result = parseArguments(['node', 'celilo', 'help']); expect(result.command).toBe('help'); expect(result.subcommand).toBeUndefined(); expect(result.args).toEqual([]); expect(result.flags).toEqual({}); }); test('should parse command with subcommand', () => { const result = parseArguments(['node', 'celilo', 'module', 'list']); expect(result.command).toBe('module'); expect(result.subcommand).toBe('list'); expect(result.args).toEqual([]); expect(result.flags).toEqual({}); }); test('should parse command with subcommand and args', () => { const result = parseArguments(['node', 'celilo', 'module', 'import', '/path/to/module']); expect(result.command).toBe('module'); expect(result.subcommand).toBe('import'); expect(result.args).toEqual(['/path/to/module']); expect(result.flags).toEqual({}); }); test('should parse boolean flags', () => { const result = parseArguments(['node', 'celilo', 'module', 'list', '--verbose']); expect(result.command).toBe('module'); expect(result.subcommand).toBe('list'); expect(result.args).toEqual([]); expect(result.flags).toEqual({ verbose: true }); }); test('should parse string flags', () => { const result = parseArguments([ 'node', 'celilo', 'module', 'import', '/path', '--target', '/target', ]); expect(result.command).toBe('module'); expect(result.subcommand).toBe('import'); expect(result.args).toEqual(['/path']); expect(result.flags).toEqual({ target: '/target' }); }); test('should parse multiple args and flags', () => { const result = parseArguments([ 'node', 'celilo', 'module', 'config', 'set', 'homebridge', 'hostname', 'myhost', '--dry-run', ]); expect(result.command).toBe('module'); expect(result.subcommand).toBe('config'); expect(result.args).toEqual(['set', 'homebridge', 'hostname', 'myhost']); expect(result.flags).toEqual({ 'dry-run': true }); }); test('should handle no arguments as help', () => { const result = parseArguments(['node', 'celilo']); expect(result.command).toBe('help'); expect(result.args).toEqual([]); expect(result.flags).toEqual({}); }); test('should handle flags at end', () => { const result = parseArguments([ 'node', 'celilo', 'module', 'generate', 'homebridge', '--output', '/output', '--verbose', ]); expect(result.command).toBe('module'); expect(result.subcommand).toBe('generate'); expect(result.args).toEqual(['homebridge']); expect(result.flags).toEqual({ output: '/output', verbose: true }); }); test('should treat flag without value as boolean', () => { const result = parseArguments(['node', 'celilo', 'module', 'list', '--json', '--verbose']); expect(result.flags).toEqual({ json: true, verbose: true }); }); test('should parse --flag=value inline form', () => { const result = parseArguments([ 'node', 'celilo', 'api-serve', '--principal=alice', '--empty=', ]); expect(result.command).toBe('api-serve'); expect(result.flags).toEqual({ principal: 'alice', empty: '' }); }); test('should not consume key=value args as flag values', () => { const result = parseArguments([ 'node', 'celilo', 'system', 'init', '--accept-defaults', 'network.dmz.subnet=10.0.10.0/24', 'primary_domain=example.com', ]); expect(result.command).toBe('system'); expect(result.subcommand).toBe('init'); expect(result.flags).toEqual({ 'accept-defaults': true }); expect(result.args).toEqual([ 'network.dmz.subnet=10.0.10.0/24', 'primary_domain=example.com', ]); }); }); describe('validateRequiredArgs', () => { test('should return null for sufficient args', () => { const error = validateRequiredArgs(['arg1', 'arg2'], 2); expect(error).toBeNull(); }); test('should return null for more args than required', () => { const error = validateRequiredArgs(['arg1', 'arg2', 'arg3'], 2); expect(error).toBeNull(); }); test('should return error for insufficient args', () => { const error = validateRequiredArgs(['arg1'], 2); expect(error).toBe('Missing required arguments. Expected 2, got 1'); }); test('should return error for no args when required', () => { const error = validateRequiredArgs([], 1); expect(error).toBe('Missing required arguments. Expected 1, got 0'); }); }); describe('getArg', () => { test('should return arg at index', () => { const args = ['first', 'second', 'third']; expect(getArg(args, 0)).toBe('first'); expect(getArg(args, 1)).toBe('second'); expect(getArg(args, 2)).toBe('third'); }); test('should return undefined for out of bounds index', () => { const args = ['first']; expect(getArg(args, 1)).toBeUndefined(); expect(getArg(args, 5)).toBeUndefined(); }); test('should return undefined for negative index', () => { const args = ['first']; expect(getArg(args, -1)).toBeUndefined(); }); }); describe('getFlag', () => { test('should return string flag value', () => { const flags = { target: '/path', name: 'test' }; expect(getFlag(flags, 'target', '')).toBe('/path'); expect(getFlag(flags, 'name', '')).toBe('test'); }); test('should return default for missing flag', () => { const flags = { target: '/path' }; expect(getFlag(flags, 'missing', 'default')).toBe('default'); }); test('should return empty string default for missing flag', () => { const flags = { target: '/path' }; expect(getFlag(flags, 'missing')).toBe(''); }); test('should return default for boolean flag', () => { const flags = { verbose: true }; expect(getFlag(flags, 'verbose', 'default')).toBe('default'); }); }); describe('hasFlag', () => { test('should return true for boolean flag', () => { const flags = { verbose: true, debug: true }; expect(hasFlag(flags, 'verbose')).toBe(true); expect(hasFlag(flags, 'debug')).toBe(true); }); test('should return false for missing flag', () => { const flags = { verbose: true }; expect(hasFlag(flags, 'debug')).toBe(false); }); test('should return false for string flag', () => { const flags = { target: '/path' }; expect(hasFlag(flags, 'target')).toBe(false); }); }); }); describe('a flag a handler reads is a flag the registry declares', () => { // `handleSystemConfigSet` has read `flags.force` since the hooks.jail_policy // interview gate landed, and its unit test drove the handler directly with // `{ force: true }`. The registry declared no flags for `system config set`, // so the parser answered `--force` with "does not accept any flags" and the // escape hatch was tested and unreachable from a terminal. The suite could // not see it, because nothing in it went through the parser. const leaf = (path: readonly string[]) => path.slice(1).reduce<{ name: string; subcommands?: unknown[] } | undefined>( (def, name) => (def as { subcommands?: { name: string }[] } | undefined)?.subcommands?.find( (s) => s.name === name, ), COMMANDS.find((c) => c.name === path[0]), ); test('system config set accepts --force through the real registry', () => { const def = leaf(['system', 'config', 'set']); expect(def).toBeDefined(); expect(validateFlags({ force: true }, def as never)).toBeNull(); }); test('and still refuses a flag nobody declared', () => { const def = leaf(['system', 'config', 'set']); expect(validateFlags({ frce: true }, def as never)).toContain('Unknown flag'); }); });