feat(0098): CLI clap migration (iteration 1) — scoped help, --version, GNU flags

Replace the hand-rolled aura-cli argv parser with a clap derive parser.
Delivers, all from one declarative source:
- scoped `aura <sub> --help` (stdout, exit 0) — each subcommand's own Options
  section; retires the #131 uniform-global-blob help
- `--version` / `-V` → `aura 0.1.0` (stdout, exit 0)
- GNU `--flag=value`, the `--` end-of-options terminator, and long-option
  abbreviation (`--harn` → `--harness`)

Architecture: one root Cli/Command enum, one `*Cmd` struct per subcommand. The
four dual-grammar subcommands (run/sweep/walkforward/mc) map the loaded-blueprint
branch as an optional `[blueprint]` positional + a post-parse `is_file()`
dispatch on a single-sourced predicate; each built-in handler re-asserts a
stray-positional guard (refuse-don't-guess) so clap's optional positional cannot
silently swallow a typo (pinned by two new E2E tests). The execution layer
(run_*/emit_*/runs_*/reproduce_*) is untouched — only arg-plumbing changes, via
thin `*_from` adapters reusing the existing value helpers.

Exit codes behaviour-preserved: clap parse errors exit 2 (matching the old
usage-error=2); domain refusals stay exit 2; reproduce-diverged stays exit 1.
The exit-code split (2=usage / 1=runtime) is iteration 2. clap is admitted under
the C16 per-case review (research-side CLI, invariant 8 untouched — a dev-loop
compile tax, not a frozen-artifact tax).

Forced plan corrections (implementer; verified against the diff):
- Step 6's deletion of the `parse_*_args` fns orphaned ~34 grammar unit tests
  that called them; those were deleted and that grammar coverage moved to the
  renegotiated cli_run.rs E2E pins (`parse_select` kept + retested).
- run_malformed_cost_value_...: a bad cost value is now a clap value-parse error
  (no "Usage:" line); renegotiated to pin exit 2 + empty stdout + the flag name
  "--cost-per-trade"; the units note stays pinned via `run --help`.
- `allow_hyphen_values` on the three cost flags so `--cost-per-trade -0.5`
  reaches the non-negativity guard (else clap rejects -0.5 as an unknown option).
- Deleted the now-dead `RealWindowGrammar` (clippy -D warnings).

Orchestrator additions after the loop:
- Enabled long-option abbreviation (`infer_long_args` on the root — one
  attribute, it propagates to subcommands) + a test, closing spec acceptance
  criterion 4 which the plan's RED tests did not cover. Decided to deliver
  rather than amend the spec: the two ratified decisions conflict on this
  low-tier item — Fork B (GNU compliance, getopt_long abbreviates) vs F5
  (LLM/automation-first, human-only niceties deprioritized) — and the cost
  tiebreaker (one attribute, not per-struct work) resolves to deliver.

Carry-on debt (not this iteration): the error-message "Usage:"/"usage:"/bare
casing is now mixed (clap's capitalized "Usage:" beside preserved lowercase aura
messages); a deliberate quality-hold since the casings are pinned by
renegotiated/preserved tests. Cosmetic; a candidate for the iteration-2
exit-code-split cleanup.

Verified green (orchestrator, this session): cargo build --workspace, cargo
clippy --workspace --all-targets -- -D warnings, and cargo test --workspace all
clean (0 failed across every test binary, including the renegotiated cli_run
pins and the new --version / scoped-help / GNU-flag / dual-grammar tests).

refs #175
This commit is contained in:
2026-07-01 18:56:46 +02:00
parent 215f403496
commit 366170aeab
5 changed files with 1303 additions and 1447 deletions
Generated
+136
View File
@@ -37,6 +37,56 @@ dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "arbitrary"
version = "1.4.2"
@@ -65,6 +115,7 @@ dependencies = [
"aura-ingest",
"aura-registry",
"aura-std",
"clap",
"data-server",
"libc",
"serde",
@@ -231,6 +282,52 @@ dependencies = [
"inout",
]
[[package]]
name = "clap"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "constant_time_eq"
version = "0.3.1"
@@ -427,6 +524,12 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hmac"
version = "0.12.1"
@@ -479,6 +582,12 @@ dependencies = [
"generic-array",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
@@ -577,6 +686,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "pbkdf2"
version = "0.12.2"
@@ -771,6 +886,12 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
@@ -839,6 +960,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "version_check"
version = "0.9.5"
@@ -958,6 +1085,15 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
+6
View File
@@ -40,3 +40,9 @@ libc = "0.2"
# SHA256 user-settled); lives in the research-side CLI, off the frozen engine
# (invariant 8).
sha2 = "0.10"
# clap: the vetted standard argument parser. Adopted (C16 per-case review) to
# replace the hand-rolled argv parser + ten duplicated help surfaces with one
# declarative source, giving scoped --help, --version, --flag=value, and
# long-option abbreviation. Research-side CLI only (invariant 8): a dev-loop
# compile tax, never a frozen-artifact tax.
clap = { version = "4", features = ["derive"] }
+49 -46
View File
@@ -178,61 +178,64 @@ pub fn introspect_unwired(doc: &str) -> Result<String, String> {
Ok(out)
}
/// `aura graph introspect`: dispatch the three read-only queries.
pub fn introspect_cmd(rest: &[&str]) {
match rest {
["--vocabulary"] => {
for t in std_vocabulary_types() {
println!("{t}");
}
/// `aura graph introspect`: dispatch the read-only queries. Exactly one of
/// `--vocabulary` / `--node <T>` / `--unwired` / `--content-id` must be set;
/// zero or more than one is the usage error (exit 2).
pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd) {
let count = cmd.vocabulary as usize
+ cmd.node.is_some() as usize
+ cmd.unwired as usize
+ cmd.content_id as usize;
if count != 1 {
eprintln!(
"aura: usage: aura graph introspect --vocabulary | --node <T> | --unwired | --content-id"
);
std::process::exit(2);
}
if cmd.vocabulary {
for t in std_vocabulary_types() {
println!("{t}");
}
["--node", type_id] => match introspect_node(type_id) {
} else if let Some(type_id) = cmd.node.as_deref() {
match introspect_node(type_id) {
Ok(s) => print!("{s}"),
Err(m) => {
eprintln!("aura: {m}");
std::process::exit(2);
}
},
["--unwired"] => {
use std::io::Read;
let mut doc = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut doc) {
eprintln!("aura: reading stdin: {e}");
std::process::exit(2);
}
match introspect_unwired(&doc) {
Ok(s) => print!("{s}"),
Err(m) => {
eprintln!("aura: {m}");
std::process::exit(2);
}
}
}
["--content-id"] => {
// the content id (#158) of the op-list's canonical blueprint: SHA256 (hex)
// of the same `blueprint_to_json` bytes `graph build` emits, via the one
// shared `crate::content_id` primitive `topology_hash` also uses — so this
// surface and hashing a `graph build` output agree by construction.
use std::io::Read;
let mut doc = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut doc) {
eprintln!("aura: reading stdin: {e}");
std::process::exit(2);
}
match build_from_str(&doc) {
Ok(json) => println!("{}", crate::content_id(&json)),
Err(m) => {
eprintln!("aura: {m}");
std::process::exit(2);
}
}
}
_ => {
eprintln!(
"aura: usage: aura graph introspect --vocabulary | --node <T> | --unwired | --content-id"
);
} else if cmd.unwired {
use std::io::Read;
let mut doc = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut doc) {
eprintln!("aura: reading stdin: {e}");
std::process::exit(2);
}
match introspect_unwired(&doc) {
Ok(s) => print!("{s}"),
Err(m) => {
eprintln!("aura: {m}");
std::process::exit(2);
}
}
} else {
// --content-id: the content id (#158) of the op-list's canonical blueprint:
// SHA256 (hex) of the same `blueprint_to_json` bytes `graph build` emits, via
// the one shared `crate::content_id` primitive `topology_hash` also uses — so
// this surface and hashing a `graph build` output agree by construction.
use std::io::Read;
let mut doc = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut doc) {
eprintln!("aura: reading stdin: {e}");
std::process::exit(2);
}
match build_from_str(&doc) {
Ok(json) => println!("{}", crate::content_id(&json)),
Err(m) => {
eprintln!("aura: {m}");
std::process::exit(2);
}
}
}
}
+910 -1364
View File
File diff suppressed because it is too large Load Diff
+202 -37
View File
@@ -113,7 +113,7 @@ fn run_with_trailing_token_is_strict_and_exits_two() {
String::from_utf8_lossy(&out.stdout)
);
let stderr = String::from_utf8(out.stderr).expect("utf-8 stderr");
assert!(stderr.contains("usage"), "stderr was: {stderr:?}");
assert!(stderr.contains("Usage"), "stderr was: {stderr:?}");
// Positive-preservation: bare `aura run` still runs and prints the report.
let ok = Command::new(BIN).arg("run").output().expect("spawn aura run");
@@ -349,6 +349,43 @@ fn aura_run_rejects_an_unknown_node_blueprint() {
assert!(stderr.contains("UnknownNodeType"), "names the cause: {stderr}");
}
/// Property (#175, refuse-don't-guess): built-in-only flags on `aura run
/// <blueprint.json>` are REFUSED, not silently dropped. The loaded-blueprint
/// grammar accepts only `--params`/`--seed`/`--real`/`--from`/`--to`; the removed
/// hand-parser rejected any other flag (exit 2). clap's optional `[blueprint]`
/// positional makes `--trace` and the cost flags structurally parseable, so the
/// dispatch guard must re-reject them — mirroring the sweep/mc blueprint branches,
/// which reject their non-branch flags exhaustively. A regression that dropped the
/// guard would silently no-op a user-provided flag.
#[test]
fn run_blueprint_rejects_builtin_only_flags_exit_two() {
for extra in [
&["--trace", "foo"][..],
&["--cost-per-trade", "1"][..],
&["--slip-vol-mult", "1"][..],
&["--carry-per-cycle", "1"][..],
] {
let mut args = vec!["run", "tests/fixtures/stage1_signal.json"];
args.extend_from_slice(extra);
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(&args)
.output()
.expect("spawn aura run blueprint + builtin-only flag");
assert_eq!(
out.status.code(),
Some(2),
"{args:?} must be refused (exit 2), got {:?}; stderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
assert!(
out.stdout.is_empty(),
"{args:?} must not emit a report on stdout: {:?}",
String::from_utf8_lossy(&out.stdout)
);
}
}
#[test]
fn run_trace_persists_taps_and_plain_run_writes_no_traces() {
let cwd = temp_cwd("run-trace");
@@ -629,7 +666,7 @@ fn no_args_prints_usage_and_exits_two() {
assert_eq!(out.status.code(), Some(2), "exit status: {:?}", out.status);
assert!(out.stdout.is_empty(), "stdout should be empty on the usage path");
let stderr = String::from_utf8(out.stderr).expect("utf-8 stderr");
assert!(stderr.contains("usage"), "stderr was: {stderr:?}");
assert!(stderr.contains("Usage"), "stderr was: {stderr:?}");
}
/// Property: `--help`/`-h` is the success-path help affordance, not the error
@@ -664,32 +701,21 @@ fn help_flag_prints_usage_to_stdout_and_exits_zero() {
);
}
/// Property (#131): `--help`/`-h` is UNIFORM across subcommands — `aura <sub> --help`
/// prints usage to stdout and exits 0 for every subcommand, not only the bare `aura
/// --help`. Pre-fix, `aura chart --help` errored with "unexpected chart argument"
/// (exit 2) while `aura run --help` leaked usage to stderr; this pins the uniform
/// success-path help affordance for all subcommands.
/// Property (post-clap migration, #175): subcommand help is SCOPED — `aura <sub>
/// --help` (and `-h`) prints that subcommand's own options to stdout and exits 0,
/// with nothing on stderr, for every subcommand. This supersedes the retired #131
/// "uniform (byte-identical) help" surface: clap gives each subcommand its own
/// scoped Options section rather than one shared global usage blob.
#[test]
fn per_subcommand_help_is_uniform_stdout_exit_zero() {
fn per_subcommand_help_is_scoped_stdout_exit_zero() {
for sub in ["run", "chart", "sweep", "walkforward", "mc", "graph", "runs"] {
for flag in ["--help", "-h"] {
let out = Command::new(BIN).args([sub, flag]).output().expect("spawn aura <sub> --help");
assert_eq!(
out.status.code(),
Some(0),
"`aura {sub} {flag}` should exit 0, got: {:?}",
out.status
);
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
assert!(
stdout.contains("usage"),
"`aura {sub} {flag}` should print usage to stdout, got: {stdout:?}"
);
assert!(
out.stderr.is_empty(),
"`aura {sub} {flag}` should not write to stderr, got: {:?}",
String::from_utf8_lossy(&out.stderr)
);
for help in ["--help", "-h"] {
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([sub, help]).output().expect("spawn");
assert_eq!(out.status.code(), Some(0), "{sub} {help} exit");
assert!(!out.stdout.is_empty(), "{sub} {help} stdout empty");
assert!(out.stderr.is_empty(), "{sub} {help} stderr not empty: {:?}",
String::from_utf8_lossy(&out.stderr));
}
}
}
@@ -703,17 +729,20 @@ fn run_help_surfaces_cost_flag_units_note() {
let out = Command::new(BIN).args(["run", "--help"]).output().expect("spawn aura run --help");
assert_eq!(out.status.code(), Some(0), "run --help exits 0: {:?}", out.status);
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
assert!(stdout.contains("per ENGINE cycle"), "help must explain carry units: {stdout}");
assert!(stdout.contains("stage1-r only"), "help must state the R-harness requirement: {stdout}");
// clap wraps `long_help` at the render width, which can split a multi-word phrase
// across lines; pin the single tokens clap never hyphenates instead of the
// multi-word "per ENGINE cycle".
assert!(stdout.contains("ENGINE"), "help must explain carry units: {stdout}");
assert!(stdout.contains("cycle"), "help must explain the per-cycle carry scale: {stdout}");
assert!(stdout.contains("stage1-r"), "help must state the R-harness requirement: {stdout}");
}
/// Property (#153): the cost-flag note reaches the user through the *grammar-error*
/// path too, not only `--help`. A malformed cost value (`--cost-per-trade x`) is a
/// usage error → exit 2 with the note on STDERR (nothing on stdout). The note is
/// appended at two independent source sites (the `USAGE` const for `--help` and the
/// `parse_run_args` usage closure for grammar errors); the help test pins the first,
/// this pins the second — the more frequently hit surface — at the binary boundary,
/// where the closure's String must actually route to stderr+exit-2.
/// Property (#153/#175): a malformed cost value (`--cost-per-trade x`, not a f64) is
/// a usage error — exit 2 with a Usage line on STDERR and nothing on stdout. Post-clap
/// migration the terse parse error names the offending flag and points to `--help`
/// (where the units note lives, pinned by `run_help_surfaces_cost_flag_units_note`);
/// clap does not inline `long_help` into a parse error, so the multi-word units note
/// no longer rides the grammar-error path — the pin is the exit code + Usage surface.
#[test]
fn run_malformed_cost_value_usage_error_carries_note_on_stderr() {
let out = Command::new(BIN)
@@ -724,8 +753,7 @@ fn run_malformed_cost_value_usage_error_carries_note_on_stderr() {
String::from_utf8_lossy(&out.stderr));
assert!(out.stdout.is_empty(), "a usage error must not emit a report on stdout: {:?}", out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("usage"), "the grammar-error path stays a usage message: {stderr:?}");
assert!(stderr.contains("per ENGINE cycle"), "the grammar-error usage must carry the cost-flag note: {stderr:?}");
assert!(stderr.contains("--cost-per-trade"), "the parse error names the offending flag: {stderr:?}");
}
/// Property: `aura graph` emits a single self-contained HTML page — the
@@ -3854,3 +3882,140 @@ fn aura_sweep_list_axes_names_round_trip_into_a_working_sweep() {
let _ = std::fs::remove_dir_all(&cwd);
}
/// `--version` / `-V` surface the crate version on stdout and exit 0 (GNU MUST):
/// a version query is a successful query, not a usage error. The expected string
/// is the crate version itself (`CARGO_PKG_VERSION`, i.e. the workspace version),
/// so this stays honest across version bumps and keeps one source of truth.
#[test]
fn version_flag_prints_version_to_stdout_and_exits_zero() {
for flag in ["--version", "-V"] {
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.arg(flag)
.output()
.expect("spawn aura");
assert_eq!(out.status.code(), Some(0), "{flag}: exit {:?}", out.status);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains(env!("CARGO_PKG_VERSION")), "{flag} stdout: {stdout:?}");
}
}
/// Subcommand help is SCOPED: `aura sweep --help` names sweep's own flags and is
/// NOT byte-identical to `aura run --help` — each subcommand documents its own
/// options rather than reprinting one shared global blob (the #131 uniform-help
/// surface retired).
#[test]
fn subcommand_help_is_scoped_not_uniform() {
let sweep = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["sweep", "--help"]).output().expect("spawn");
let run = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["run", "--help"]).output().expect("spawn");
assert_eq!(sweep.status.code(), Some(0), "sweep --help exit");
assert_eq!(run.status.code(), Some(0), "run --help exit");
let sweep_out = String::from_utf8_lossy(&sweep.stdout);
let run_out = String::from_utf8_lossy(&run.stdout);
assert!(sweep_out.contains("--strategy"), "sweep help names its flags: {sweep_out:?}");
assert_ne!(sweep_out, run_out, "per-subcommand help must be scoped, not uniform");
}
/// The GNU `--flag=value` equals form is accepted, equivalent to the
/// space-separated `--flag value`.
#[test]
fn gnu_equals_form_is_accepted() {
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["run", "--harness=sma"]).output().expect("spawn");
assert_eq!(out.status.code(), Some(0),
"run --harness=sma should run; stderr: {}", String::from_utf8_lossy(&out.stderr));
}
/// The `--` end-of-options terminator is recognized (a bare `--` after the flags
/// is a no-op that still runs the default harness).
#[test]
fn double_dash_terminator_is_recognized() {
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["run", "--harness", "sma", "--"]).output().expect("spawn");
assert_eq!(out.status.code(), Some(0),
"run ... -- should run; stderr: {}", String::from_utf8_lossy(&out.stderr));
}
/// GNU long-option abbreviation (spec acceptance criterion 4): an unambiguous prefix
/// of a long flag is accepted (`--harn` → `--harness`), via clap `infer_long_args`
/// on the root command (which propagates to subcommands). An LLM/automation caller
/// always writes the full flag, but GNU compliance (the cycle's ratified purpose)
/// includes abbreviation, and clap delivers it with one root attribute.
#[test]
fn long_option_abbreviation_is_accepted() {
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["run", "--harn", "sma"]).output().expect("spawn");
assert_eq!(out.status.code(), Some(0),
"run --harn sma (abbrev of --harness) should run; stderr: {}",
String::from_utf8_lossy(&out.stderr));
}
/// Property (#175, dual-grammar built-in branch stays strict): a leading positional
/// that is NOT an existing `.json` file is a usage error (exit 2), never silently
/// swallowed — for every dual-grammar subcommand (run/sweep/walkforward/mc). The
/// clap migration models the loaded-blueprint `[BLUEPRINT]` as an OPTIONAL positional
/// on one shared args struct; that makes a typo'd leading token (`aura run bogus`,
/// meant as `--harness bogus`) structurally parseable where the old hand-parser
/// rejected any stray token. Each built-in handler therefore re-asserts the guard
/// (`if a.blueprint.is_some() { usage; exit 2 }`). Dropping any one guard would
/// silently run the default harness on a mistyped invocation — the exact regression
/// clap's optional positional invites and this pins shut. Exit 2 preserved (the
/// usage/runtime split is iteration 2); nothing on stdout on the refusal path.
#[test]
fn dual_grammar_stray_positional_is_a_usage_error_not_swallowed() {
// A trailing flag is added where the built-in grammar needs one to reach the
// guard rather than a bare-run default; `bogus` never ends in `.json`, so the
// is_file() discriminator sends every case down the built-in branch.
for argv in [
&["run", "bogus"][..],
&["sweep", "bogus", "--fast", "3"][..],
&["walkforward", "bogus", "--strategy", "sma"][..],
&["mc", "bogus"][..],
] {
let out = Command::new(BIN).args(argv).output().expect("spawn aura <sub> <stray-positional>");
assert_eq!(
out.status.code(),
Some(2),
"{argv:?} must be refused (exit 2), got {:?}; stderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
assert!(
out.stdout.is_empty(),
"{argv:?} must not emit a report on stdout: {:?}",
String::from_utf8_lossy(&out.stdout)
);
}
}
/// Property (#175, dual-grammar discriminator keys on `is_file()`, not the suffix):
/// `aura run <name>.json` where the file does NOT exist is treated as the built-in
/// grammar with a stray positional (refused, exit 2, built-in usage naming
/// `--harness`), NOT as a loaded blueprint. The discriminator is
/// `ends_with(".json") && Path::is_file()`; a regression that dropped the `is_file()`
/// conjunct (suffix-only) would send this down the blueprint-read branch and fail
/// with a file-read error instead — a different, misleading refusal. The
/// `--harness`-in-stderr assertion pins that the built-in branch was chosen; the
/// positive `.json`-file path stays covered by `aura_run_loads_and_runs_a_blueprint_file`.
#[test]
fn dual_grammar_discriminator_requires_an_existing_file_not_just_json_suffix() {
let out = Command::new(BIN)
.args(["run", "definitely-not-a-real-file.json"])
.output()
.expect("spawn aura run <nonexistent>.json");
assert_eq!(
out.status.code(),
Some(2),
"a .json name that is not a file must be refused (exit 2), got {:?}; stderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
assert!(out.stdout.is_empty(), "no report on stdout: {:?}", String::from_utf8_lossy(&out.stdout));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("--harness"),
"the built-in run grammar (not the blueprint-read path) must handle it: {stderr:?}"
);
}