diff --git a/README.md b/README.md index 3d8315a..da3474f 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,16 @@ diffbudget scan --base HEAD --output .diffbudget/latest --strict cat .diffbudget/latest/diffbudget-report.md ``` +## CLI options + +- `init`: `--force` +- `scan`: `--base`, `--target`, `--diff`, `--config`, `--output`, `--format markdown|json`, `--strict` +- `report`: `--input`, `--output`, `--format markdown|json` +- `doctor`: `--config` + +Boolean options (`--force` and `--strict`) do not take values. Unknown options, +missing values, and formats other than `markdown` or `json` exit with an error. + Tune budgets in `diffbudget.config.json`: ```json diff --git a/src/args.test.ts b/src/args.test.ts new file mode 100644 index 0000000..c734bfd --- /dev/null +++ b/src/args.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { parseArgs } from "./args.js"; + +test("parseArgs accepts documented command options", () => { + assert.deepEqual(parseArgs(["init", "--force"]), { + command: "init", + flags: { force: true }, + rest: [] + }); + assert.deepEqual(parseArgs([ + "scan", + "--base", "HEAD", + "--target=main", + "--diff", "change.patch", + "--config", "diffbudget.config.json", + "--output", ".diffbudget/latest", + "--format", "json", + "--strict" + ]), { + command: "scan", + flags: { + base: "HEAD", + target: "main", + diff: "change.patch", + config: "diffbudget.config.json", + output: ".diffbudget/latest", + format: "json", + strict: true + }, + rest: [] + }); + assert.deepEqual(parseArgs(["report", "--input", "report.json", "--output", "report.md", "--format", "markdown"]), { + command: "report", + flags: { input: "report.json", output: "report.md", format: "markdown" }, + rest: [] + }); + assert.deepEqual(parseArgs(["doctor", "--config", "diffbudget.config.json"]), { + command: "doctor", + flags: { config: "diffbudget.config.json" }, + rest: [] + }); +}); + +test("parseArgs rejects unknown options with the command name", () => { + assert.throws( + () => parseArgs(["scan", "--strcit"]), + /Unknown option for scan: --strcit/ + ); +}); + +test("parseArgs rejects unsupported output formats", () => { + assert.throws( + () => parseArgs(["scan", "--format", "yaml"]), + /Invalid value for --format: yaml \(expected markdown or json\)/ + ); +}); + +test("parseArgs rejects values supplied to boolean options", () => { + assert.throws( + () => parseArgs(["scan", "--strict=false"]), + /Option --strict does not accept a value/ + ); + assert.throws( + () => parseArgs(["scan", "--strict", "false"]), + /Option --strict does not accept a value/ + ); +}); diff --git a/src/args.ts b/src/args.ts index ab6c1f7..f5e6d49 100644 --- a/src/args.ts +++ b/src/args.ts @@ -4,8 +4,31 @@ export interface ParsedArgs { rest: string[]; } +type OptionKind = "boolean" | "string" | "format"; + +const COMMAND_OPTIONS: Record> = { + init: { force: "boolean" }, + scan: { + base: "string", + target: "string", + diff: "string", + config: "string", + output: "string", + format: "format", + strict: "boolean" + }, + report: { input: "string", output: "string", format: "format" }, + doctor: { config: "string" }, + help: {}, + version: {}, + "--help": {}, + "--version": {}, + "-h": {} +}; + export function parseArgs(argv: string[]): ParsedArgs { const [command = "help", ...tail] = argv; + const options = COMMAND_OPTIONS[command] ?? {}; const flags: Record = {}; const rest: string[] = []; for (let index = 0; index < tail.length; index += 1) { @@ -15,18 +38,31 @@ export function parseArgs(argv: string[]): ParsedArgs { continue; } const withoutPrefix = token.slice(2); - const [key, inlineValue] = withoutPrefix.split(/=(.*)/s).filter(Boolean); - if (inlineValue !== undefined) { - flags[key] = inlineValue; + const equalsIndex = withoutPrefix.indexOf("="); + const key = equalsIndex === -1 ? withoutPrefix : withoutPrefix.slice(0, equalsIndex); + const inlineValue = equalsIndex === -1 ? undefined : withoutPrefix.slice(equalsIndex + 1); + const kind = options[key]; + if (!kind) { + throw new Error(`Unknown option for ${command}: --${key}`); + } + if (kind === "boolean") { + if (inlineValue !== undefined || (tail[index + 1] && !tail[index + 1].startsWith("--"))) { + throw new Error(`Option --${key} does not accept a value`); + } + flags[key] = true; continue; } - const next = tail[index + 1]; - if (next && !next.startsWith("--")) { - flags[key] = next; + const value = inlineValue ?? tail[index + 1]; + if (value === undefined || value === "" || (inlineValue === undefined && value.startsWith("--"))) { + throw new Error(`Option --${key} requires a value`); + } + if (inlineValue === undefined) { index += 1; - } else { - flags[key] = true; } + if (kind === "format" && value !== "markdown" && value !== "json") { + throw new Error(`Invalid value for --format: ${value} (expected markdown or json)`); + } + flags[key] = value; } return { command, flags, rest }; }