Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions src/args.test.ts
Original file line number Diff line number Diff line change
@@ -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/
);
});
52 changes: 44 additions & 8 deletions src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,31 @@ export interface ParsedArgs {
rest: string[];
}

type OptionKind = "boolean" | "string" | "format";

const COMMAND_OPTIONS: Record<string, Record<string, OptionKind>> = {
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<string, string | boolean> = {};
const rest: string[] = [];
for (let index = 0; index < tail.length; index += 1) {
Expand All @@ -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 };
}
Expand Down