Skip to main content

rac_engine/
cli.rs

1//! CLI argv surface (PORT-CONTRACT.d/01).
2//!
3//! Parity scope: exit codes, stdout bytes, and the final
4//! `<prog>: error: <msg>` stderr line. Usage/help BODY text is out of scope
5//! (decision 9) — stdout stays byte-identical (empty on errors).
6
7use crate::commands::{
8    cmd_coverage, cmd_decisions_for, cmd_diff, cmd_doctor, cmd_eval, cmd_export, cmd_find,
9    cmd_gate, cmd_herald, cmd_hook, cmd_improve, cmd_index, cmd_init, cmd_inspect, cmd_mcp_stats, cmd_migrate,
10    cmd_new, cmd_portfolio, cmd_quickstart, cmd_relationships, cmd_rename, cmd_resolve,
11    cmd_retrieve, cmd_review, cmd_schema, cmd_sentry, cmd_skill, cmd_stats, cmd_telemetry,
12    cmd_templates, cmd_usage, cmd_validate, CoverageArgs, DecisionsForArgs, DiffArgs, DoctorArgs,
13    EvalArgs, ExportArgs, FindArgs, GateArgs, HeraldArgs, HookArgs, ImproveArgs, IndexArgs, InitArgs,
14    InspectArgs, McpStatsArgs, MigrateArgs, NewArgs, PortfolioArgs, QuickstartArgs,
15    RelationshipsArgs, RenameArgs, ResolveArgs, RetrieveArgs, ReviewArgs, SchemaArgs, SentryArgs,
16    SkillArgs, StatsArgs, TelemetryArgs, TemplatesArgs, UsageArgs, ValidateArgs, WatchkeeperArgs,
17};
18use crate::commands::cmd_watchkeeper;
19use crate::output::rac_version;
20
21/// Root subcommand table, in argparse declaration order (the order the
22/// `invalid choice` message quotes).
23const SUBCOMMANDS: [&str; 32] = [
24    "validate",
25    "diff",
26    "stats",
27    "ingest",
28    "inspect",
29    "improve",
30    "schema",
31    "relationships",
32    "rename",
33    "review",
34    "doctor",
35    "coverage",
36    "gate",
37    "watchkeeper",
38    "portfolio",
39    "index",
40    "export",
41    "mcp",
42    "mcp-stats",
43    "telemetry",
44    "usage",
45    "new",
46    "templates",
47    "init",
48    "quickstart",
49    "resolve",
50    "find",
51    "decisions-for",
52    "eval",
53    "migrate",
54    "skill",
55    "hook",
56];
57
58fn version_line() -> String {
59    format!("decided {}", rac_version())
60}
61
62fn print_stdout(text: &str) {
63    use std::io::Write;
64    let mut out = std::io::stdout().lock();
65    let _ = out.write_all(text.as_bytes());
66    let _ = out.write_all(b"\n");
67    let _ = out.flush();
68}
69
70/// The ADR-046 recorder gate: the oracle's `cli.main` computes the command
71/// name only AFTER `parse_args` returns, so argparse-level exits (parse
72/// errors, `--version`/`-h` actions) never record a usage event, while
73/// dispatched commands record `ok`/`error` from the exit code. Every
74/// parse-level early return in this module raises this flag.
75static PARSE_LEVEL_EXIT: std::sync::atomic::AtomicBool =
76    std::sync::atomic::AtomicBool::new(false);
77
78fn skip_usage_record() {
79    PARSE_LEVEL_EXIT.store(true, std::sync::atomic::Ordering::Relaxed);
80}
81
82/// argparse-style error: usage to stderr, final `<prog>: error: <msg>` line,
83/// exit 2. The usage body is out of parity scope; only the last line is
84/// contract-shaped.
85fn argparse_error(prog: &str, message: &str) -> u8 {
86    skip_usage_record();
87    eprintln!("usage: {prog} ...");
88    eprintln!("{prog}: error: {message}");
89    2
90}
91
92/// Leftover-token rejection; argparse reports these against the root prog
93/// (`decided`), not the subcommand.
94fn unrecognized(extras: &[String]) -> u8 {
95    argparse_error(
96        "decided",
97        &format!("unrecognized arguments: {}", extras.join(" ")),
98    )
99}
100
101fn invalid_choice_message(token: &str) -> String {
102    let choices = SUBCOMMANDS
103        .iter()
104        .map(|s| format!("'{s}'"))
105        .collect::<Vec<_>>()
106        .join(", ");
107    format!("argument command: invalid choice: '{token}' (choose from {choices})")
108}
109
110/// Dispatch plus the ADR-046 usage recorder: one content-free event per
111/// dispatched command, gated by recorded consent, silent-fail, after the
112/// command completes (never before, so `telemetry on` records itself under
113/// its own freshly-written consent, exactly like the oracle).
114pub fn run(args: &[String]) -> u8 {
115    let start = std::time::Instant::now();
116    let code = run_dispatch(args);
117    if !PARSE_LEVEL_EXIT.load(std::sync::atomic::Ordering::Relaxed) {
118        if let Some(command) = args.first().filter(|a| !a.starts_with('-')) {
119            let outcome = if code == 0 {
120                crate::usage::OUTCOME_OK
121            } else {
122                crate::usage::OUTCOME_ERROR
123            };
124            crate::usage::record_command(
125                command,
126                outcome,
127                start.elapsed().as_millis() as i64,
128            );
129        }
130    }
131    code
132}
133
134fn run_dispatch(args: &[String]) -> u8 {
135    let mut it = args.iter();
136    let first = match it.next() {
137        None => return argparse_error("decided", "the following arguments are required: command"),
138        Some(a) if a == "--version" => {
139            skip_usage_record();
140            print_stdout(&version_line());
141            return 0;
142        }
143        Some(a) if a == "-h" || a == "--help" => {
144            // Help body is out of parity scope; emit a stub to stdout.
145            skip_usage_record();
146            print_stdout("usage: decided [-h] [--version] <command> ...");
147            return 0;
148        }
149        Some(a) => a,
150    };
151
152    if first.starts_with('-') {
153        return argparse_error("decided", &format!("unrecognized arguments: {first}"));
154    }
155    // Native-only additions dispatch but are deliberately NOT in SUBCOMMANDS:
156    // the retired Python oracle's `invalid choice` bytes remain pinned by the
157    // bounded compatibility suite.
158    if !matches!(first.as_str(), "retrieve" | "sentry" | "herald")
159        && !SUBCOMMANDS.contains(&first.as_str())
160    {
161        return argparse_error("decided", &invalid_choice_message(first));
162    }
163
164    let rest: Vec<&String> = it.collect();
165
166    // `--version` short-circuits on every subcommand (version_parent) —
167    // EXCEPT where an earlier argv token can error first at its own
168    // position: choice-validated positionals (telemetry, skill, hook,
169    // migrate's target), a choice-validated option value (hook --style,
170    // init --ticketing/--profile), an immediate mutex (usage/mcp-stats,
171    // eval --check|--update-baseline), or a value-taking option whose
172    // missing value errors at the encounter point (init --key,
173    // quickstart --key/--type — measured: `quickstart --type --version`
174    // exits 2 while `quickstart --key bad --version` prints the version).
175    // Those parse order-aware and fire version/help at the encounter
176    // point, like argparse. watchkeeper joins the set for its
177    // choice-validated option VALUES (--format/--fail-on) and value-taking
178    // options (--base/--head), measured: `watchkeeper --format bogus
179    // --version` exits 2 while `watchkeeper --version --format bogus`
180    // prints the version.
181    let order_aware = matches!(
182        first.as_str(),
183        "mcp-stats" | "telemetry" | "usage" | "skill" | "hook" | "eval" | "init" | "quickstart"
184            | "migrate" | "watchkeeper"
185    );
186    if !order_aware {
187        if rest.iter().any(|a| a.as_str() == "--version") {
188            skip_usage_record();
189            print_stdout(&version_line());
190            return 0;
191        }
192        if rest.iter().any(|a| a.as_str() == "-h" || a.as_str() == "--help") {
193            skip_usage_record();
194            print_stdout(&format!("usage: decided {first} ..."));
195            return 0;
196        }
197    }
198
199    match first.as_str() {
200        "validate" => run_validate(&rest),
201        "diff" => run_diff(&rest),
202        "inspect" => run_inspect(&rest),
203        "improve" => run_improve(&rest),
204        "relationships" => run_relationships(&rest),
205        "stats" => run_stats(&rest),
206        "schema" => run_schema(&rest),
207        "templates" => run_templates(&rest),
208        "resolve" => run_resolve(&rest),
209        "find" => run_find(&rest),
210        "retrieve" => run_retrieve(&rest),
211        "review" => run_review(&rest),
212        "export" => run_export(&rest),
213        "index" => run_index(&rest),
214        "portfolio" => run_portfolio(&rest),
215        "coverage" => run_coverage(&rest),
216        "decisions-for" => run_decisions_for(&rest),
217        "gate" => run_gate(&rest),
218        "sentry" => run_sentry(&rest),
219        "herald" => run_herald(&rest),
220        "doctor" => run_doctor(&rest),
221        "watchkeeper" => run_watchkeeper(&rest),
222        "mcp-stats" => run_mcp_stats(&rest),
223        "usage" => run_usage(&rest),
224        "telemetry" => run_telemetry(&rest),
225        "skill" => run_skill(&rest),
226        "hook" => run_hook(&rest),
227        "eval" => run_eval(&rest),
228        "new" => run_new(&rest),
229        "init" => run_init(&rest),
230        "quickstart" => run_quickstart(&rest),
231        "rename" => run_rename(&rest),
232        "migrate" => run_migrate(&rest),
233        other => {
234            eprintln!("decided-rs: subcommand '{other}' is not yet implemented");
235            2
236        }
237    }
238}
239
240struct FlagError(u8);
241
242/// Track the last-seen member of an argparse mutually-exclusive group and
243/// error like argparse does on a conflict. Returns the exit code on conflict.
244fn mutex_check(prog: &str, new_flag: &str, other_flag: &str, other_set: bool) -> Option<u8> {
245    if other_set {
246        Some(argparse_error(
247            prog,
248            &format!("argument {new_flag}: not allowed with argument {other_flag}"),
249        ))
250    } else {
251        None
252    }
253}
254
255/// Consume a single-argument option's value: the inline `--flag=VALUE` form,
256/// or the next token when it reads as a value (bare `-` counts; other
257/// `-`-leading tokens do not). A missing value errors immediately at this
258/// argv position with `argument <flag>: expected one argument`.
259fn take_opt_value(
260    prog: &str,
261    flag: &str,
262    arg: &str,
263    rest: &[&String],
264    i: &mut usize,
265) -> Result<String, u8> {
266    if let Some(inline) = arg.strip_prefix(flag).and_then(|r| r.strip_prefix('=')) {
267        return Ok(inline.to_string());
268    }
269    *i += 1;
270    match rest.get(*i) {
271        Some(v) if !v.starts_with('-') || v.as_str() == "-" => Ok(v.to_string()),
272        _ => Err(argparse_error(
273            prog,
274            &format!("argument {flag}: expected one argument"),
275        )),
276    }
277}
278
279fn run_validate(rest: &[&String]) -> u8 {
280    let prog = "decided validate";
281    let mut file: Option<String> = None;
282    let mut json = false;
283    let mut sarif = false;
284    let mut top_level = false;
285    let mut corpus: Option<String> = None;
286    let mut cache = true;
287    let mut verify = false;
288    let mut extras: Vec<String> = Vec::new();
289    let mut positional_only = false;
290
291    let mut i = 0;
292    while i < rest.len() {
293        let arg = rest[i].as_str();
294        if positional_only || arg == "-" || !arg.starts_with('-') {
295            if file.is_none() {
296                file = Some(arg.to_string());
297            } else {
298                extras.push(arg.to_string());
299            }
300            i += 1;
301            continue;
302        }
303        match arg {
304            "--" => positional_only = true,
305            "--json" => {
306                if let Some(code) = mutex_check(prog, "--json", "--sarif", sarif) {
307                    return code;
308                }
309                json = true;
310            }
311            "--sarif" => {
312                if let Some(code) = mutex_check(prog, "--sarif", "--json", json) {
313                    return code;
314                }
315                sarif = true;
316            }
317            "--top-level" => top_level = true,
318            "--recursive" => {} // affirmation of the default
319            "--cache" => cache = true,
320            "--no-cache" => cache = false,
321            "--verify" => verify = true,
322            other if other == "--corpus" || other.starts_with("--corpus=") => {
323                match take_opt_value(prog, "--corpus", other, rest, &mut i) {
324                    Ok(v) => corpus = Some(v),
325                    Err(code) => return code,
326                }
327            }
328            other => extras.push(other.to_string()),
329        }
330        i += 1;
331    }
332
333    let Some(file) = file else {
334        return argparse_error(prog, "the following arguments are required: file");
335    };
336    if !extras.is_empty() {
337        return unrecognized(&extras);
338    }
339
340    cmd_validate(&ValidateArgs {
341        file,
342        json,
343        sarif,
344        top_level,
345        corpus,
346        cache,
347        verify,
348    }) as u8
349}
350
351fn run_diff(rest: &[&String]) -> u8 {
352    let prog = "decided diff";
353    let mut old: Option<String> = None;
354    let mut new: Option<String> = None;
355    let mut json = false;
356    let mut extras: Vec<String> = Vec::new();
357    let mut positional_only = false;
358
359    for arg in rest {
360        let arg = arg.as_str();
361        if positional_only || arg == "-" || !arg.starts_with('-') {
362            if old.is_none() {
363                old = Some(arg.to_string());
364            } else if new.is_none() {
365                new = Some(arg.to_string());
366            } else {
367                extras.push(arg.to_string());
368            }
369            continue;
370        }
371        match arg {
372            "--" => positional_only = true,
373            "--json" => json = true,
374            other => extras.push(other.to_string()),
375        }
376    }
377
378    // argparse reports every still-missing required positional at once:
379    // neither given -> "old, new"; only `old` given -> "new".
380    let (Some(old), Some(new)) = (old.clone(), new) else {
381        let missing = if old.is_none() { "old, new" } else { "new" };
382        return argparse_error(
383            prog,
384            &format!("the following arguments are required: {missing}"),
385        );
386    };
387    if !extras.is_empty() {
388        // Leftover positionals surface as the TOP-LEVEL parser's error.
389        return unrecognized(&extras);
390    }
391
392    cmd_diff(&DiffArgs { old, new, json }) as u8
393}
394
395fn run_inspect(rest: &[&String]) -> u8 {
396    let prog = "decided inspect";
397    let mut file: Option<String> = None;
398    let mut verbose = false;
399    let mut top_level = false;
400    let mut json = false;
401    let mut extras: Vec<String> = Vec::new();
402    let mut positional_only = false;
403
404    for arg in rest {
405        let arg = arg.as_str();
406        if positional_only || arg == "-" || !arg.starts_with('-') {
407            if file.is_none() {
408                file = Some(arg.to_string());
409            } else {
410                extras.push(arg.to_string());
411            }
412            continue;
413        }
414        match arg {
415            "--" => positional_only = true,
416            "--verbose" => verbose = true,
417            "--top-level" => top_level = true,
418            "--recursive" => {} // affirmation of the default
419            "--json" => json = true,
420            other => extras.push(other.to_string()),
421        }
422    }
423
424    let Some(file) = file else {
425        return argparse_error(prog, "the following arguments are required: file");
426    };
427    if !extras.is_empty() {
428        return unrecognized(&extras);
429    }
430
431    cmd_inspect(&InspectArgs {
432        file,
433        verbose,
434        top_level,
435        json,
436    }) as u8
437}
438
439fn run_improve(rest: &[&String]) -> u8 {
440    let prog = "decided improve";
441    let mut file: Option<String> = None;
442    let mut json = false;
443    let mut template = false;
444    let mut extras: Vec<String> = Vec::new();
445    let mut positional_only = false;
446
447    for arg in rest {
448        let arg = arg.as_str();
449        if positional_only || arg == "-" || !arg.starts_with('-') {
450            if file.is_none() {
451                file = Some(arg.to_string());
452            } else {
453                extras.push(arg.to_string());
454            }
455            continue;
456        }
457        match arg {
458            "--" => positional_only = true,
459            // `--json | --template` is a local mutually-exclusive group
460            // (improve does NOT inherit json_parent).
461            "--json" => {
462                if let Some(code) = mutex_check(prog, "--json", "--template", template) {
463                    return code;
464                }
465                json = true;
466            }
467            "--template" => {
468                if let Some(code) = mutex_check(prog, "--template", "--json", json) {
469                    return code;
470                }
471                template = true;
472            }
473            other => extras.push(other.to_string()),
474        }
475    }
476
477    let Some(file) = file else {
478        return argparse_error(prog, "the following arguments are required: file");
479    };
480    if !extras.is_empty() {
481        return unrecognized(&extras);
482    }
483
484    cmd_improve(&ImproveArgs {
485        file,
486        json,
487        template,
488    }) as u8
489}
490
491fn run_relationships(rest: &[&String]) -> u8 {
492    let prog = "decided relationships";
493    let mut path: Option<String> = None;
494    let mut validate = false;
495    let mut sarif = false;
496    let mut json = false;
497    let mut top_level = false;
498    let mut extras: Vec<String> = Vec::new();
499    let mut positional_only = false;
500
501    for arg in rest {
502        let arg = arg.as_str();
503        if positional_only || !arg.starts_with('-') {
504            if path.is_none() {
505                path = Some(arg.to_string());
506            } else {
507                extras.push(arg.to_string());
508            }
509            continue;
510        }
511        match arg {
512            "--" => positional_only = true,
513            "--validate" => validate = true,
514            "--sarif" => sarif = true,
515            "--json" => json = true,
516            "--top-level" => top_level = true,
517            "--recursive" => {}
518            other => extras.push(other.to_string()),
519        }
520    }
521
522    let Some(path) = path else {
523        return argparse_error(prog, "the following arguments are required: path");
524    };
525    if !extras.is_empty() {
526        return unrecognized(&extras);
527    }
528
529    cmd_relationships(&RelationshipsArgs {
530        path,
531        validate,
532        sarif,
533        json,
534        top_level,
535    }) as u8
536}
537
538fn run_stats(rest: &[&String]) -> u8 {
539    let prog = "decided stats";
540    let mut directory: Option<String> = None;
541    let mut json = false;
542    let mut extras: Vec<String> = Vec::new();
543    let mut positional_only = false;
544
545    for arg in rest {
546        let arg = arg.as_str();
547        if positional_only || !arg.starts_with('-') {
548            if directory.is_none() {
549                directory = Some(arg.to_string());
550            } else {
551                extras.push(arg.to_string());
552            }
553            continue;
554        }
555        match arg {
556            "--" => positional_only = true,
557            "--json" => json = true,
558            other => extras.push(other.to_string()),
559        }
560    }
561
562    let Some(directory) = directory else {
563        return argparse_error(prog, "the following arguments are required: directory");
564    };
565    if !extras.is_empty() {
566        return unrecognized(&extras);
567    }
568
569    cmd_stats(&StatsArgs { directory, json }) as u8
570}
571
572fn run_portfolio(rest: &[&String]) -> u8 {
573    let prog = "decided portfolio";
574    let mut directory: Option<String> = None;
575    let mut json = false;
576    let mut top_level = false;
577    let mut extras: Vec<String> = Vec::new();
578    let mut positional_only = false;
579
580    for arg in rest {
581        let arg = arg.as_str();
582        if positional_only || arg == "-" || !arg.starts_with('-') {
583            if directory.is_none() {
584                directory = Some(arg.to_string());
585            } else {
586                extras.push(arg.to_string());
587            }
588            continue;
589        }
590        match arg {
591            "--" => positional_only = true,
592            "--json" => json = true,
593            "--top-level" => top_level = true,
594            "--recursive" => {} // affirmation of the default
595            other => extras.push(other.to_string()),
596        }
597    }
598
599    // `directory` is REQUIRED here, unlike the sibling index/export parsers.
600    let Some(directory) = directory else {
601        return argparse_error(prog, "the following arguments are required: directory");
602    };
603    if !extras.is_empty() {
604        return unrecognized(&extras);
605    }
606
607    cmd_portfolio(&PortfolioArgs {
608        directory,
609        json,
610        top_level,
611    }) as u8
612}
613
614/// `decided index [directory] [--json] [--top-level]` — optional positional
615/// (default '.', like the sibling export parser), version/json/scope
616/// parents. No cache flags: index never consumes the cache.
617fn run_index(rest: &[&String]) -> u8 {
618    let mut directory: Option<String> = None;
619    let mut json = false;
620    let mut top_level = false;
621    let mut extras: Vec<String> = Vec::new();
622    let mut positional_only = false;
623
624    for arg in rest {
625        let arg = arg.as_str();
626        if positional_only || arg == "-" || !arg.starts_with('-') {
627            if directory.is_none() {
628                directory = Some(arg.to_string());
629            } else {
630                extras.push(arg.to_string());
631            }
632            continue;
633        }
634        match arg {
635            "--" => positional_only = true,
636            "--json" => json = true,
637            "--top-level" => top_level = true,
638            "--recursive" => {} // affirmation of the default
639            other => extras.push(other.to_string()),
640        }
641    }
642
643    if !extras.is_empty() {
644        return unrecognized(&extras);
645    }
646
647    cmd_index(&IndexArgs {
648        directory: directory.unwrap_or_else(|| ".".to_string()),
649        json,
650        top_level,
651    }) as u8
652}
653
654fn run_coverage(rest: &[&String]) -> u8 {
655    // Optional positional (default '.'); json_parent only — an unknown flag
656    // (e.g. --top-level) bubbles to the TOP-LEVEL parser's error.
657    let mut directory: Option<String> = None;
658    let mut json = false;
659    let mut extras: Vec<String> = Vec::new();
660    let mut positional_only = false;
661
662    for arg in rest {
663        let arg = arg.as_str();
664        if positional_only || arg == "-" || !arg.starts_with('-') {
665            if directory.is_none() {
666                directory = Some(arg.to_string());
667            } else {
668                extras.push(arg.to_string());
669            }
670            continue;
671        }
672        match arg {
673            "--" => positional_only = true,
674            "--json" => json = true,
675            other => extras.push(other.to_string()),
676        }
677    }
678
679    if !extras.is_empty() {
680        return unrecognized(&extras);
681    }
682
683    cmd_coverage(&CoverageArgs {
684        directory: directory.unwrap_or_else(|| ".".to_string()),
685        json,
686    }) as u8
687}
688
689fn run_decisions_for(rest: &[&String]) -> u8 {
690    let prog = "decided decisions-for";
691    let mut path: Option<String> = None;
692    let mut directory: Option<String> = None;
693    let mut json = false;
694    let mut top_level = false;
695    let mut extras: Vec<String> = Vec::new();
696    let mut positional_only = false;
697
698    for arg in rest {
699        let arg = arg.as_str();
700        if positional_only || arg == "-" || !arg.starts_with('-') {
701            if path.is_none() {
702                path = Some(arg.to_string());
703            } else if directory.is_none() {
704                directory = Some(arg.to_string());
705            } else {
706                extras.push(arg.to_string());
707            }
708            continue;
709        }
710        match arg {
711            "--" => positional_only = true,
712            "--json" => json = true,
713            "--top-level" => top_level = true,
714            "--recursive" => {} // affirmation of the default
715            other => extras.push(other.to_string()),
716        }
717    }
718
719    let Some(path) = path else {
720        return argparse_error(prog, "the following arguments are required: path");
721    };
722    if !extras.is_empty() {
723        return unrecognized(&extras);
724    }
725
726    cmd_decisions_for(&DecisionsForArgs {
727        path,
728        directory: directory.unwrap_or_else(|| ".".to_string()),
729        json,
730        top_level,
731    }) as u8
732}
733
734fn run_gate(rest: &[&String]) -> u8 {
735    let prog = "decided gate";
736    let mut directory: Option<String> = None;
737    let mut json = false;
738    let mut sarif = false;
739    let mut top_level = false;
740    let mut code = false;
741    let mut repository = ".".to_string();
742    let mut base: Option<String> = None;
743    let mut full = false;
744    let mut extras: Vec<String> = Vec::new();
745    let mut positional_only = false;
746
747    let mut i = 0;
748    while i < rest.len() {
749        let arg = rest[i].as_str();
750        if positional_only || arg == "-" || !arg.starts_with('-') {
751            if directory.is_none() {
752                directory = Some(arg.to_string());
753            } else {
754                extras.push(arg.to_string());
755            }
756            i += 1;
757            continue;
758        }
759        match arg {
760            "--" => positional_only = true,
761            // `--json | --sarif` is a mutually-exclusive group (like validate).
762            "--json" => {
763                if let Some(code) = mutex_check(prog, "--json", "--sarif", sarif) {
764                    return code;
765                }
766                json = true;
767            }
768            "--sarif" => {
769                if let Some(code) = mutex_check(prog, "--sarif", "--json", json) {
770                    return code;
771                }
772                sarif = true;
773            }
774            "--top-level" => top_level = true,
775            "--code" => code = true,
776            "--full" => full = true,
777            "--repository" | "--base" => {
778                i += 1;
779                if i >= rest.len() {
780                    return argparse_error(prog, &format!("argument {arg}: expected one argument"));
781                }
782                if arg == "--repository" {
783                    repository = rest[i].to_string();
784                } else {
785                    base = Some(rest[i].to_string());
786                }
787            }
788            // NO --recursive here (gate declares --top-level inline, not
789            // scope_parent) — it bubbles to the top-level parser's error.
790            other => extras.push(other.to_string()),
791        }
792        i += 1;
793    }
794
795    // `directory` is a REQUIRED positional — unlike doctor/coverage.
796    let Some(directory) = directory else {
797        return argparse_error(prog, "the following arguments are required: directory");
798    };
799    if !extras.is_empty() {
800        return unrecognized(&extras);
801    }
802
803    cmd_gate(&GateArgs {
804        directory,
805        json,
806        sarif,
807        top_level,
808        code,
809        repository,
810        base,
811        full,
812    }) as u8
813}
814
815fn run_sentry(rest: &[&String]) -> u8 {
816    let prog = "decided sentry";
817    let mut directory: Option<String> = None;
818    let mut repository = ".".to_string();
819    let mut base: Option<String> = None;
820    let mut full = false;
821    let mut json = false;
822    let mut sarif = false;
823    let mut top_level = false;
824    let mut extras = Vec::new();
825    let mut positional_only = false;
826    let mut i = 0;
827    while i < rest.len() {
828        let arg = rest[i].as_str();
829        if positional_only || arg == "-" || !arg.starts_with('-') {
830            if directory.is_none() {
831                directory = Some(arg.to_string());
832            } else {
833                extras.push(arg.to_string());
834            }
835            i += 1;
836            continue;
837        }
838        match arg {
839            "--" => positional_only = true,
840            "--json" => {
841                if let Some(code) = mutex_check(prog, "--json", "--sarif", sarif) {
842                    return code;
843                }
844                json = true;
845            }
846            "--sarif" => {
847                if let Some(code) = mutex_check(prog, "--sarif", "--json", json) {
848                    return code;
849                }
850                sarif = true;
851            }
852            "--full" => full = true,
853            "--top-level" => top_level = true,
854            "--repository" | "--base" => {
855                i += 1;
856                if i >= rest.len() {
857                    return argparse_error(prog, &format!("argument {arg}: expected one argument"));
858                }
859                if arg == "--repository" {
860                    repository = rest[i].to_string();
861                } else {
862                    base = Some(rest[i].to_string());
863                }
864            }
865            other => extras.push(other.to_string()),
866        }
867        i += 1;
868    }
869    let Some(directory) = directory else {
870        return argparse_error(prog, "the following arguments are required: directory");
871    };
872    if !extras.is_empty() {
873        return unrecognized(&extras);
874    }
875    cmd_sentry(&SentryArgs {
876        directory,
877        repository,
878        base,
879        full,
880        json,
881        sarif,
882        top_level,
883    }) as u8
884}
885
886fn run_herald(rest: &[&String]) -> u8 {
887    let prog = "decided herald";
888    let mut directory = None;
889    let mut paths_file = None;
890    let mut link_base = String::new();
891    let mut max_inline = 5i64;
892    let mut out = None;
893    let mut github_output = None;
894    let mut top_level = false;
895    let mut extras = Vec::new();
896    let mut i = 0;
897    while i < rest.len() {
898        let arg = rest[i].as_str();
899        if arg == "-" || !arg.starts_with('-') {
900            if directory.is_none() {
901                directory = Some(arg.to_string());
902            } else {
903                extras.push(arg.to_string());
904            }
905            i += 1;
906            continue;
907        }
908        match arg {
909            "--top-level" => top_level = true,
910            "--paths-file" | "--link-base" | "--max-inline" | "--out" | "--github-output" => {
911                i += 1;
912                if i >= rest.len() {
913                    return argparse_error(prog, &format!("argument {arg}: expected one argument"));
914                }
915                let value = rest[i].to_string();
916                match arg {
917                    "--paths-file" => paths_file = Some(value),
918                    "--link-base" => link_base = value,
919                    "--max-inline" => {
920                        max_inline = match value.parse() {
921                            Ok(value) => value,
922                            Err(_) => {
923                                return argparse_error(
924                                    prog,
925                                    &format!("argument --max-inline: invalid int value: '{value}'"),
926                                )
927                            }
928                        }
929                    }
930                    "--out" => out = Some(value),
931                    "--github-output" => github_output = Some(value),
932                    _ => unreachable!(),
933                }
934            }
935            other => extras.push(other.to_string()),
936        }
937        i += 1;
938    }
939    let Some(directory) = directory else {
940        return argparse_error(prog, "the following arguments are required: directory");
941    };
942    let Some(paths_file) = paths_file else {
943        return argparse_error(prog, "the following arguments are required: --paths-file");
944    };
945    let Some(out) = out else {
946        return argparse_error(prog, "the following arguments are required: --out");
947    };
948    if !extras.is_empty() {
949        return unrecognized(&extras);
950    }
951    cmd_herald(&HeraldArgs {
952        directory,
953        paths_file,
954        link_base,
955        max_inline,
956        out,
957        github_output,
958        top_level,
959    }) as u8
960}
961
962fn run_doctor(rest: &[&String]) -> u8 {
963    let prog = "decided doctor";
964    let mut directory: Option<String> = None;
965    let mut json = false;
966    let mut top_level = false;
967    let mut hub_threshold: i64 = 20; // doctor.DEFAULT_HUB_THRESHOLD
968    let mut extras: Vec<String> = Vec::new();
969    let mut positional_only = false;
970
971    let mut i = 0;
972    while i < rest.len() {
973        let arg = rest[i].as_str();
974        if positional_only || arg == "-" || !arg.starts_with('-') || looks_like_negative_number(arg)
975        {
976            if directory.is_none() {
977                directory = Some(arg.to_string());
978            } else {
979                extras.push(arg.to_string());
980            }
981            i += 1;
982            continue;
983        }
984        match arg {
985            "--" => positional_only = true,
986            "--json" => json = true,
987            "--top-level" => top_level = true,
988            "--recursive" => {} // affirmation of the default (scope_parent)
989            "--hub-threshold" => {
990                // type=int: the next token is a value when it does not look
991                // like an option (bare `-` and negative numbers count).
992                i += 1;
993                let raw = match rest.get(i) {
994                    Some(v)
995                        if !v.starts_with('-')
996                            || v.as_str() == "-"
997                            || looks_like_negative_number(v) =>
998                    {
999                        v.as_str()
1000                    }
1001                    _ => {
1002                        return argparse_error(
1003                            prog,
1004                            "argument --hub-threshold: expected one argument",
1005                        )
1006                    }
1007                };
1008                match py_parse_int(raw) {
1009                    Some(v) => hub_threshold = v,
1010                    None => {
1011                        return argparse_error(
1012                            prog,
1013                            &format!("argument --hub-threshold: invalid int value: '{raw}'"),
1014                        )
1015                    }
1016                }
1017            }
1018            other if other.starts_with("--hub-threshold=") => {
1019                let raw = &other["--hub-threshold=".len()..];
1020                match py_parse_int(raw) {
1021                    Some(v) => hub_threshold = v,
1022                    None => {
1023                        return argparse_error(
1024                            prog,
1025                            &format!("argument --hub-threshold: invalid int value: '{raw}'"),
1026                        )
1027                    }
1028                }
1029            }
1030            other => extras.push(other.to_string()),
1031        }
1032        i += 1;
1033    }
1034
1035    if !extras.is_empty() {
1036        return unrecognized(&extras);
1037    }
1038
1039    cmd_doctor(&DoctorArgs {
1040        directory: directory.unwrap_or_else(|| ".".to_string()),
1041        json,
1042        top_level,
1043        hub_threshold,
1044    }) as u8
1045}
1046
1047/// The shared `[--json | --share]` parser of `mcp-stats` and `usage`
1048/// (identical argparse shapes, no positional). Order-aware: the mutex
1049/// error fires at the ENCOUNTER of the conflicting flag (so it beats a
1050/// later `--version`), unknown tokens defer to the root parser's
1051/// `unrecognized arguments` at end-of-parse (so an earlier `--version`
1052/// beats them), exactly like argparse.
1053fn parse_json_share_group(prog: &str, rest: &[&String]) -> Result<(bool, bool), u8> {
1054    let mut json = false;
1055    let mut share = false;
1056    let mut extras: Vec<String> = Vec::new();
1057    let mut positional_only = false;
1058
1059    for arg in rest {
1060        let arg = arg.as_str();
1061        if positional_only {
1062            extras.push(arg.to_string());
1063            continue;
1064        }
1065        match arg {
1066            "--" => positional_only = true,
1067            "--version" => {
1068                skip_usage_record();
1069                print_stdout(&version_line());
1070                return Err(0);
1071            }
1072            "-h" | "--help" => {
1073                skip_usage_record();
1074                print_stdout(&format!("usage: {prog} ..."));
1075                return Err(0);
1076            }
1077            "--json" => {
1078                if let Some(code) = mutex_check(prog, "--json", "--share", share) {
1079                    return Err(code);
1080                }
1081                json = true;
1082            }
1083            "--share" => {
1084                if let Some(code) = mutex_check(prog, "--share", "--json", json) {
1085                    return Err(code);
1086                }
1087                share = true;
1088            }
1089            other => extras.push(other.to_string()),
1090        }
1091    }
1092
1093    if !extras.is_empty() {
1094        return Err(unrecognized(&extras));
1095    }
1096    Ok((json, share))
1097}
1098
1099fn run_mcp_stats(rest: &[&String]) -> u8 {
1100    match parse_json_share_group("decided mcp-stats", rest) {
1101        Ok((json, share)) => cmd_mcp_stats(&McpStatsArgs { json, share }) as u8,
1102        Err(code) => code,
1103    }
1104}
1105
1106fn run_usage(rest: &[&String]) -> u8 {
1107    match parse_json_share_group("decided usage", rest) {
1108        Ok((json, share)) => cmd_usage(&UsageArgs { json, share }) as u8,
1109        Err(code) => code,
1110    }
1111}
1112
1113fn run_telemetry(rest: &[&String]) -> u8 {
1114    let prog = "decided telemetry";
1115    let mut action: Option<String> = None;
1116    let mut enterprise = false;
1117    let mut unlock = false;
1118    let mut extras: Vec<String> = Vec::new();
1119    let mut positional_only = false;
1120
1121    for arg in rest {
1122        let arg = arg.as_str();
1123        if positional_only || arg == "-" || !arg.starts_with('-') {
1124            if action.is_none() {
1125                // The positional's choice set is validated when the token
1126                // is CONSUMED, so an invalid choice beats a later
1127                // `--version` (measured: `telemetry bogus --version` exits
1128                // 2, `telemetry --version bogus` prints the version).
1129                if !matches!(arg, "on" | "off" | "status") {
1130                    return argparse_error(
1131                        prog,
1132                        &format!(
1133                            "argument action: invalid choice: '{arg}' (choose from 'on', 'off', 'status')"
1134                        ),
1135                    );
1136                }
1137                action = Some(arg.to_string());
1138            } else {
1139                // A second positional is an end-of-parse `unrecognized
1140                // arguments` — deferred, so a later `--version` wins.
1141                extras.push(arg.to_string());
1142            }
1143            continue;
1144        }
1145        match arg {
1146            "--" => positional_only = true,
1147            "--version" => {
1148                skip_usage_record();
1149                print_stdout(&version_line());
1150                return 0;
1151            }
1152            "-h" | "--help" => {
1153                skip_usage_record();
1154                print_stdout(&format!("usage: {prog} ..."));
1155                return 0;
1156            }
1157            "--enterprise" => enterprise = true,
1158            "--unlock" => unlock = true,
1159            other => extras.push(other.to_string()),
1160        }
1161    }
1162
1163    if !extras.is_empty() {
1164        return unrecognized(&extras);
1165    }
1166
1167    cmd_telemetry(&TelemetryArgs {
1168        action: action.unwrap_or_else(|| "status".to_string()),
1169        enterprise,
1170        unlock,
1171    }) as u8
1172}
1173
1174/// `decided skill <action> [name] [--dir DIR] [--json]` — order-aware: the
1175/// `action` positional's choice set is validated when the token is
1176/// CONSUMED (an invalid action beats a later `--version`; an earlier
1177/// `--version` wins), a second positional defers to the end-of-parse
1178/// `unrecognized arguments`, exactly like argparse.
1179fn run_skill(rest: &[&String]) -> u8 {
1180    let prog = "decided skill";
1181    let mut action: Option<String> = None;
1182    let mut name: Option<String> = None;
1183    let mut dir: String = ".".to_string();
1184    let mut json = false;
1185    let mut extras: Vec<String> = Vec::new();
1186    let mut positional_only = false;
1187
1188    let mut i = 0;
1189    while i < rest.len() {
1190        let arg = rest[i].as_str();
1191        if positional_only || arg == "-" || !arg.starts_with('-') {
1192            if action.is_none() {
1193                if !matches!(arg, "install" | "list") {
1194                    return argparse_error(
1195                        prog,
1196                        &format!(
1197                            "argument action: invalid choice: '{arg}' (choose from 'install', 'list')"
1198                        ),
1199                    );
1200                }
1201                action = Some(arg.to_string());
1202            } else if name.is_none() {
1203                name = Some(arg.to_string());
1204            } else {
1205                extras.push(arg.to_string());
1206            }
1207            i += 1;
1208            continue;
1209        }
1210        match arg {
1211            "--" => positional_only = true,
1212            "--version" => {
1213                skip_usage_record();
1214                print_stdout(&version_line());
1215                return 0;
1216            }
1217            "-h" | "--help" => {
1218                skip_usage_record();
1219                print_stdout(&format!("usage: {prog} ..."));
1220                return 0;
1221            }
1222            "--json" => json = true,
1223            other if other == "--dir" || other.starts_with("--dir=") => {
1224                match take_opt_value(prog, "--dir", other, rest, &mut i) {
1225                    Ok(v) => dir = v,
1226                    Err(code) => return code,
1227                }
1228            }
1229            other => extras.push(other.to_string()),
1230        }
1231        i += 1;
1232    }
1233
1234    let Some(action) = action else {
1235        return argparse_error(prog, "the following arguments are required: action");
1236    };
1237    if !extras.is_empty() {
1238        return unrecognized(&extras);
1239    }
1240
1241    cmd_skill(&SkillArgs {
1242        action,
1243        name,
1244        dir,
1245        json,
1246    }) as u8
1247}
1248
1249/// `decided hook <action> [--style STYLE] [--dir DIR] [--json]` — order-aware
1250/// like skill; additionally `--style`'s choice set is validated when its
1251/// VALUE is consumed (so `--style bogus --version` exits 2 while
1252/// `--version --style bogus` prints the version), making the service-level
1253/// unknown-style error unreachable via the CLI.
1254fn run_hook(rest: &[&String]) -> u8 {
1255    let prog = "decided hook";
1256    let mut action: Option<String> = None;
1257    let mut style: String = "post-commit".to_string(); // hooks.DEFAULT_STYLE
1258    let mut dir: String = ".".to_string();
1259    let mut json = false;
1260    let mut extras: Vec<String> = Vec::new();
1261    let mut positional_only = false;
1262
1263    let style_choice = |prog: &str, v: &str| -> Option<u8> {
1264        if matches!(v, "post-commit" | "pre-commit") {
1265            None
1266        } else {
1267            Some(argparse_error(
1268                prog,
1269                &format!(
1270                    "argument --style: invalid choice: '{v}' (choose from 'post-commit', 'pre-commit')"
1271                ),
1272            ))
1273        }
1274    };
1275
1276    let mut i = 0;
1277    while i < rest.len() {
1278        let arg = rest[i].as_str();
1279        if positional_only || arg == "-" || !arg.starts_with('-') {
1280            if action.is_none() {
1281                if !matches!(arg, "install" | "list") {
1282                    return argparse_error(
1283                        prog,
1284                        &format!(
1285                            "argument action: invalid choice: '{arg}' (choose from 'install', 'list')"
1286                        ),
1287                    );
1288                }
1289                action = Some(arg.to_string());
1290            } else {
1291                extras.push(arg.to_string());
1292            }
1293            i += 1;
1294            continue;
1295        }
1296        match arg {
1297            "--" => positional_only = true,
1298            "--version" => {
1299                skip_usage_record();
1300                print_stdout(&version_line());
1301                return 0;
1302            }
1303            "-h" | "--help" => {
1304                skip_usage_record();
1305                print_stdout(&format!("usage: {prog} ..."));
1306                return 0;
1307            }
1308            "--json" => json = true,
1309            other if other == "--style" || other.starts_with("--style=") => {
1310                match take_opt_value(prog, "--style", other, rest, &mut i) {
1311                    Ok(v) => {
1312                        if let Some(code) = style_choice(prog, &v) {
1313                            return code;
1314                        }
1315                        style = v;
1316                    }
1317                    Err(code) => return code,
1318                }
1319            }
1320            other if other == "--dir" || other.starts_with("--dir=") => {
1321                match take_opt_value(prog, "--dir", other, rest, &mut i) {
1322                    Ok(v) => dir = v,
1323                    Err(code) => return code,
1324                }
1325            }
1326            other => extras.push(other.to_string()),
1327        }
1328        i += 1;
1329    }
1330
1331    let Some(action) = action else {
1332        return argparse_error(prog, "the following arguments are required: action");
1333    };
1334    if !extras.is_empty() {
1335        return unrecognized(&extras);
1336    }
1337
1338    cmd_hook(&HookArgs {
1339        action,
1340        style,
1341        dir,
1342        json,
1343    }) as u8
1344}
1345
1346/// `decided watchkeeper [directory] [--base REV] [--head REV]
1347/// [--format {human,json,github}] [--json] [--fail-on {error,warning,none}]
1348/// [--no-annotate]` — order-aware: `--format`/`--fail-on` are
1349/// argparse-choice-validated when their VALUE is consumed and a missing
1350/// `--base`/`--head` value errors at its own position (each beats a later
1351/// `--version`; an earlier `--version` wins). The directory positional is a
1352/// free string (a bad directory defers to dispatch, so `--version` after it
1353/// still wins), and extra positionals defer to the end-of-parse
1354/// `unrecognized arguments`.
1355fn run_watchkeeper(rest: &[&String]) -> u8 {
1356    let prog = "decided watchkeeper";
1357    let mut directory: Option<String> = None;
1358    let mut base: String = "main".to_string();
1359    let mut head: Option<String> = None;
1360    let mut format: String = "human".to_string();
1361    let mut json = false;
1362    let mut fail_on: String = "error".to_string();
1363    let mut annotate = true;
1364    let mut extras: Vec<String> = Vec::new();
1365    let mut positional_only = false;
1366
1367    let format_choice = |v: &str| -> Option<u8> {
1368        if matches!(v, "human" | "json" | "github") {
1369            None
1370        } else {
1371            Some(argparse_error(
1372                prog,
1373                &format!(
1374                    "argument --format: invalid choice: '{v}' (choose from 'human', 'json', 'github')"
1375                ),
1376            ))
1377        }
1378    };
1379    let fail_on_choice = |v: &str| -> Option<u8> {
1380        if matches!(v, "error" | "warning" | "none") {
1381            None
1382        } else {
1383            Some(argparse_error(
1384                prog,
1385                &format!(
1386                    "argument --fail-on: invalid choice: '{v}' (choose from 'error', 'warning', 'none')"
1387                ),
1388            ))
1389        }
1390    };
1391
1392    let mut i = 0;
1393    while i < rest.len() {
1394        let arg = rest[i].as_str();
1395        if positional_only || arg == "-" || !arg.starts_with('-') {
1396            if directory.is_none() {
1397                directory = Some(arg.to_string());
1398            } else {
1399                extras.push(arg.to_string());
1400            }
1401            i += 1;
1402            continue;
1403        }
1404        match arg {
1405            "--" => positional_only = true,
1406            "--version" => {
1407                skip_usage_record();
1408                print_stdout(&version_line());
1409                return 0;
1410            }
1411            "-h" | "--help" => {
1412                skip_usage_record();
1413                print_stdout(&format!("usage: {prog} ..."));
1414                return 0;
1415            }
1416            "--json" => json = true,
1417            "--no-annotate" => annotate = false,
1418            other if other == "--base" || other.starts_with("--base=") => {
1419                match take_opt_value(prog, "--base", other, rest, &mut i) {
1420                    Ok(v) => base = v,
1421                    Err(code) => return code,
1422                }
1423            }
1424            other if other == "--head" || other.starts_with("--head=") => {
1425                match take_opt_value(prog, "--head", other, rest, &mut i) {
1426                    Ok(v) => head = Some(v),
1427                    Err(code) => return code,
1428                }
1429            }
1430            other if other == "--format" || other.starts_with("--format=") => {
1431                match take_opt_value(prog, "--format", other, rest, &mut i) {
1432                    Ok(v) => {
1433                        if let Some(code) = format_choice(&v) {
1434                            return code;
1435                        }
1436                        format = v;
1437                    }
1438                    Err(code) => return code,
1439                }
1440            }
1441            other if other == "--fail-on" || other.starts_with("--fail-on=") => {
1442                match take_opt_value(prog, "--fail-on", other, rest, &mut i) {
1443                    Ok(v) => {
1444                        if let Some(code) = fail_on_choice(&v) {
1445                            return code;
1446                        }
1447                        fail_on = v;
1448                    }
1449                    Err(code) => return code,
1450                }
1451            }
1452            other => extras.push(other.to_string()),
1453        }
1454        i += 1;
1455    }
1456
1457    if !extras.is_empty() {
1458        return unrecognized(&extras);
1459    }
1460
1461    cmd_watchkeeper(&WatchkeeperArgs {
1462        directory,
1463        base,
1464        head,
1465        format,
1466        json,
1467        fail_on,
1468        annotate,
1469    }) as u8
1470}
1471
1472/// `decided eval [--check | --update-baseline] [--json] [--root ROOT]
1473/// [--queries QUERIES] [--baseline BASELINE] [--config CONFIG]` — no
1474/// positionals; the mode mutex errors at the ENCOUNTER of the conflicting
1475/// flag (so it beats a later `--version`), like argparse.
1476fn run_eval(rest: &[&String]) -> u8 {
1477    let prog = "decided eval";
1478    let mut check = false;
1479    let mut update_baseline = false;
1480    let mut json = false;
1481    let mut root: String = "rust/fixtures/eval/corpus".to_string();
1482    let mut queries: String = "rust/fixtures/eval/queries.json".to_string();
1483    let mut baseline: String = "rust/fixtures/eval/baseline.json".to_string();
1484    let mut config: String = "rust/fixtures/eval/eval-config.json".to_string();
1485    let mut extras: Vec<String> = Vec::new();
1486    let mut positional_only = false;
1487
1488    let mut i = 0;
1489    while i < rest.len() {
1490        let arg = rest[i].as_str();
1491        if positional_only || arg == "-" || !arg.starts_with('-') {
1492            extras.push(arg.to_string());
1493            i += 1;
1494            continue;
1495        }
1496        match arg {
1497            "--" => positional_only = true,
1498            "--version" => {
1499                skip_usage_record();
1500                print_stdout(&version_line());
1501                return 0;
1502            }
1503            "-h" | "--help" => {
1504                skip_usage_record();
1505                print_stdout(&format!("usage: {prog} ..."));
1506                return 0;
1507            }
1508            "--check" => {
1509                if let Some(code) =
1510                    mutex_check(prog, "--check", "--update-baseline", update_baseline)
1511                {
1512                    return code;
1513                }
1514                check = true;
1515            }
1516            "--update-baseline" => {
1517                if let Some(code) = mutex_check(prog, "--update-baseline", "--check", check) {
1518                    return code;
1519                }
1520                update_baseline = true;
1521            }
1522            "--json" => json = true,
1523            other if other == "--root" || other.starts_with("--root=") => {
1524                match take_opt_value(prog, "--root", other, rest, &mut i) {
1525                    Ok(v) => root = v,
1526                    Err(code) => return code,
1527                }
1528            }
1529            other if other == "--queries" || other.starts_with("--queries=") => {
1530                match take_opt_value(prog, "--queries", other, rest, &mut i) {
1531                    Ok(v) => queries = v,
1532                    Err(code) => return code,
1533                }
1534            }
1535            other if other == "--baseline" || other.starts_with("--baseline=") => {
1536                match take_opt_value(prog, "--baseline", other, rest, &mut i) {
1537                    Ok(v) => baseline = v,
1538                    Err(code) => return code,
1539                }
1540            }
1541            other if other == "--config" || other.starts_with("--config=") => {
1542                match take_opt_value(prog, "--config", other, rest, &mut i) {
1543                    Ok(v) => config = v,
1544                    Err(code) => return code,
1545                }
1546            }
1547            other => extras.push(other.to_string()),
1548        }
1549        i += 1;
1550    }
1551
1552    if !extras.is_empty() {
1553        return unrecognized(&extras);
1554    }
1555
1556    cmd_eval(&EvalArgs {
1557        check,
1558        update_baseline,
1559        json,
1560        root,
1561        queries,
1562        baseline,
1563        config,
1564    }) as u8
1565}
1566
1567fn run_resolve(rest: &[&String]) -> u8 {
1568    let prog = "decided resolve";
1569    let mut id: Option<String> = None;
1570    let mut directory: Option<String> = None;
1571    let mut json = false;
1572    let mut top_level = false;
1573    let mut extras: Vec<String> = Vec::new();
1574    let mut positional_only = false;
1575
1576    for arg in rest {
1577        let arg = arg.as_str();
1578        if positional_only || arg == "-" || !arg.starts_with('-') {
1579            if id.is_none() {
1580                id = Some(arg.to_string());
1581            } else if directory.is_none() {
1582                directory = Some(arg.to_string());
1583            } else {
1584                extras.push(arg.to_string());
1585            }
1586            continue;
1587        }
1588        match arg {
1589            "--" => positional_only = true,
1590            "--json" => json = true,
1591            "--top-level" => top_level = true,
1592            "--recursive" => {} // affirmation of the default
1593            other => extras.push(other.to_string()),
1594        }
1595    }
1596
1597    let Some(id) = id else {
1598        return argparse_error(prog, "the following arguments are required: id");
1599    };
1600    if !extras.is_empty() {
1601        return unrecognized(&extras);
1602    }
1603
1604    cmd_resolve(&ResolveArgs {
1605        id,
1606        directory: directory.unwrap_or_else(|| ".".to_string()),
1607        json,
1608        top_level,
1609    }) as u8
1610}
1611
1612fn run_find(rest: &[&String]) -> u8 {
1613    let prog = "decided find";
1614    let mut query: Option<String> = None;
1615    let mut directory: Option<String> = None;
1616    let mut artifact_type: Option<String> = None;
1617    let mut decisions = false;
1618    let mut tags: Vec<String> = Vec::new();
1619    let mut json = false;
1620    let mut explain = false;
1621    let mut top_level = false;
1622    let mut live = false;
1623    let mut cache = true;
1624    let mut verify = false;
1625    let mut extras: Vec<String> = Vec::new();
1626    let mut positional_only = false;
1627
1628    let mut i = 0;
1629    while i < rest.len() {
1630        let arg = rest[i].as_str();
1631        if positional_only || arg == "-" || !arg.starts_with('-') {
1632            if query.is_none() {
1633                query = Some(arg.to_string());
1634            } else if directory.is_none() {
1635                directory = Some(arg.to_string());
1636            } else {
1637                extras.push(arg.to_string());
1638            }
1639            i += 1;
1640            continue;
1641        }
1642        match arg {
1643            "--" => positional_only = true,
1644            "--json" => json = true,
1645            "--explain" => explain = true,
1646            "--top-level" => top_level = true,
1647            "--live" => live = true, // the live-only facet (ADR-113)
1648            "--recursive" => {} // affirmation of the default
1649            "--cache" => cache = true,
1650            "--no-cache" => cache = false,
1651            "--verify" => verify = true,
1652            "--decisions" => {
1653                // Mutually exclusive with --type (argparse group).
1654                if let Some(code) =
1655                    mutex_check(prog, "--decisions", "--type", artifact_type.is_some())
1656                {
1657                    return code;
1658                }
1659                decisions = true;
1660            }
1661            other if other == "--type" || other.starts_with("--type=") => {
1662                if let Some(code) = mutex_check(prog, "--type", "--decisions", decisions) {
1663                    return code;
1664                }
1665                match take_opt_value(prog, "--type", other, rest, &mut i) {
1666                    Ok(v) => artifact_type = Some(v),
1667                    Err(code) => return code,
1668                }
1669            }
1670            other if other == "--tag" || other.starts_with("--tag=") => {
1671                match take_opt_value(prog, "--tag", other, rest, &mut i) {
1672                    Ok(v) => tags.push(v),
1673                    Err(code) => return code,
1674                }
1675            }
1676            other => extras.push(other.to_string()),
1677        }
1678        i += 1;
1679    }
1680
1681    let Some(query) = query else {
1682        return argparse_error(prog, "the following arguments are required: query");
1683    };
1684    if !extras.is_empty() {
1685        return unrecognized(&extras);
1686    }
1687
1688    cmd_find(&FindArgs {
1689        query,
1690        directory: directory.unwrap_or_else(|| ".".to_string()),
1691        artifact_type,
1692        decisions,
1693        tags,
1694        json,
1695        explain,
1696        top_level,
1697        live,
1698        cache,
1699        verify,
1700    }) as u8
1701}
1702
1703/// `int(value)` for argparse `type=int`: Python-style strip, optional sign,
1704/// ASCII digits with single interior underscores. (Non-ASCII digit forms are
1705/// out of scope for the parity surface.)
1706fn py_parse_int(value: &str) -> Option<i64> {
1707    let text = crate::pycompat::py_strip(value);
1708    let (neg, digits) = match text.strip_prefix('-') {
1709        Some(rest) => (true, rest),
1710        None => (false, text.strip_prefix('+').unwrap_or(text)),
1711    };
1712    if digits.is_empty() {
1713        return None;
1714    }
1715    let bytes = digits.as_bytes();
1716    if bytes[0] == b'_' || bytes[bytes.len() - 1] == b'_' {
1717        return None;
1718    }
1719    let mut out: i64 = 0;
1720    let mut prev_underscore = false;
1721    for &b in bytes {
1722        if b == b'_' {
1723            if prev_underscore {
1724                return None;
1725            }
1726            prev_underscore = true;
1727            continue;
1728        }
1729        prev_underscore = false;
1730        if !b.is_ascii_digit() {
1731            return None;
1732        }
1733        out = out
1734            .saturating_mul(10)
1735            .saturating_add(i64::from(b - b'0'));
1736    }
1737    Some(if neg { -out } else { out })
1738}
1739
1740fn run_retrieve(rest: &[&String]) -> u8 {
1741    let prog = "decided retrieve";
1742    let mut task: Option<String> = None;
1743    let mut directory: Option<String> = None;
1744    let mut scope: Option<String> = None;
1745    let mut top_k: i64 = 5;
1746    let mut budget: i64 = 10_000;
1747    let mut live = false;
1748    let mut all = false;
1749    let mut json = false;
1750    let mut extras: Vec<String> = Vec::new();
1751    let mut positional_only = false;
1752
1753    // One int-valued flag consumer: argparse `type=int` + its error line.
1754    enum IntErr {
1755        Missing,
1756        Invalid(String),
1757    }
1758    let parse_int_flag = |raw: Option<&&String>| -> Result<i64, IntErr> {
1759        match raw {
1760            Some(v)
1761                if !v.starts_with('-')
1762                    || v.as_str() == "-"
1763                    || looks_like_negative_number(v) =>
1764            {
1765                py_parse_int(v).ok_or_else(|| IntErr::Invalid(v.to_string()))
1766            }
1767            _ => Err(IntErr::Missing),
1768        }
1769    };
1770    let int_flag_error = |flag: &str, err: IntErr| -> u8 {
1771        match err {
1772            IntErr::Missing => {
1773                argparse_error(prog, &format!("argument {flag}: expected one argument"))
1774            }
1775            IntErr::Invalid(v) => argparse_error(
1776                prog,
1777                &format!("argument {flag}: invalid int value: '{v}'"),
1778            ),
1779        }
1780    };
1781
1782    let mut i = 0;
1783    while i < rest.len() {
1784        let arg = rest[i].as_str();
1785        if positional_only || !arg.starts_with('-') || arg == "-" || looks_like_negative_number(arg)
1786        {
1787            if task.is_none() {
1788                task = Some(arg.to_string());
1789            } else if directory.is_none() {
1790                directory = Some(arg.to_string());
1791            } else {
1792                extras.push(arg.to_string());
1793            }
1794            i += 1;
1795            continue;
1796        }
1797        match arg {
1798            "--" => positional_only = true,
1799            "--json" => json = true,
1800            "--live" => {
1801                if let Some(code) = mutex_check(prog, "--live", "--all", all) {
1802                    return code;
1803                }
1804                live = true;
1805            }
1806            "--all" => {
1807                if let Some(code) = mutex_check(prog, "--all", "--live", live) {
1808                    return code;
1809                }
1810                all = true;
1811            }
1812            other if other == "--scope" || other.starts_with("--scope=") => {
1813                match take_opt_value(prog, "--scope", other, rest, &mut i) {
1814                    Ok(v) => scope = Some(v),
1815                    Err(code) => return code,
1816                }
1817            }
1818            "--top-k" => {
1819                i += 1;
1820                match parse_int_flag(rest.get(i)) {
1821                    Ok(v) => top_k = v,
1822                    Err(e) => return int_flag_error("--top-k", e),
1823                }
1824            }
1825            other if other.starts_with("--top-k=") => {
1826                let v = &other["--top-k=".len()..];
1827                match py_parse_int(v) {
1828                    Some(parsed) => top_k = parsed,
1829                    None => return int_flag_error("--top-k", IntErr::Invalid(v.to_string())),
1830                }
1831            }
1832            "--budget" => {
1833                i += 1;
1834                match parse_int_flag(rest.get(i)) {
1835                    Ok(v) => budget = v,
1836                    Err(e) => return int_flag_error("--budget", e),
1837                }
1838            }
1839            other if other.starts_with("--budget=") => {
1840                let v = &other["--budget=".len()..];
1841                match py_parse_int(v) {
1842                    Some(parsed) => budget = parsed,
1843                    None => return int_flag_error("--budget", IntErr::Invalid(v.to_string())),
1844                }
1845            }
1846            other => extras.push(other.to_string()),
1847        }
1848        i += 1;
1849    }
1850
1851    let Some(task) = task else {
1852        return argparse_error(prog, "the following arguments are required: task");
1853    };
1854    if !extras.is_empty() {
1855        return unrecognized(&extras);
1856    }
1857
1858    cmd_retrieve(&RetrieveArgs {
1859        task,
1860        directory: directory.unwrap_or_else(|| ".".to_string()),
1861        scope,
1862        top_k,
1863        budget,
1864        all,
1865        json,
1866    }) as u8
1867}
1868
1869/// argparse treats a token matching `^-\d+$` / `^-\d*\.\d+$` as a value, not an
1870/// option (the parser has no option strings that look like negative numbers).
1871fn looks_like_negative_number(s: &str) -> bool {
1872    let Some(rest) = s.strip_prefix('-') else {
1873        return false;
1874    };
1875    if rest.is_empty() {
1876        return false;
1877    }
1878    let mut seen_dot = false;
1879    let mut seen_digit = false;
1880    for ch in rest.chars() {
1881        if ch == '.' {
1882            if seen_dot {
1883                return false;
1884            }
1885            seen_dot = true;
1886        } else if ch.is_ascii_digit() {
1887            seen_digit = true;
1888        } else {
1889            return false;
1890        }
1891    }
1892    seen_digit
1893}
1894
1895fn run_review(rest: &[&String]) -> u8 {
1896    let prog = "decided review";
1897    let mut directory: Option<String> = None;
1898    let mut json = false;
1899    let mut sarif = false;
1900    let mut top_level = false;
1901    let mut stale_after: Option<i64> = None;
1902    let mut extras: Vec<String> = Vec::new();
1903    let mut positional_only = false;
1904
1905    let mut i = 0;
1906    while i < rest.len() {
1907        let arg = rest[i].as_str();
1908        if positional_only || !arg.starts_with('-') || arg == "-" {
1909            if directory.is_none() {
1910                directory = Some(arg.to_string());
1911            } else {
1912                extras.push(arg.to_string());
1913            }
1914            i += 1;
1915            continue;
1916        }
1917        match arg {
1918            "--" => positional_only = true,
1919            "--json" => json = true,
1920            "--sarif" => sarif = true,
1921            "--top-level" => top_level = true,
1922            "--recursive" => {}
1923            "--stale-after" => {
1924                // nargs="?" const=14: consume the next token only if it is a
1925                // value (not another option), including a negative number.
1926                let consume = match rest.get(i + 1) {
1927                    Some(v) => !v.starts_with('-') || looks_like_negative_number(v),
1928                    None => false,
1929                };
1930                if consume {
1931                    i += 1;
1932                    let raw = rest[i].as_str();
1933                    match raw.trim().parse::<i64>() {
1934                        Ok(v) => stale_after = Some(v),
1935                        Err(_) => {
1936                            return argparse_error(
1937                                prog,
1938                                &format!("argument --stale-after: invalid int value: '{raw}'"),
1939                            )
1940                        }
1941                    }
1942                } else {
1943                    stale_after = Some(14);
1944                }
1945            }
1946            other if other.starts_with("--stale-after=") => {
1947                let raw = &other["--stale-after=".len()..];
1948                match raw.trim().parse::<i64>() {
1949                    Ok(v) => stale_after = Some(v),
1950                    Err(_) => {
1951                        return argparse_error(
1952                            prog,
1953                            &format!("argument --stale-after: invalid int value: '{raw}'"),
1954                        )
1955                    }
1956                }
1957            }
1958            other => extras.push(other.to_string()),
1959        }
1960        i += 1;
1961    }
1962
1963    let Some(directory) = directory else {
1964        return argparse_error(prog, "the following arguments are required: directory");
1965    };
1966    if !extras.is_empty() {
1967        return unrecognized(&extras);
1968    }
1969
1970    cmd_review(&ReviewArgs {
1971        directory,
1972        json,
1973        sarif,
1974        top_level,
1975        stale_after,
1976    }) as u8
1977}
1978
1979fn run_export(rest: &[&String]) -> u8 {
1980    let prog = "decided export";
1981    let mut directory: Option<String> = None;
1982    let mut json = false;
1983    let mut html = false;
1984    let mut okf = false;
1985    let mut documents = false;
1986    let mut graph = false;
1987    let mut agent_rules = false;
1988    let mut check = false;
1989    let mut client: Vec<String> = Vec::new();
1990    let mut out: Option<String> = None;
1991    let mut extras: Vec<String> = Vec::new();
1992    let mut positional_only = false;
1993
1994    // Track the last write-mode flag seen for argparse mutex diagnostics.
1995    let mut last_mode: Option<&'static str> = None;
1996    let set_mode = |flag: &'static str,
1997                        slot: &mut bool,
1998                        last_mode: &mut Option<&'static str>|
1999     -> Result<(), FlagError> {
2000        if let Some(prev) = *last_mode {
2001            if prev != flag {
2002                return Err(FlagError(argparse_error(
2003                    prog,
2004                    &format!("argument {flag}: not allowed with argument {prev}"),
2005                )));
2006            }
2007        }
2008        *slot = true;
2009        *last_mode = Some(flag);
2010        Ok(())
2011    };
2012
2013    let mut i = 0;
2014    while i < rest.len() {
2015        let arg = rest[i].as_str();
2016        if positional_only || !arg.starts_with('-') || arg == "-" {
2017            if directory.is_none() {
2018                directory = Some(arg.to_string());
2019            } else {
2020                extras.push(arg.to_string());
2021            }
2022            i += 1;
2023            continue;
2024        }
2025        match arg {
2026            "--" => positional_only = true,
2027            "--json" => json = true,
2028            "--html" => {
2029                if let Err(FlagError(c)) = set_mode("--html", &mut html, &mut last_mode) {
2030                    return c;
2031                }
2032            }
2033            "--okf" => {
2034                if let Err(FlagError(c)) = set_mode("--okf", &mut okf, &mut last_mode) {
2035                    return c;
2036                }
2037            }
2038            "--documents" => {
2039                if let Err(FlagError(c)) = set_mode("--documents", &mut documents, &mut last_mode) {
2040                    return c;
2041                }
2042            }
2043            "--graph" => {
2044                if let Err(FlagError(c)) = set_mode("--graph", &mut graph, &mut last_mode) {
2045                    return c;
2046                }
2047            }
2048            "--agent-rules" => {
2049                if let Err(FlagError(c)) =
2050                    set_mode("--agent-rules", &mut agent_rules, &mut last_mode)
2051                {
2052                    return c;
2053                }
2054            }
2055            "--check" => check = true,
2056            "--client" => {
2057                i += 1;
2058                match rest.get(i) {
2059                    Some(v) if is_client_choice(v) => client.push(v.to_string()),
2060                    Some(v) if !v.starts_with('-') => {
2061                        return argparse_error(
2062                            prog,
2063                            &format!(
2064                                "argument --client: invalid choice: '{v}' (choose from 'claude', 'agents', 'cursor', 'copilot')"
2065                            ),
2066                        )
2067                    }
2068                    _ => return argparse_error(prog, "argument --client: expected one argument"),
2069                }
2070            }
2071            other if other.starts_with("--client=") => {
2072                let v = &other["--client=".len()..];
2073                if is_client_choice(v) {
2074                    client.push(v.to_string());
2075                } else {
2076                    return argparse_error(
2077                        prog,
2078                        &format!(
2079                            "argument --client: invalid choice: '{v}' (choose from 'claude', 'agents', 'cursor', 'copilot')"
2080                        ),
2081                    );
2082                }
2083            }
2084            other if other == "--out" || other.starts_with("--out=") => {
2085                match take_opt_value(prog, "--out", other, rest, &mut i) {
2086                    Ok(v) => out = Some(v),
2087                    Err(code) => return code,
2088                }
2089            }
2090            other => extras.push(other.to_string()),
2091        }
2092        i += 1;
2093    }
2094
2095    if !extras.is_empty() {
2096        return unrecognized(&extras);
2097    }
2098
2099    cmd_export(&ExportArgs {
2100        directory: directory.unwrap_or_else(|| ".".to_string()),
2101        json,
2102        graph,
2103        documents,
2104        html,
2105        okf,
2106        agent_rules,
2107        check,
2108        client,
2109        out,
2110    }) as u8
2111}
2112
2113fn is_client_choice(v: &str) -> bool {
2114    matches!(v, "claude" | "agents" | "cursor" | "copilot")
2115}
2116
2117fn run_schema(rest: &[&String]) -> u8 {
2118    let prog = "decided schema";
2119    let mut schema: Option<String> = None;
2120    let mut list = false;
2121    let mut json = false;
2122    let mut template = false;
2123    let mut extras: Vec<String> = Vec::new();
2124    let mut positional_only = false;
2125
2126    for arg in rest {
2127        let arg = arg.as_str();
2128        if positional_only || !arg.starts_with('-') {
2129            if schema.is_none() {
2130                schema = Some(arg.to_string());
2131            } else {
2132                extras.push(arg.to_string());
2133            }
2134            continue;
2135        }
2136        match arg {
2137            "--" => positional_only = true,
2138            "--list" => list = true,
2139            "--json" => {
2140                if let Some(code) = mutex_check(prog, "--json", "--template", template) {
2141                    return code;
2142                }
2143                json = true;
2144            }
2145            "--template" => {
2146                if let Some(code) = mutex_check(prog, "--template", "--json", json) {
2147                    return code;
2148                }
2149                template = true;
2150            }
2151            other => extras.push(other.to_string()),
2152        }
2153    }
2154
2155    if !extras.is_empty() {
2156        return unrecognized(&extras);
2157    }
2158
2159    cmd_schema(&SchemaArgs {
2160        schema,
2161        list,
2162        json,
2163        template,
2164    }) as u8
2165}
2166
2167fn run_templates(rest: &[&String]) -> u8 {
2168    let mut json = false;
2169    let mut extras: Vec<String> = Vec::new();
2170    let mut positional_only = false;
2171
2172    for arg in rest {
2173        let arg = arg.as_str();
2174        if positional_only || !arg.starts_with('-') {
2175            extras.push(arg.to_string());
2176            continue;
2177        }
2178        match arg {
2179            "--" => positional_only = true,
2180            "--json" => json = true,
2181            other => extras.push(other.to_string()),
2182        }
2183    }
2184
2185    if !extras.is_empty() {
2186        return unrecognized(&extras);
2187    }
2188
2189    cmd_templates(&TemplatesArgs { json }) as u8
2190}
2191
2192fn run_new(rest: &[&String]) -> u8 {
2193    let prog = "decided new";
2194    let mut artifact_type: Option<String> = None;
2195    let mut output_path: Option<String> = None;
2196    let mut json = false;
2197    let mut extras: Vec<String> = Vec::new();
2198    let mut positional_only = false;
2199
2200    for arg in rest {
2201        let arg = arg.as_str();
2202        if positional_only || arg == "-" || !arg.starts_with('-') {
2203            if artifact_type.is_none() {
2204                artifact_type = Some(arg.to_string());
2205            } else if output_path.is_none() {
2206                output_path = Some(arg.to_string());
2207            } else {
2208                extras.push(arg.to_string());
2209            }
2210            continue;
2211        }
2212        match arg {
2213            "--" => positional_only = true,
2214            "--json" => json = true,
2215            other => extras.push(other.to_string()),
2216        }
2217    }
2218
2219    // argparse reports every still-missing required positional at once.
2220    let (Some(artifact_type), Some(output_path)) = (artifact_type.clone(), output_path) else {
2221        let missing = if artifact_type.is_none() {
2222            "type, output_path"
2223        } else {
2224            "output_path"
2225        };
2226        return argparse_error(
2227            prog,
2228            &format!("the following arguments are required: {missing}"),
2229        );
2230    };
2231    if !extras.is_empty() {
2232        return unrecognized(&extras);
2233    }
2234
2235    cmd_new(&NewArgs {
2236        artifact_type,
2237        output_path,
2238        json,
2239    }) as u8
2240}
2241
2242/// `decided init [directory] [--key KEY] [--ticketing PROVIDER] [--profile
2243/// NAME] [--json]` — order-aware: `--ticketing`/`--profile` are
2244/// argparse-choice-validated when their VALUE is consumed (an invalid
2245/// choice beats a later `--version`; an earlier `--version` wins), and a
2246/// missing option value errors at its own position too.
2247fn run_init(rest: &[&String]) -> u8 {
2248    let prog = "decided init";
2249    let mut directory: Option<String> = None;
2250    let mut key: String = "RAC".to_string(); // init.DEFAULT_KEY
2251    let mut ticketing: Option<String> = None;
2252    let mut profile: Option<String> = None;
2253    let mut org_endpoint: Option<String> = None;
2254    let mut json = false;
2255    let mut extras: Vec<String> = Vec::new();
2256    let mut positional_only = false;
2257
2258    let ticketing_choice = |v: &str| -> Option<u8> {
2259        if matches!(v, "jira" | "github" | "linear" | "azure-devops" | "servicenow" | "none") {
2260            None
2261        } else {
2262            Some(argparse_error(
2263                prog,
2264                &format!(
2265                    "argument --ticketing: invalid choice: '{v}' (choose from 'jira', 'github', 'linear', 'azure-devops', 'servicenow', 'none')"
2266                ),
2267            ))
2268        }
2269    };
2270    let profile_choice = |v: &str| -> Option<u8> {
2271        if matches!(v, "default" | "enterprise") {
2272            None
2273        } else {
2274            Some(argparse_error(
2275                prog,
2276                &format!(
2277                    "argument --profile: invalid choice: '{v}' (choose from 'default', 'enterprise')"
2278                ),
2279            ))
2280        }
2281    };
2282
2283    let mut i = 0;
2284    while i < rest.len() {
2285        let arg = rest[i].as_str();
2286        if positional_only || arg == "-" || !arg.starts_with('-') {
2287            if directory.is_none() {
2288                directory = Some(arg.to_string());
2289            } else {
2290                extras.push(arg.to_string());
2291            }
2292            i += 1;
2293            continue;
2294        }
2295        match arg {
2296            "--" => positional_only = true,
2297            "--version" => {
2298                skip_usage_record();
2299                print_stdout(&version_line());
2300                return 0;
2301            }
2302            "-h" | "--help" => {
2303                skip_usage_record();
2304                print_stdout(&format!("usage: {prog} ..."));
2305                return 0;
2306            }
2307            "--json" => json = true,
2308            other if other == "--key" || other.starts_with("--key=") => {
2309                match take_opt_value(prog, "--key", other, rest, &mut i) {
2310                    Ok(v) => key = v,
2311                    Err(code) => return code,
2312                }
2313            }
2314            other if other == "--ticketing" || other.starts_with("--ticketing=") => {
2315                match take_opt_value(prog, "--ticketing", other, rest, &mut i) {
2316                    Ok(v) => {
2317                        if let Some(code) = ticketing_choice(&v) {
2318                            return code;
2319                        }
2320                        ticketing = Some(v);
2321                    }
2322                    Err(code) => return code,
2323                }
2324            }
2325            other if other == "--profile" || other.starts_with("--profile=") => {
2326                match take_opt_value(prog, "--profile", other, rest, &mut i) {
2327                    Ok(v) => {
2328                        if let Some(code) = profile_choice(&v) {
2329                            return code;
2330                        }
2331                        profile = Some(v);
2332                    }
2333                    Err(code) => return code,
2334                }
2335            }
2336            other if other == "--org-endpoint" || other.starts_with("--org-endpoint=") => {
2337                // Free string like --key: the http(s) check is the service
2338                // layer's `decided:` usage error, not an argparse choice.
2339                match take_opt_value(prog, "--org-endpoint", other, rest, &mut i) {
2340                    Ok(v) => org_endpoint = Some(v),
2341                    Err(code) => return code,
2342                }
2343            }
2344            other => extras.push(other.to_string()),
2345        }
2346        i += 1;
2347    }
2348
2349    if !extras.is_empty() {
2350        return unrecognized(&extras);
2351    }
2352
2353    cmd_init(&InitArgs {
2354        directory: directory.unwrap_or_else(|| ".".to_string()),
2355        key,
2356        ticketing,
2357        profile,
2358        org_endpoint,
2359        json,
2360    }) as u8
2361}
2362
2363/// `decided quickstart [directory] [--key KEY] [--type TYPE] [--json]` —
2364/// order-aware only for the value-taking options: a MISSING `--key`/
2365/// `--type` value errors at its own position (beating a later
2366/// `--version`), while their values are free strings validated by the
2367/// service (so `--key bad --version` prints the version, measured).
2368fn run_quickstart(rest: &[&String]) -> u8 {
2369    let prog = "decided quickstart";
2370    let mut directory: Option<String> = None;
2371    let mut key: String = "RAC".to_string(); // init.DEFAULT_KEY
2372    let mut artifact_type: String = "requirement".to_string(); // quickstart.DEFAULT_TYPE
2373    let mut json = false;
2374    let mut extras: Vec<String> = Vec::new();
2375    let mut positional_only = false;
2376
2377    let mut i = 0;
2378    while i < rest.len() {
2379        let arg = rest[i].as_str();
2380        if positional_only || arg == "-" || !arg.starts_with('-') {
2381            if directory.is_none() {
2382                directory = Some(arg.to_string());
2383            } else {
2384                extras.push(arg.to_string());
2385            }
2386            i += 1;
2387            continue;
2388        }
2389        match arg {
2390            "--" => positional_only = true,
2391            "--version" => {
2392                skip_usage_record();
2393                print_stdout(&version_line());
2394                return 0;
2395            }
2396            "-h" | "--help" => {
2397                skip_usage_record();
2398                print_stdout(&format!("usage: {prog} ..."));
2399                return 0;
2400            }
2401            "--json" => json = true,
2402            other if other == "--key" || other.starts_with("--key=") => {
2403                match take_opt_value(prog, "--key", other, rest, &mut i) {
2404                    Ok(v) => key = v,
2405                    Err(code) => return code,
2406                }
2407            }
2408            other if other == "--type" || other.starts_with("--type=") => {
2409                match take_opt_value(prog, "--type", other, rest, &mut i) {
2410                    Ok(v) => artifact_type = v,
2411                    Err(code) => return code,
2412                }
2413            }
2414            other => extras.push(other.to_string()),
2415        }
2416        i += 1;
2417    }
2418
2419    if !extras.is_empty() {
2420        return unrecognized(&extras);
2421    }
2422
2423    cmd_quickstart(&QuickstartArgs {
2424        directory: directory.unwrap_or_else(|| ".".to_string()),
2425        key,
2426        artifact_type,
2427        json,
2428    }) as u8
2429}
2430
2431fn run_rename(rest: &[&String]) -> u8 {
2432    let prog = "decided rename";
2433    let mut old: Option<String> = None;
2434    let mut new: Option<String> = None;
2435    let mut directory: Option<String> = None;
2436    let mut apply = false;
2437    let mut top_level = false;
2438    let mut json = false;
2439    let mut extras: Vec<String> = Vec::new();
2440    let mut positional_only = false;
2441
2442    for arg in rest {
2443        let arg = arg.as_str();
2444        if positional_only || arg == "-" || !arg.starts_with('-') {
2445            if old.is_none() {
2446                old = Some(arg.to_string());
2447            } else if new.is_none() {
2448                new = Some(arg.to_string());
2449            } else if directory.is_none() {
2450                directory = Some(arg.to_string());
2451            } else {
2452                extras.push(arg.to_string());
2453            }
2454            continue;
2455        }
2456        match arg {
2457            "--" => positional_only = true,
2458            "--apply" => apply = true,
2459            "--top-level" => top_level = true,
2460            "--json" => json = true,
2461            other => extras.push(other.to_string()),
2462        }
2463    }
2464
2465    // argparse reports every still-missing required positional at once
2466    // (positional ORDER is old, new, directory — directory LAST).
2467    let (Some(old), Some(new), Some(directory)) = (old.clone(), new.clone(), directory) else {
2468        let mut missing: Vec<&str> = Vec::new();
2469        if old.is_none() {
2470            missing.push("old");
2471        }
2472        if new.is_none() {
2473            missing.push("new");
2474        }
2475        missing.push("directory");
2476        return argparse_error(
2477            prog,
2478            &format!("the following arguments are required: {}", missing.join(", ")),
2479        );
2480    };
2481    if !extras.is_empty() {
2482        return unrecognized(&extras);
2483    }
2484
2485    cmd_rename(&RenameArgs {
2486        old,
2487        new,
2488        directory,
2489        apply,
2490        top_level,
2491        json,
2492    }) as u8
2493}
2494
2495/// `decided migrate {metadata} <directory> [--dry-run] [--top-level]
2496/// [--recursive] [--json]` — order-aware: the `target` positional's choice
2497/// set is validated when the token is CONSUMED (an invalid target beats a
2498/// later `--version`; an earlier `--version` wins).
2499fn run_migrate(rest: &[&String]) -> u8 {
2500    let prog = "decided migrate";
2501    let mut target: Option<String> = None;
2502    let mut directory: Option<String> = None;
2503    let mut dry_run = false;
2504    let mut top_level = false;
2505    let mut json = false;
2506    let mut extras: Vec<String> = Vec::new();
2507    let mut positional_only = false;
2508
2509    for arg in rest {
2510        let arg = arg.as_str();
2511        if positional_only || arg == "-" || !arg.starts_with('-') {
2512            if target.is_none() {
2513                if arg != "metadata" && arg != "layout" {
2514                    return argparse_error(
2515                        prog,
2516                        &format!(
2517                            "argument target: invalid choice: '{arg}' (choose from 'metadata', 'layout')"
2518                        ),
2519                    );
2520                }
2521                target = Some(arg.to_string());
2522            } else if directory.is_none() {
2523                directory = Some(arg.to_string());
2524            } else {
2525                extras.push(arg.to_string());
2526            }
2527            continue;
2528        }
2529        match arg {
2530            "--" => positional_only = true,
2531            "--version" => {
2532                skip_usage_record();
2533                print_stdout(&version_line());
2534                return 0;
2535            }
2536            "-h" | "--help" => {
2537                skip_usage_record();
2538                print_stdout(&format!("usage: {prog} ..."));
2539                return 0;
2540            }
2541            "--dry-run" => dry_run = true,
2542            "--top-level" => top_level = true,
2543            "--recursive" => {} // affirmation of the default (scope_parent)
2544            "--json" => json = true,
2545            other => extras.push(other.to_string()),
2546        }
2547    }
2548
2549    let (Some(target), Some(directory)) = (target.clone(), directory) else {
2550        let missing = if target.is_none() {
2551            "target, directory"
2552        } else {
2553            "directory"
2554        };
2555        return argparse_error(
2556            prog,
2557            &format!("the following arguments are required: {missing}"),
2558        );
2559    };
2560    if !extras.is_empty() {
2561        return unrecognized(&extras);
2562    }
2563
2564    cmd_migrate(&MigrateArgs {
2565        target,
2566        directory,
2567        dry_run,
2568        top_level,
2569        json,
2570    }) as u8
2571}