diff --git a/src/__tests__/args.test.ts b/src/__tests__/args.test.ts new file mode 100644 index 0000000..18ecac5 --- /dev/null +++ b/src/__tests__/args.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { parseArgs, ArgError } from '../args.js'; + +describe('parseArgs', () => { + it('defaults to text format when --format is omitted', () => { + // Regression: previously `args.indexOf('--format')` returned -1 and the + // first positional arg was mis-read as the format, crashing the headline + // `sbom-diff old.json new.json` command. + const parsed = parseArgs(['old.json', 'new.json']); + expect(parsed.oldPath).toBe('old.json'); + expect(parsed.newPath).toBe('new.json'); + expect(parsed.format).toBe('text'); + }); + + it('parses --format=value (inline form)', () => { + expect(parseArgs(['old.json', 'new.json', '--format=json']).format).toBe('json'); + }); + + it('parses --format value (space-separated form)', () => { + expect(parseArgs(['old.json', 'new.json', '--format', 'markdown']).format).toBe('markdown'); + }); + + it('accepts the flag before positional arguments', () => { + const parsed = parseArgs(['--format', 'json', 'old.json', 'new.json']); + expect(parsed.format).toBe('json'); + expect(parsed.oldPath).toBe('old.json'); + expect(parsed.newPath).toBe('new.json'); + }); + + it('throws ArgError when fewer than two files are given', () => { + expect(() => parseArgs(['old.json'])).toThrow(ArgError); + expect(() => parseArgs([])).toThrow(ArgError); + }); + + it('throws ArgError on an unsupported format', () => { + expect(() => parseArgs(['old.json', 'new.json', '--format', 'xml'])).toThrow(ArgError); + expect(() => parseArgs(['old.json', 'new.json', '--format=xml'])).toThrow(/Invalid format/); + }); +}); diff --git a/src/args.ts b/src/args.ts new file mode 100644 index 0000000..2577517 --- /dev/null +++ b/src/args.ts @@ -0,0 +1,65 @@ +import type { ReportFormat } from './types.js'; + +/** Result of parsing the CLI arguments. */ +export interface ParsedArgs { + oldPath: string; + newPath: string; + format: ReportFormat; +} + +const SUPPORTED_FORMATS: ReportFormat[] = ['text', 'json', 'markdown']; + +/** A user-facing argument error. The CLI turns this into a clean message + non-zero exit. */ +export class ArgError extends Error {} + +/** + * Parse the CLI argument vector (everything after `node cli.js`). + * + * Supports both `--format json` and `--format=json`, in any position. When + * `--format` is omitted the report defaults to `text`. + * + * @throws {ArgError} when positional args are missing or the format is invalid. + */ +export function parseArgs(argv: string[]): ParsedArgs { + const positional: string[] = []; + let format: ReportFormat | undefined; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + + if (arg.startsWith('--format=')) { + format = validateFormat(arg.slice('--format='.length)); + } else if (arg === '--format') { + // Consume the next token as the value so it is not mistaken for a file. + format = validateFormat(argv[++i]); + } else if (arg.startsWith('--')) { + throw new ArgError(`Unknown option: ${arg}`); + } else { + positional.push(arg); + } + } + + if (positional.length < 2) { + throw new ArgError( + 'Usage: sbom-diff [--format text|json|markdown]', + ); + } + + const [oldPath, newPath] = positional; + return { oldPath, newPath, format: format ?? 'text' }; +} + +/** Validate a `--format` value, throwing a friendly error for anything unsupported. */ +function validateFormat(value: string | undefined): ReportFormat { + if (value === undefined || value === '') { + throw new ArgError( + `Missing value for --format. Supported formats: ${SUPPORTED_FORMATS.join(', ')}.`, + ); + } + if (!SUPPORTED_FORMATS.includes(value as ReportFormat)) { + throw new ArgError( + `Invalid format: "${value}". Supported formats: ${SUPPORTED_FORMATS.join(', ')}.`, + ); + } + return value as ReportFormat; +} diff --git a/src/cli.ts b/src/cli.ts index 986eb1a..94a18af 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,21 +10,10 @@ import { readFile } from 'node:fs/promises'; import { parse } from './parser.js'; import { diff } from './diff.js'; import { renderReport } from './reporter.js'; -import type { ReportFormat } from './types.js'; +import { parseArgs, ArgError } from './args.js'; async function main(): Promise { - const args = process.argv.slice(2); - - const positional = args.filter(a => !a.startsWith('--')); - if (positional.length < 2) { - console.error('Usage: sbom-diff [--format text|json|markdown]'); - process.exit(1); - } - - const [oldPath, newPath] = positional; - const formatArg = args.find(a => a.startsWith('--format='))?.split('=')[1] - ?? args[args.indexOf('--format') + 1]; - const format: ReportFormat = (formatArg as ReportFormat) ?? 'text'; + const { oldPath, newPath, format } = parseArgs(process.argv.slice(2)); const [oldRaw, newRaw] = await Promise.all([ readFile(oldPath, 'utf-8'), @@ -38,7 +27,12 @@ async function main(): Promise { console.log(renderReport(report, format)); } -main().catch(err => { - console.error(err); +main().catch((err) => { + // Argument errors are user-facing: show a clean message, not a stack trace. + if (err instanceof ArgError) { + console.error(err.message); + } else { + console.error(err); + } process.exit(1); });