Skip to main content

safe_chains/registry/
build.rs

1use std::collections::HashMap;
2
3use crate::policy::{FlagTolerance, UnknownTolerance};
4use crate::verdict::SafetyLevel;
5
6use super::types::*;
7
8/// A command definition is invalid. Returns `Err` from the enclosing function instead of panicking.
9///
10/// These checks run on USER-supplied configs as well as the built-in `commands/*.toml`: a
11/// `~/.config/safe-chains.toml` or a repo `.safe-chains.toml` goes through exactly this code. An
12/// abort there takes a PreToolUse hook down, and a crashed hook means the harness PROCEEDS — so a
13/// typo in a config file used to disable safe-chains altogether. It was caught downstream by
14/// `catch_unwind`, but panicking across a library boundary as control flow left two marks: the
15/// default hook printed a panic and a backtrace to stderr on every invocation before anything could
16/// catch it, and under libFuzzer a caught panic is still an abort, which is how `config_load` kept
17/// failing.
18///
19/// The built-in definitions are compiled in and turn an `Err` back into a panic AT THE CALL SITE,
20/// where it belongs: a broken `commands/*.toml` is a bug in this repository, not something a user
21/// typed, and it should stop the process loudly.
22macro_rules! fail {
23    ($($arg:tt)*) => { return Err(format!($($arg)*)) };
24}
25
26/// `assert!` for the same checks: the condition must hold or the definition is rejected.
27macro_rules! ensure {
28    ($cond:expr, $($arg:tt)*) => {
29        if !($cond) {
30            return Err(format!($($arg)*));
31        }
32    };
33}
34
35#[path = "build_lower.rs"]
36mod lower;
37use lower::{lower_behavior, lower_output};
38
39/// `optional_valued` compiles down to membership in BOTH other lists, and that is the whole
40/// mechanism — the walk needs no third state.
41///
42/// Measured before choosing this (`policy::tests::a_flag_declared_in_both_lists_…`): `check_flags`
43/// consults `standalone` first, so the BARE spelling matches there and cannot swallow the next
44/// token; its `=` branch consults `valued`, so the GLUED spelling matches too. Both are honoured,
45/// for different spellings, which is exactly what an optional-value flag means — and it matches
46/// getopt, where an optional argument must be glued (`--flag=x`, never `--flag x`).
47///
48/// So this field buys INTENT, not behaviour. That distinction is the point: the same pair of
49/// memberships previously meant either "a deliberate optional-value flag" or "somebody made a
50/// mistake", and nothing could tell those apart — which is why the overlap audit was blocked on
51/// this. Declaring it here says which one it is, and leaves a raw `standalone`+`valued` overlap
52/// free to become a build error once the existing scopes are sorted.
53///
54/// NOT covered: the short glued form (`-r0`). That reaches the cluster loop, which walks byte by
55/// byte and has no notion of "the rest of this token is my value". Recorded in TODO.md.
56pub(super) fn build_policy(
57    standalone: Vec<String>,
58    valued: Vec<String>,
59    optional_valued: Vec<String>,
60    bare: Option<bool>,
61    max_positional: Option<usize>,
62    tolerate_unknown_short: Option<bool>,
63    tolerate_unknown_long: Option<bool>,
64    numeric_dash: Option<bool>,
65) -> OwnedPolicy {
66    let (mut standalone, mut valued) = (standalone, valued);
67    for flag in optional_valued {
68        standalone.push(flag.clone());
69        valued.push(flag);
70    }
71    let unknown = match (tolerate_unknown_short.unwrap_or(false), tolerate_unknown_long.unwrap_or(false)) {
72        (false, false) => UnknownTolerance::Strict,
73        (true, false) => UnknownTolerance::Short,
74        (false, true) => UnknownTolerance::Long,
75        (true, true) => UnknownTolerance::Both,
76    };
77    OwnedPolicy {
78        standalone,
79        valued,
80        bare: bare.unwrap_or(true),
81        max_positional,
82        tolerance: FlagTolerance { unknown, numeric_dash: numeric_dash.unwrap_or(false) },
83    }
84}
85
86fn build_matrix(toml: TomlMatrix) -> MatrixSpec {
87    let actions = toml
88        .actions
89        .into_iter()
90        .map(|(name, action)| {
91            let built = match action {
92                TomlMatrixAction::Policy(policy_key) => MatrixAction { policy_key, guard: None, guard_short: None },
93                TomlMatrixAction::Detailed(d) => MatrixAction { policy_key: d.policy, guard: d.guard, guard_short: d.guard_short },
94            };
95            (name, built)
96        })
97        .collect();
98    MatrixSpec { parents: toml.parents, level: toml.level.into(), actions }
99}
100
101fn build_handler_policy(toml: TomlHandlerPolicy) -> OwnedPolicy {
102    build_policy(
103        toml.standalone, toml.valued, toml.optional_valued, toml.bare, toml.max_positional, toml.tolerate_unknown_short,
104        toml.tolerate_unknown_long, toml.numeric_dash,
105    )
106}
107
108fn build_verb_chain(toml: TomlVerbChain) -> VerbChainSpec {
109    VerbChainSpec {
110        level: toml.level.unwrap_or(TomlLevel::Inert).into(),
111        separator: toml.separator.unwrap_or_else(|| "then".to_string()),
112        main_standalone: toml.main_standalone,
113        main_valued: toml.main_valued,
114        main_variadic: toml.main_variadic,
115        verbs: toml.verbs.into_iter().collect(),
116    }
117}
118
119fn build_fallback(parent: &str, toml: TomlFallback) -> Result<FallbackSpec, String> {
120    let policy = build_policy(
121        toml.standalone, toml.valued, toml.optional_valued, toml.bare, toml.max_positional, toml.tolerate_unknown_short,
122        toml.tolerate_unknown_long, toml.numeric_dash,
123    );
124    let level: SafetyLevel = toml.level.unwrap_or(TomlLevel::Inert).into();
125    let positional_shape = match toml.positional_shape.as_deref() {
126        None => None,
127        Some(name) => match crate::policy::PositionalShape::from_name(name) {
128            Some(shape) => Some(shape),
129            None => fail!("{parent}: unknown fallback positional_shape `{name}` (known: path)"),
130        },
131    };
132    let executor = match toml.executor.as_deref() {
133        None => None,
134        Some(name) => match ExecutorKind::from_name(name) {
135            Some(kind) => Some(kind),
136            None => fail!("{parent}: unknown fallback executor `{name}` (known: file, project)"),
137        },
138    };
139    Ok(FallbackSpec {
140        policy,
141        level,
142        positional_shape,
143        executor,
144        executor_redirect_flag: toml.executor_redirect_flag,
145        passes_argv: toml.passes_argv.unwrap_or(false),
146    })
147}
148
149fn allow_all_policy() -> OwnedPolicy {
150    OwnedPolicy {
151        standalone: Vec::new(),
152        valued: Vec::new(),
153        bare: true,
154        max_positional: None,
155        tolerance: FlagTolerance { unknown: UnknownTolerance::Both, numeric_dash: false },
156    }
157}
158
159fn check_no_legacy_positional_style(name: &str, ps: Option<bool>) -> Result<(), String> {
160    if ps.is_some() {
161        fail!(
162            "command '{name}': `positional_style` was removed. Use \
163             `tolerate_unknown_short = true` for tools with single-dash \
164             flags (pdftotext -help, sample -mayDie). Use \
165             `tolerate_unknown_long = true` ONLY for tools whose \
166             double-dash flag surface is genuinely unbounded (AWS CLI \
167             style); double-dash unknowns silently pass when this is \
168             on, which has caused safety bugs. Most tools need neither."
169        );
170    }
171    Ok(())
172}
173
174fn filter_candidates(subs: Vec<TomlSub>) -> Result<impl Iterator<Item = TomlSub>, String> {
175    // Asserted HERE, not in `build_subs`, because that is the whole point: a candidate sub is
176    // dropped by this filter and never reaches `build_subs`, so a check placed there could never
177    // fire for the case it targets. (It was written there first, and the red demo caught it.)
178    //
179    // Any nested subs a candidate declares are dropped with it — silently, since the file still
180    // parses and builds. That is dead data wearing the shape of configuration: a reader sees the
181    // nested surface declared and reasonably concludes it is in force. Same family as the
182    // profiled-leaf assert in `build_subs`, where one field also makes another unreachable.
183    for s in &subs {
184        ensure!(
185            !s.candidate.unwrap_or(false) || s.sub.is_empty(),
186            "sub `{}`: a `candidate` sub is filtered out, so its nested subs are dead — drop the \
187             nested subs, or drop `candidate` if the sub is meant to be live",
188            s.name,
189        );
190    }
191    // A `per_database` sub must not be a PREFIX of another declared task, or the two are
192    // indistinguishable by shape and the suffix rule swallows the sibling.
193    //
194    // Rails makes this concrete: `db:migrate:primary` names a database and `db:migrate:redo` names
195    // a different task, and nothing in the string says which. Marking `db:migrate` therefore read
196    // `redo` as a database name and admitted a destructive task that is deliberately `candidate`.
197    // The existing `examples_denied` entry caught it, which is why the check belongs here rather
198    // than in a test.
199    //
200    // Checked in THIS function for the same reason the assert above is: candidates are filtered
201    // out below, so a check placed after the filter could not see the very siblings that create
202    // the ambiguity — `db:migrate:redo` and `db:migrate:reset` are both candidates.
203    for s in &subs {
204        if !s.per_database {
205            continue;
206        }
207        let clash = subs.iter().find(|o| o.name.starts_with(&format!("{}:", s.name)));
208        ensure!(
209            clash.is_none(),
210            "sub `{}`: `per_database` is unsound on a task that prefixes another (`{}`) — a \
211             suffix cannot be told from a subtask name. Enumerate the variants, or drop the flag",
212            s.name,
213            clash.map(|c| c.name.as_str()).unwrap_or(""),
214        );
215    }
216    Ok(subs.into_iter().filter(|s| !s.candidate.unwrap_or(false)))
217}
218
219/// Whether `name` is matched by a `first_arg` pattern (`get-*` prefix glob, or an exact token).
220fn first_arg_matches(name: &str, patterns: &[String]) -> bool {
221    patterns.iter().any(|p| match p.strip_suffix('*') {
222        Some(prefix) => name.starts_with(prefix),
223        None => p == name,
224    })
225}
226
227/// Fail-closed guard for the `candidate`-under-glob footgun. A `candidate = true` sub is REMOVED from
228/// the registry (so an older client sees "not found" and denies) — but if a sibling `first_arg` glob
229/// would MATCH that removed name, the token falls through to the glob and AUTO-APPROVES, silently
230/// inverting the author's intent (a deny becomes an allow). This panics at load when it detects that
231/// shape, directing the author to a `profile`/explicit sub-sub instead. (The AWS blob-readers hit
232/// exactly this: a `candidate` under `get-*` would have auto-approved.)
233fn assert_no_candidate_shadowed_by_glob(parent: &str, subs: &[TomlSub], first_arg: &[String]) -> Result<(), String> {
234    if first_arg.is_empty() {
235        return Ok(());
236    }
237    for s in subs {
238        if s.candidate.unwrap_or(false) && first_arg_matches(&s.name, first_arg) {
239            fail!(
240                "'{parent}' sub `{}` is `candidate = true` but its name is MATCHED by the sibling \
241                 first_arg glob {first_arg:?} — it would fall through the filter and AUTO-APPROVE \
242                 (a silent deny→allow inversion). Use `profile`/an explicit sub-sub to deny it, or \
243                 drop it from the glob.",
244                s.name,
245            );
246        }
247    }
248    Ok(())
249}
250
251/// Builds one SubSpec per alias (canonical name first, then each alias).
252/// All entries share the same kind via Clone — the dispatcher doesn't care
253/// which name the user invoked. `handler_policies` is consulted only by
254/// subs that set `policy = "key"`.
255pub(super) fn build_subs(
256    parent: &str,
257    toml: TomlSub,
258    handler_policies: &std::collections::HashMap<String, OwnedPolicy>,
259) -> Result<Vec<SubSpec>, String> {
260    let aliases = toml.aliases.clone();
261    let canonical = build_sub(parent, toml, handler_policies)?;
262    let mut out = Vec::with_capacity(1 + aliases.len());
263    for alias in aliases {
264        out.push(SubSpec {
265            name: alias,
266            kind: canonical.kind.clone(),
267            name_match: canonical.name_match,
268            policy_ref: canonical.policy_ref.clone(),
269            profile: canonical.profile.clone(),
270            flags: canonical.flags.clone(),
271            // An alias must inherit the allowlist: an empty list reads as "not yet enumerated" and
272            // would let the alias spelling accept flags its canonical name rejects.
273            allowed_standalone: canonical.allowed_standalone.clone(),
274            allowed_valued: canonical.allowed_valued.clone(),
275            allowed_unknown: canonical.allowed_unknown,
276            eval_safe: canonical.eval_safe,
277            eval_safe_flags: canonical.eval_safe_flags.clone(),
278            eval_safe_flag_values: canonical.eval_safe_flag_values.clone(),
279            eval_safe_required_flags: canonical.eval_safe_required_flags.clone(),
280            // As with the flag allowlist: an alias spelling must carry the canonical sub's stdout
281            // claim, or `$(git diff --name-only)` would be bounded while an aliased spelling of the
282            // same sub fell back to unpinnable — the mirror of the sub-alias gate hole.
283            output: canonical.output.clone(),
284            network_destination: canonical.network_destination,
285            destination_flag: canonical.destination_flag.clone(),
286            loopback_valued: canonical.loopback_valued.clone(),
287            loopback_effect: canonical.loopback_effect,
288            output_path_flags: canonical.output_path_flags.clone(),
289            writes_cwd: canonical.writes_cwd,
290        });
291    }
292    out.push(canonical);
293    Ok(out)
294}
295
296/// Enforce the research standard at build time (reading the TOML fields, so provenance is a
297/// production-validated part of the tree, not dead metadata). A sub with a `profile` must name a
298/// real archetype and cite a `fact` + `source`; each escalating `[[command.sub.flag]]` must name a
299/// real archetype (or the `unclassified` fail-closed escape) and cite its own `fact` + `source`. A
300/// mis-authored classification fails CLOSED — the registry panics at load rather than silently
301/// under-recording why a subcommand sits above the auto-approve line.
302fn assert_sub_provenance(parent: &str, toml: &TomlSub) -> Result<(), String> {
303    let cited = |o: &Option<String>| o.as_deref().is_some_and(|s| !s.trim().is_empty());
304    // A `judgment` is optional (the discretionary layer), but if given it must say something.
305    let judged = |o: &Option<String>| o.as_deref().is_none_or(|s| !s.trim().is_empty());
306    if let Some(p) = &toml.profile {
307        ensure!(
308            crate::engine::archetype::archetype(p).is_some(),
309            "{parent} sub `{}`: profile `{p}` is not a known archetype (archetypes.toml)",
310            toml.name,
311        );
312        ensure!(cited(&toml.fact), "{parent} sub `{}`: `profile` requires a `fact`", toml.name);
313        ensure!(cited(&toml.source), "{parent} sub `{}`: `profile` requires a `source`", toml.name);
314        ensure!(judged(&toml.judgment), "{parent} sub `{}`: `judgment`, if given, must not be blank", toml.name);
315        // A profiled sub must say what flags it accepts. The engine classifies it from its archetype
316        // without running the legacy flag walk, so an EMPTY list is not "nothing permitted" — before
317        // this was enforced it meant "everything permitted", and `git rebase --exec 'rm -rf /'`
318        // classified as an ordinary rebase. Declaring `tolerate_unknown_*` is the explicit way to say
319        // a surface is genuinely unbounded; saying nothing at all is not an option.
320        ensure!(
321            !toml.standalone.is_empty()
322                || !toml.valued.is_empty()
323                || toml.tolerate_unknown_short == Some(true)
324                || toml.tolerate_unknown_long == Some(true)
325                || !toml.flag.is_empty(),
326            "{parent} sub `{}`: a profiled sub must declare its flag surface — list `standalone`/\
327             `valued`, or set `tolerate_unknown_long = true` if it is genuinely unbounded",
328            toml.name,
329        );
330        // A profiled sub is a leaf: the engine classifies it by archetype, and its legacy kind is
331        // forced to deny-all (below) — a nested Branching would sidestep that.
332        ensure!(toml.sub.is_empty(), "{parent} sub `{}`: a profiled sub must be a leaf (no nested subs)", toml.name);
333    }
334    // `network_destination` classifies the destination onto the archetype's `locus.provenance`, so
335    // it only has meaning on a profiled sub.
336    ensure!(
337        toml.network_destination != Some(true) || toml.profile.is_some(),
338        "{parent} sub `{}`: `network_destination` requires a `profile`",
339        toml.name,
340    );
341    ensure!(
342        toml.destination_flag.is_none() || toml.network_destination == Some(true),
343        "{parent} sub `{}`: `destination_flag` requires `network_destination`",
344        toml.name,
345    );
346    // An output-file path is only meaningful on a profiled (`data-export`) sub — the engine gates
347    // that file's write onto the sub's derived profile.
348    ensure!(
349        toml.output_path_flags.is_empty() || toml.profile.is_some(),
350        "{parent} sub `{}`: `output_path_flags` requires a `profile`",
351        toml.name,
352    );
353    for f in &toml.flag {
354        ensure!(
355            f.classifies == "unclassified" || crate::engine::archetype::archetype(&f.classifies).is_some(),
356            "{parent} sub `{}` flag `{}`: classifies `{}` is not a known archetype",
357            toml.name,
358            f.name,
359            f.classifies,
360        );
361        ensure!(cited(&f.fact), "{parent} sub `{}` flag `{}`: requires a `fact`", toml.name, f.name);
362        ensure!(cited(&f.source), "{parent} sub `{}` flag `{}`: requires a `source`", toml.name, f.name);
363        ensure!(judged(&f.judgment), "{parent} sub `{}` flag `{}`: `judgment`, if given, must not be blank", toml.name, f.name);
364        ensure!(
365            !(f.when_absent == Some(true) && f.value_prefix.is_some()),
366            "{parent} sub `{}` flag `{}`: `when_absent` and `value_prefix` are mutually exclusive",
367            toml.name,
368            f.name,
369        );
370    }
371    Ok(())
372}
373
374/// `loopback_localizes` says "pointed at this machine, clear the facets the destination decides".
375/// Two things must hold or the claim is incoherent:
376///
377/// - it needs a `loopback_valued` flag, since nothing else can establish that the destination IS
378///   this machine;
379/// - a DESTROY archetype may not set it. The emulator claim is unverifiable — `ssh -L
380///   8000:dynamodb.<region>.amazonaws.com:443` makes `localhost:8000` production — and a wrong
381///   guess costs a stray write for a mutate and the data for a delete. Refusing it here rather than
382///   relying on the resolver's runtime skip means no future sub opts out by accident.
383fn assert_loopback_localizes_is_coherent(parent: &str, name: &str, toml: &TomlSub) -> Result<(), String> {
384    // Both the admission gate (`presents_unlisted_flag`) and the delta (`sub_loopback_localizes`)
385    // are reached only through the PROFILED sub walk, so on an unprofiled sub the declaration is
386    // inert — the flag would simply be missing from the legacy lists and deny, with nothing saying
387    // why. Fails closed, but silently, which is its own kind of bug.
388    ensure!(
389        toml.loopback_valued.is_empty() || toml.profile.is_some(),
390        "{parent} sub `{name}`: `loopback_valued` needs `profile` — the gate is only consulted on \
391         a profiled sub, so here it would do nothing at all",
392    );
393    if !toml.loopback_localizes.unwrap_or(false) {
394        return Ok(());
395    }
396    ensure!(
397        !toml.loopback_valued.is_empty(),
398        "{parent} sub `{name}`: `loopback_localizes` requires a `loopback_valued` flag — without \
399         one nothing can establish that the destination is this machine",
400    );
401    // Asked of the archetype's OPERATION facet, not of its name. A name test passes today only
402    // because every destroy archetype happens to be called `*destroy*`; a later `remote-wipe` or
403    // `bucket-purge` carrying `operation = "destroy"` would sail through it and localize. Deciding
404    // by name rather than behavior is the exact mistake this classifier exists to avoid.
405    let profile = toml.profile.as_deref().unwrap_or_default();
406    let operation = crate::engine::archetype::archetype(profile).map(|c| c.operation);
407    ensure!(
408        operation != Some(crate::engine::facet::Operation::Destroy),
409        "{parent} sub `{name}`: `profile = \"{profile}\"` carries `operation = destroy` and may \
410         not set `loopback_localizes` — a loopback endpoint cannot be verified (an SSH tunnel makes \
411         localhost mean production), and that is only unrecoverable for destroy",
412    );
413    Ok(())
414}
415
416pub(super) fn build_sub(
417    parent: &str,
418    mut toml: TomlSub,
419    handler_policies: &std::collections::HashMap<String, OwnedPolicy>,
420) -> Result<SubSpec, String> {
421    check_no_legacy_positional_style(&toml.name, toml.positional_style)?;
422    let name = toml.name.clone();
423    let policy_ref = toml.policy.clone();
424    let profile = toml.profile.clone();
425    assert_sub_provenance(parent, &toml)?;
426    assert_no_candidate_shadowed_by_glob(&format!("{parent} {}", toml.name), &toml.sub, &toml.first_arg)?;
427    let flags = std::mem::take(&mut toml.flag)
428        .into_iter()
429        .map(|f| crate::registry::types::FlagProvenance {
430            name: f.name,
431            classifies: f.classifies,
432            value_prefix: f.value_prefix,
433            when_absent: f.when_absent.unwrap_or(false),
434        })
435        .collect();
436    // A profiled sub is engine-classified and above the auto-approve line. Its LEGACY dispatch is
437    // reached only when the engine ABSTAINS — e.g. a global flag (`git -c …`, `git -C …`) intervenes
438    // before the subcommand, so `sub_archetypes`'s walk stops early. In that case the legacy kind
439    // MUST deny outright (fail-closed), never fall through to a permissive default: force bare off,
440    // no flags, no positionals.
441    // Preserve the DECLARED lists before the legacy kind is zeroed: the engine classifies a profiled
442    // sub from its archetype without ever running the legacy flag walk, so these are the only copy
443    // that survives, and `sub_archetypes` validates against them.
444    let (allowed_standalone, allowed_valued) = (toml.standalone.clone(), toml.valued.clone());
445    let allowed_unknown = match (toml.tolerate_unknown_short.unwrap_or(false), toml.tolerate_unknown_long.unwrap_or(false)) {
446        (false, false) => UnknownTolerance::Strict,
447        (true, false) => UnknownTolerance::Short,
448        (false, true) => UnknownTolerance::Long,
449        (true, true) => UnknownTolerance::Both,
450    };
451    if profile.is_some() {
452        toml.bare = Some(false);
453        toml.standalone = Vec::new();
454        toml.valued = Vec::new();
455        toml.max_positional = Some(0);
456    }
457    let eval_safe = toml.eval_safe.unwrap_or(false);
458    let eval_safe_flags = std::mem::take(&mut toml.eval_safe_flags);
459    let eval_safe_flag_values = std::mem::take(&mut toml.eval_safe_flag_values);
460    let eval_safe_required_flags = std::mem::take(&mut toml.eval_safe_required_flags);
461    let network_destination = toml.network_destination.unwrap_or(false);
462    let destination_flag = toml.destination_flag.clone();
463    let output_path_flags = toml.output_path_flags.clone();
464    let loopback_valued = toml.loopback_valued.clone();
465    let loopback_effect = if toml.loopback_localizes.unwrap_or(false) { LoopbackEffect::Localizes } else { LoopbackEffect::AdmitOnly };
466    assert_loopback_localizes_is_coherent(parent, &name, &toml)?;
467    let valued_for_check = toml.valued.clone();
468    assert_eval_safe_flags_require_tag(parent, &name, eval_safe, &eval_safe_flags)?;
469    assert_eval_safe_flag_values_consistent(parent, &name, &eval_safe_flags, &eval_safe_flag_values)?;
470    assert_eval_safe_valued_flags_declared(parent, &name, &eval_safe_flags, &valued_for_check, &eval_safe_flag_values)?;
471    assert_eval_safe_required_flags_consistent(parent, &name, &eval_safe_flags, &eval_safe_required_flags)?;
472    assert_sub_eval_safe_only_on_leaf(parent, &toml)?;
473    let writes_cwd = super::cwd_writes::lower(parent, &name, toml.writes_cwd.as_deref(), &toml.output_dirs, toml.executor.as_deref())?;
474    let output = lower_output(&format!("{parent} {name}"), toml.output.as_ref())?;
475    Ok(SubSpec {
476        name,
477        output,
478        writes_cwd,
479        name_match: if toml.per_database { NameMatch::WithDatabaseSuffix } else { NameMatch::Exact },
480        kind: build_sub_kind(parent, toml, handler_policies)?,
481        policy_ref,
482        profile,
483        flags,
484        allowed_standalone,
485        allowed_valued,
486        allowed_unknown,
487        eval_safe,
488        eval_safe_flags,
489        eval_safe_flag_values,
490        eval_safe_required_flags,
491        network_destination,
492        destination_flag,
493        loopback_valued,
494        loopback_effect,
495        output_path_flags,
496    })
497}
498
499fn assert_eval_safe_flag_values_consistent(
500    parent: &str,
501    name: &str,
502    eval_safe_flags: &[String],
503    eval_safe_flag_values: &std::collections::HashMap<String, Vec<String>>,
504) -> Result<(), String> {
505    for (flag, values) in eval_safe_flag_values {
506        if !eval_safe_flags.iter().any(|f| f == flag) {
507            fail!(
508                "command '{parent}' sub `{name}` lists `{flag}` in \
509                 `eval_safe_flag_values` but not in `eval_safe_flags`. \
510                 A value allowlist only takes effect when the flag itself \
511                 is allowed. Add `{flag}` to `eval_safe_flags` or remove \
512                 the value entry."
513            );
514        }
515        for value in values {
516            if value.is_empty() || !value.chars().all(is_bare_literal_char) {
517                fail!(
518                    "command '{parent}' sub `{name}` has eval_safe_flag_values \
519                     for `{flag}` containing value `{value:?}` with characters \
520                     outside `[a-zA-Z0-9_./=-]`. The allowed-value set must \
521                     itself be bare-literal so it can never embed a shell-\
522                     expansion trigger into the substituted invocation."
523                );
524            }
525        }
526    }
527    Ok(())
528}
529
530fn is_bare_literal_char(c: char) -> bool {
531    c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | '=')
532}
533
534fn assert_eval_safe_required_flags_consistent(
535    parent: &str,
536    name: &str,
537    eval_safe_flags: &[String],
538    eval_safe_required_flags: &[String],
539) -> Result<(), String> {
540    for flag in eval_safe_required_flags {
541        if !eval_safe_flags.iter().any(|f| f == flag) {
542            fail!(
543                "command '{parent}' sub `{name}` lists `{flag}` in \
544                 `eval_safe_required_flags` but not in `eval_safe_flags`. \
545                 A required-flag constraint must be a subset of the \
546                 allowed-flag set — otherwise the flag is required AND \
547                 immediately denied. Add `{flag}` to `eval_safe_flags` or \
548                 remove it from `eval_safe_required_flags`."
549            );
550        }
551    }
552    Ok(())
553}
554
555/// Every valued flag in `eval_safe_flags` must declare its value
556/// posture in `eval_safe_flag_values`: either a concrete value
557/// allowlist (`["env", "fish"]`) OR the explicit-unrestricted form
558/// (`[]`). Without this, a contributor adding a short alias like
559/// `-f` for an already-tagged `--format` would silently widen the
560/// eval-safe surface to any value of `-f` — the v0.196.0 aws
561/// near-miss in disguise.
562fn assert_eval_safe_valued_flags_declared(
563    parent: &str,
564    name: &str,
565    eval_safe_flags: &[String],
566    valued: &[String],
567    eval_safe_flag_values: &std::collections::HashMap<String, Vec<String>>,
568) -> Result<(), String> {
569    for flag in eval_safe_flags {
570        if !valued.iter().any(|v| v == flag) {
571            continue;
572        }
573        if !eval_safe_flag_values.contains_key(flag) {
574            fail!(
575                "command '{parent}' sub `{name}` lists `{flag}` in \
576                 `eval_safe_flags` AND in `valued`, but `{flag}` has no \
577                 entry in `eval_safe_flag_values`. Every valued flag \
578                 tagged eval-safe must declare its value posture: \
579                 either a concrete allowlist of safe values \
580                 (`{flag} = [\"value-a\", \"value-b\"]`) or the \
581                 explicit-unrestricted form (`{flag} = []`) signaling \
582                 the contributor vetted that any value preserves shell-\
583                 init output. Omitting an entry means the walker can't \
584                 tell whether the value-following-flag is supposed to \
585                 be checked, and a future short alias of `{flag}` \
586                 could silently widen the eval-safe surface."
587            );
588        }
589    }
590    Ok(())
591}
592
593fn assert_eval_safe_flags_require_tag(parent: &str, name: &str, eval_safe: bool, flags: &[String]) -> Result<(), String> {
594    if !flags.is_empty() && !eval_safe {
595        fail!(
596            "command '{parent}' sub `{name}` declares `eval_safe_flags` without \
597             `eval_safe = true`. The flag allowlist only takes effect when the \
598             sub is tagged eval-safe. Add `eval_safe = true` or drop \
599             `eval_safe_flags`."
600        );
601    }
602    Ok(())
603}
604
605fn assert_sub_eval_safe_only_on_leaf(parent: &str, toml: &TomlSub) -> Result<(), String> {
606    if toml.eval_safe != Some(true) {
607        return Ok(());
608    }
609    if !toml.sub.is_empty() {
610        fail!(
611            "command '{parent}' sub `{}` sets `eval_safe = true` AND has \
612             nested [[command.sub.sub]] blocks. eval_safe must be tagged on \
613             a leaf node — move the tag onto the specific sub-sub that emits \
614             shell-init code. Otherwise the walker accepts any unmatched \
615             sub-sub name as a positional, which is counter-intuitive.",
616            toml.name,
617        );
618    }
619    if toml.handler.is_some() {
620        fail!(
621            "command '{parent}' sub `{}` sets `eval_safe = true` AND \
622             `handler = \"...\"`. Handler-based subs run Rust dispatch logic \
623             whose shape the eval walker cannot introspect — eval-safety \
624             requires a declarative leaf the registry can reason about.",
625            toml.name,
626        );
627    }
628    if toml.delegate_after.is_some() || toml.delegate_skip.is_some() {
629        fail!(
630            "command '{parent}' sub `{}` sets `eval_safe = true` AND \
631             delegates to an inner command (delegate_after / delegate_skip). \
632             The inner command's output is unrelated to this sub's vetting \
633             — drop `eval_safe`.",
634            toml.name,
635        );
636    }
637    Ok(())
638}
639
640fn assert_eval_safe_tagged_command_has_researched_version(toml: &TomlCommand) -> Result<(), String> {
641    let command_tagged = toml.eval_safe == Some(true);
642    let any_sub_tagged = toml_has_any_eval_safe_sub(&toml.sub);
643    if !command_tagged && !any_sub_tagged {
644        return Ok(());
645    }
646    if toml.researched_version.is_none() {
647        fail!(
648            "command '{}' has `eval_safe = true` (on the command or a sub) \
649             but no `researched_version`. eval-safe tags pin a per-tag trust \
650             claim against a specific upstream snapshot — add the version \
651             you researched (e.g. `researched_version = \"v2026.5.3\"`) so \
652             the next contributor knows what to diff against.",
653            toml.name,
654        );
655    }
656    Ok(())
657}
658
659fn toml_has_any_eval_safe_sub(subs: &[TomlSub]) -> bool {
660    subs.iter().any(|s| s.eval_safe == Some(true) || toml_has_any_eval_safe_sub(&s.sub))
661}
662
663fn assert_command_eval_safe_only_on_leaf(toml: &TomlCommand) -> Result<(), String> {
664    if toml.eval_safe != Some(true) {
665        return Ok(());
666    }
667    if !toml.sub.is_empty() {
668        fail!(
669            "command '{}' sets `eval_safe = true` at the command level AND \
670             has [[command.sub]] blocks. Move the tag onto the specific \
671             sub that emits shell-init code (e.g. `mise activate`) — \
672             command-level tagging on a structured command is counter-\
673             intuitive.",
674            toml.name,
675        );
676    }
677    // Note: command-level eval_safe IS allowed alongside `handler =
678    // "..."`. The walker reads `spec.eval_safe` directly at the leaf
679    // — it does not need to introspect the handler's dispatch logic.
680    // The contributor takes responsibility for vouching that every
681    // invocation the handler accepts AND that passes the eval_safe_*
682    // flag checks produces shell-init stdout (typically narrowed via
683    // `eval_safe_required_flags`). See fzf's TOML for the canonical
684    // pattern.
685    if toml.wrapper.is_some() {
686        fail!(
687            "command '{}' sets `eval_safe = true` AND `[command.wrapper]`. \
688             Wrappers forward to an inner command — tagging the wrapper \
689             would tag every wrapped invocation. Drop `eval_safe`.",
690            toml.name,
691        );
692    }
693    if toml.deny.unwrap_or(false) {
694        fail!(
695            "command '{}' sets both `deny = true` and `eval_safe = true`. \
696             These are contradictory — deny silently dominates. Drop one.",
697            toml.name,
698        );
699    }
700    Ok(())
701}
702
703fn build_sub_kind(
704    parent: &str,
705    toml: TomlSub,
706    handler_policies: &std::collections::HashMap<String, OwnedPolicy>,
707) -> Result<DispatchKind, String> {
708    if let Some(handler_name) = toml.handler {
709        return Ok(DispatchKind::Custom {
710            handler_name,
711            doc_body: toml.doc_body,
712            subs: Vec::new(),
713            fallback: None,
714            handler_policies: std::collections::HashMap::new(),
715            matrices: Vec::new(),
716        });
717    }
718    if toml.allow_all.unwrap_or(false) {
719        return Ok(DispatchKind::Policy { policy: allow_all_policy(), level: toml.level.unwrap_or(TomlLevel::Inert).into() });
720    }
721    if let Some(sep) = toml.delegate_after {
722        return Ok(DispatchKind::DelegateAfterSeparator { separator: sep });
723    }
724    if let Some(skip) = toml.delegate_skip {
725        return Ok(DispatchKind::DelegateSkip { skip });
726    }
727    // A `credential_first_arg` gate forces the Branching path even with no sub-subs: only Branching
728    // runs `skip_pre_flags`, so the credential check sees the real resource arg (`get -o yaml secret`),
729    // not a leading flag — `FirstArg` reads `tokens[1]` verbatim and would be bypassed by a flag.
730    if !toml.sub.is_empty() || !toml.credential_first_arg.is_empty() {
731        // A sub may carry BOTH explicit sub-subs AND a fallback `first_arg` glob (a service that
732        // auto-approves its read verbs via `get-*`/`describe-*` but carves specific dangerous actions
733        // out to profiled sub-subs). Dispatch checks the explicit subs FIRST, then the glob
734        // (dispatch.rs), so the carve-outs escalate while the benign reads still glob-match. Mirror
735        // the command-level Branching, which already threads `first_arg` through; the sub level used
736        // to hard-drop it (`Vec::new()`), which made "glob + carve-out" inexpressible.
737        let first_arg_level = toml.level.unwrap_or(TomlLevel::Inert).into();
738        return Ok(DispatchKind::Branching {
739            subs: filter_candidates(toml.sub)?
740                .map(|s| build_subs(parent, s, handler_policies))
741                .collect::<Result<Vec<_>, _>>()?
742                .into_iter()
743                .flatten()
744                .collect(),
745            bare_flags: Vec::new(),
746            bare_ok: toml.nested_bare.unwrap_or(false),
747            pre_standalone: toml.standalone,
748            pre_valued: toml.valued,
749            first_arg: toml.first_arg,
750            first_arg_level,
751            first_arg_standalone: toml.first_arg_standalone,
752            first_arg_valued: toml.first_arg_valued,
753            first_arg_loopback_valued: toml.first_arg_loopback_valued,
754            credential_first_arg: toml.credential_first_arg,
755            // A nested sub level; the selector is only ever leading, so it belongs to the command.
756            toolchain_selector: false,
757        });
758    }
759    build_policy_sub_kind(parent, toml, handler_policies)
760}
761
762fn build_policy_sub_kind(
763    parent: &str,
764    toml: TomlSub,
765    handler_policies: &std::collections::HashMap<String, OwnedPolicy>,
766) -> Result<DispatchKind, String> {
767    let policy = if let Some(key) = &toml.policy {
768        if !toml.standalone.is_empty() || !toml.valued.is_empty() {
769            fail!(
770                "command '{parent}' sub `{}` sets both `policy = \"{}\"` and \
771                 inline standalone/valued — pick one. Either drop the inline \
772                 lists (and rely on the referenced handler_policy) or drop \
773                 the `policy` field.",
774                toml.name,
775                key,
776            );
777        }
778        match handler_policies.get(key).cloned() {
779            Some(p) => p,
780            None => fail!(
781                "command '{parent}' sub `{}` references handler_policy \
782                 `{key}` which is not declared. Add a \
783                 [command.handler_policy.{key}] block or fix the typo.",
784                toml.name,
785            ),
786        }
787    } else {
788        build_policy(
789            toml.standalone, toml.valued, toml.optional_valued, toml.bare, toml.max_positional, toml.tolerate_unknown_short,
790            toml.tolerate_unknown_long, toml.numeric_dash,
791        )
792    };
793    let level: SafetyLevel = toml.level.unwrap_or(TomlLevel::Inert).into();
794    if let Some(name) = toml.executor.as_deref() {
795        let Some(kind) = ExecutorKind::from_name(name) else {
796            fail!("command '{parent}' sub `{}`: unknown executor `{name}` (known: file, project)", toml.name)
797        };
798        let shape = match toml.positional_shape.as_deref() {
799            None => None,
800            Some(s) => match crate::policy::PositionalShape::from_name(s) {
801                Some(shape) => Some(shape),
802                None => fail!("command '{parent}' sub `{}`: unknown positional_shape `{s}`", toml.name),
803            },
804        };
805        return Ok(DispatchKind::Executor {
806            policy,
807            level,
808            kind,
809            redirect_flag: toml.executor_redirect_flag,
810            shape,
811            passes_argv: toml.passes_argv.unwrap_or(false),
812        });
813    }
814    if !toml.write_flags.is_empty() {
815        return Ok(DispatchKind::WriteFlagged { policy, base_level: level, write_flags: toml.write_flags });
816    }
817    if let Some(guard) = toml.guard {
818        let mut require_any = vec![guard];
819        if let Some(short) = toml.guard_short {
820            require_any.push(short);
821        }
822        return Ok(DispatchKind::RequireAny { require_any, policy, level, accept_bare_help: true });
823    }
824    if !toml.first_arg.is_empty() {
825        return Ok(DispatchKind::FirstArg {
826            patterns: toml.first_arg,
827            level,
828            standalone: toml.first_arg_standalone,
829            valued: toml.first_arg_valued,
830            loopback_valued: toml.first_arg_loopback_valued,
831        });
832    }
833    if !toml.require_any.is_empty() {
834        return Ok(DispatchKind::RequireAny { require_any: toml.require_any, policy, level, accept_bare_help: false });
835    }
836    Ok(DispatchKind::Policy { policy, level })
837}
838
839/// Diagnostic for a configuration class that silently breaks flag dispatch:
840/// a structured command (with `[[command.sub]]` blocks) cannot also use the
841/// flat-style top-level fields. When subs are present, top-level standalone/
842/// valued/max_positional/positional_style/numeric_dash are dropped — the
843/// dispatch routes through the Branching path. The fix is to either remove
844/// the subs (if the command is meant to be flat) or move global flags into
845/// a `[command.wrapper]` block.
846fn assert_flat_or_structured(toml: &TomlCommand) -> Result<(), String> {
847    if toml.sub.is_empty() {
848        return Ok(());
849    }
850    let mut conflicts = Vec::new();
851    if !toml.standalone.is_empty() {
852        conflicts.push("standalone");
853    }
854    if !toml.valued.is_empty() {
855        conflicts.push("valued");
856    }
857    if toml.max_positional.is_some() {
858        conflicts.push("max_positional");
859    }
860    if toml.tolerate_unknown_short.is_some() {
861        conflicts.push("tolerate_unknown_short");
862    }
863    if toml.tolerate_unknown_long.is_some() {
864        conflicts.push("tolerate_unknown_long");
865    }
866    if toml.numeric_dash.is_some() {
867        conflicts.push("numeric_dash");
868    }
869    if !conflicts.is_empty() {
870        fail!(
871            "command '{}' mixes flat-style top-level fields ({}) with [[command.sub]] blocks. \
872             When subs are present these fields are silently dropped. \
873             Either drop the subs (if the command is flat) or move global \
874             flags into a [command.wrapper] block.",
875            toml.name,
876            conflicts.join(", "),
877        );
878    }
879    Ok(())
880}
881
882fn assert_matrix_policy_keys_exist(toml: &TomlCommand) -> Result<(), String> {
883    if toml.matrix.is_empty() {
884        return Ok(());
885    }
886    for matrix in &toml.matrix {
887        for (action_name, action) in &matrix.actions {
888            let policy_key = match action {
889                TomlMatrixAction::Policy(k) => k,
890                TomlMatrixAction::Detailed(d) => &d.policy,
891            };
892            if !toml.handler_policy.contains_key(policy_key) {
893                fail!(
894                    "command '{}' matrix action `{}` references \
895                     handler_policy `{}` which is not declared. \
896                     Add a [command.handler_policy.{}] block or fix the typo.",
897                    toml.name,
898                    action_name,
899                    policy_key,
900                    policy_key,
901                );
902            }
903        }
904    }
905    Ok(())
906}
907
908fn assert_matrix_no_duplicate_parent_action(toml: &TomlCommand) -> Result<(), String> {
909    if toml.matrix.len() < 2 {
910        return Ok(());
911    }
912    let mut seen: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();
913    for matrix in &toml.matrix {
914        for parent in &matrix.parents {
915            for action in matrix.actions.keys() {
916                let key = (parent.clone(), action.clone());
917                if !seen.insert(key) {
918                    fail!(
919                        "command '{}' matrix has duplicate (parent, action) pair \
920                         (`{}`, `{}`). The first match would silently win — \
921                         consolidate into one matrix block or remove the duplicate.",
922                        toml.name,
923                        parent,
924                        action,
925                    );
926                }
927            }
928        }
929    }
930    Ok(())
931}
932
933fn assert_fallback_requires_handler(toml: &TomlCommand) -> Result<(), String> {
934    if toml.fallback.is_some() && toml.handler.is_none() {
935        fail!(
936            "command '{}' declares [command.fallback] without a handler. \
937             Fallback grammars are only consulted via \
938             registry::try_fallback_grammar() from a Rust handler — without \
939             handler = \"...\" the block is silently dropped. \
940             Either set handler or remove [command.fallback].",
941            toml.name,
942        );
943    }
944    Ok(())
945}
946
947/// Validate and lower a top-level command's `[[command.flag]]` classifying flags — the flat-command
948/// analog of a profiled sub's escalating flags. Each must name a known archetype (or `unclassified`)
949/// and cite `fact`/`source`; `when_absent`/`value_prefix` are mutually exclusive. Mirrors the per-sub
950/// check in `assert_sub_provenance`.
951fn build_command_archetype_flags(cmd: &str, flags: Vec<TomlSubFlag>) -> Result<Vec<crate::registry::types::FlagProvenance>, String> {
952    let cited = |o: &Option<String>| o.as_deref().is_some_and(|s| !s.trim().is_empty());
953    let judged = |o: &Option<String>| o.as_deref().is_none_or(|s| !s.trim().is_empty());
954    flags
955        .into_iter()
956        .map(|f| {
957            ensure!(
958                f.classifies == "unclassified" || crate::engine::archetype::archetype(&f.classifies).is_some(),
959                "command `{cmd}` flag `{}`: classifies `{}` is not a known archetype",
960                f.name,
961                f.classifies,
962            );
963            ensure!(cited(&f.fact), "command `{cmd}` flag `{}`: requires a `fact`", f.name);
964            ensure!(cited(&f.source), "command `{cmd}` flag `{}`: requires a `source`", f.name);
965            ensure!(judged(&f.judgment), "command `{cmd}` flag `{}`: `judgment`, if given, must not be blank", f.name);
966            ensure!(
967                !(f.when_absent == Some(true) && f.value_prefix.is_some()),
968                "command `{cmd}` flag `{}`: `when_absent` and `value_prefix` are mutually exclusive",
969                f.name,
970            );
971            Ok(crate::registry::types::FlagProvenance {
972                name: f.name,
973                classifies: f.classifies,
974                value_prefix: f.value_prefix,
975                when_absent: f.when_absent.unwrap_or(false),
976            })
977        })
978        .collect()
979}
980
981#[allow(clippy::too_many_lines)]
982pub(super) fn build_command(toml: TomlCommand, category: &str) -> Result<CommandSpec, String> {
983    assert_flat_or_structured(&toml)?;
984    assert_fallback_requires_handler(&toml)?;
985    assert_matrix_policy_keys_exist(&toml)?;
986    assert_no_candidate_shadowed_by_glob(&toml.name, &toml.sub, &toml.first_arg)?;
987    assert_matrix_no_duplicate_parent_action(&toml)?;
988    assert_command_eval_safe_only_on_leaf(&toml)?;
989    assert_eval_safe_tagged_command_has_researched_version(&toml)?;
990    check_no_legacy_positional_style(&toml.name, toml.positional_style)?;
991    let cat = category.to_string();
992    let desc = toml.description.unwrap_or_default();
993    let researched_version = toml.researched_version;
994    let examples_safe = toml.examples_safe;
995    let examples_denied = toml.examples_denied;
996    let eval_safe = toml.eval_safe.unwrap_or(false);
997    let eval_safe_flags = toml.eval_safe_flags;
998    let eval_safe_flag_values = toml.eval_safe_flag_values;
999    let eval_safe_required_flags = toml.eval_safe_required_flags;
1000    if !eval_safe_flags.is_empty() && !eval_safe {
1001        fail!(
1002            "command '{}' declares `eval_safe_flags` without `eval_safe = true`. \
1003             The flag allowlist only takes effect when the command is tagged \
1004             eval-safe. Add `eval_safe = true` or drop `eval_safe_flags`.",
1005            toml.name,
1006        );
1007    }
1008    assert_eval_safe_flag_values_consistent(&toml.name, "<command>", &eval_safe_flags, &eval_safe_flag_values)?;
1009    assert_eval_safe_valued_flags_declared(&toml.name, "<command>", &eval_safe_flags, &toml.valued, &eval_safe_flag_values)?;
1010    assert_eval_safe_required_flags_consistent(&toml.name, "<command>", &eval_safe_flags, &eval_safe_required_flags)?;
1011    let behavior = lower_behavior(&toml.name, toml.behavior.as_ref())?;
1012    let output = lower_output(&toml.name, toml.output.as_ref())?;
1013    let writes_cwd = super::cwd_writes::lower(&toml.name, "<command>", toml.writes_cwd.as_deref(), &toml.output_dirs, None)?;
1014    let env_assignment_positionals = toml.env_assignment_positionals.unwrap_or(false);
1015    let archetype_flags = build_command_archetype_flags(&toml.name, toml.flag)?;
1016    if toml.deny.unwrap_or(false) {
1017        return Ok(CommandSpec {
1018            name: toml.name,
1019            description: desc,
1020            aliases: toml.aliases,
1021            url: toml.url,
1022            category: cat,
1023            researched_version,
1024            examples_safe,
1025            examples_denied,
1026            eval_safe,
1027            eval_safe_flags: eval_safe_flags.clone(),
1028            eval_safe_flag_values: eval_safe_flag_values.clone(),
1029            eval_safe_required_flags: eval_safe_required_flags.clone(),
1030            path_gate: toml.path_gate,
1031            archetype_flags: archetype_flags.clone(),
1032            behavior: behavior.clone(),
1033            output,
1034            writes_cwd,
1035            env_assignment_positionals,
1036            kind: DispatchKind::Policy {
1037                policy: OwnedPolicy {
1038                    standalone: Vec::new(),
1039                    valued: Vec::new(),
1040                    bare: false,
1041                    max_positional: Some(0),
1042                    tolerance: FlagTolerance::default(),
1043                },
1044                level: SafetyLevel::Inert,
1045            },
1046        });
1047    }
1048    if let Some(vc) = toml.verb_chain {
1049        return Ok(CommandSpec {
1050            name: toml.name,
1051            description: desc,
1052            aliases: toml.aliases,
1053            url: toml.url,
1054            category: cat,
1055            researched_version,
1056            examples_safe,
1057            examples_denied,
1058            eval_safe,
1059            eval_safe_flags: eval_safe_flags.clone(),
1060            eval_safe_flag_values: eval_safe_flag_values.clone(),
1061            eval_safe_required_flags: eval_safe_required_flags.clone(),
1062            path_gate: toml.path_gate,
1063            archetype_flags: archetype_flags.clone(),
1064            behavior: behavior.clone(),
1065            output,
1066            writes_cwd,
1067            env_assignment_positionals,
1068            kind: DispatchKind::VerbChain(build_verb_chain(vc)),
1069        });
1070    }
1071
1072    if let Some(handler_name) = toml.handler {
1073        // Build handler_policies first so subs that use `policy = "key"`
1074        // can resolve the reference at build time.
1075        let handler_policies: std::collections::HashMap<String, OwnedPolicy> =
1076            toml.handler_policy.into_iter().map(|(k, v)| (k, build_handler_policy(v))).collect();
1077        let parent_name = toml.name.clone();
1078        let subs: Vec<SubSpec> = filter_candidates(toml.sub)?
1079            .map(|s| build_subs(&parent_name, s, &handler_policies))
1080            .collect::<Result<Vec<_>, _>>()?
1081            .into_iter()
1082            .flatten()
1083            .collect();
1084        let fallback = toml.fallback.map(|f| build_fallback(&toml.name, f)).transpose()?;
1085        let matrices = toml.matrix.into_iter().map(build_matrix).collect();
1086        return Ok(CommandSpec {
1087            name: toml.name,
1088            description: desc,
1089            aliases: toml.aliases,
1090            url: toml.url,
1091            category: cat,
1092            researched_version,
1093            examples_safe,
1094            examples_denied,
1095            eval_safe,
1096            eval_safe_flags: eval_safe_flags.clone(),
1097            eval_safe_flag_values: eval_safe_flag_values.clone(),
1098            eval_safe_required_flags: eval_safe_required_flags.clone(),
1099            path_gate: toml.path_gate,
1100            archetype_flags: archetype_flags.clone(),
1101            behavior: behavior.clone(),
1102            output,
1103            writes_cwd,
1104            env_assignment_positionals,
1105            kind: DispatchKind::Custom { handler_name, doc_body: toml.doc_body, subs, fallback, handler_policies, matrices },
1106        });
1107    }
1108
1109    if let Some(w) = toml.wrapper {
1110        if !toml.sub.is_empty() || !toml.bare_flags.is_empty() {
1111            let first_arg_level = toml.level.unwrap_or(TomlLevel::Inert).into();
1112            let parent_name = toml.name.clone();
1113            return Ok(CommandSpec {
1114                name: toml.name,
1115                description: desc,
1116                aliases: toml.aliases,
1117                url: toml.url,
1118                category: cat,
1119                researched_version,
1120                examples_safe,
1121                examples_denied,
1122                eval_safe,
1123                eval_safe_flags: eval_safe_flags.clone(),
1124                eval_safe_flag_values: eval_safe_flag_values.clone(),
1125                eval_safe_required_flags: eval_safe_required_flags.clone(),
1126                path_gate: toml.path_gate,
1127                archetype_flags: archetype_flags.clone(),
1128                behavior: behavior.clone(),
1129                output,
1130                writes_cwd,
1131                env_assignment_positionals,
1132                kind: DispatchKind::Branching {
1133                    bare_flags: toml.bare_flags,
1134                    subs: filter_candidates(toml.sub)?
1135                        .map(|s| build_subs(&parent_name, s, &std::collections::HashMap::new()))
1136                        .collect::<Result<Vec<_>, _>>()?
1137                        .into_iter()
1138                        .flatten()
1139                        .collect(),
1140                    pre_standalone: w.standalone,
1141                    pre_valued: w.valued,
1142                    bare_ok: toml.bare.unwrap_or(false),
1143                    first_arg: toml.first_arg,
1144                    first_arg_standalone: toml.first_arg_standalone,
1145                    first_arg_valued: toml.first_arg_valued,
1146                    first_arg_loopback_valued: toml.first_arg_loopback_valued,
1147                    first_arg_level,
1148                    credential_first_arg: toml.credential_first_arg,
1149                    toolchain_selector: w.toolchain_selector.unwrap_or(false),
1150                },
1151            });
1152        }
1153        return Ok(CommandSpec {
1154            name: toml.name,
1155            description: desc,
1156            aliases: toml.aliases,
1157            url: toml.url,
1158            category: cat,
1159            researched_version,
1160            examples_safe,
1161            examples_denied,
1162            eval_safe,
1163            eval_safe_flags: eval_safe_flags.clone(),
1164            eval_safe_flag_values: eval_safe_flag_values.clone(),
1165            eval_safe_required_flags: eval_safe_required_flags.clone(),
1166            path_gate: toml.path_gate,
1167            archetype_flags: archetype_flags.clone(),
1168            behavior: behavior.clone(),
1169            output,
1170            writes_cwd,
1171            env_assignment_positionals,
1172            kind: DispatchKind::Wrapper {
1173                standalone: w.standalone,
1174                valued: w.valued,
1175                positional_skip: w.positional_skip.unwrap_or(0),
1176                separator: w.separator,
1177                bare_ok: w.bare_ok.unwrap_or(false),
1178            },
1179        });
1180    }
1181
1182    if !toml.sub.is_empty() || !toml.bare_flags.is_empty() {
1183        let first_arg_level = toml.level.unwrap_or(TomlLevel::Inert).into();
1184        let parent_name = toml.name.clone();
1185        return Ok(CommandSpec {
1186            name: toml.name,
1187            description: desc,
1188            aliases: toml.aliases,
1189            url: toml.url,
1190            category: cat,
1191            researched_version,
1192            examples_safe,
1193            examples_denied,
1194            eval_safe,
1195            eval_safe_flags: eval_safe_flags.clone(),
1196            eval_safe_flag_values: eval_safe_flag_values.clone(),
1197            eval_safe_required_flags: eval_safe_required_flags.clone(),
1198            path_gate: toml.path_gate,
1199            archetype_flags: archetype_flags.clone(),
1200            behavior: behavior.clone(),
1201            output,
1202            writes_cwd,
1203            env_assignment_positionals,
1204            kind: DispatchKind::Branching {
1205                bare_flags: toml.bare_flags,
1206                subs: filter_candidates(toml.sub)?
1207                    .map(|s| build_subs(&parent_name, s, &std::collections::HashMap::new()))
1208                    .collect::<Result<Vec<_>, _>>()?
1209                    .into_iter()
1210                    .flatten()
1211                    .collect(),
1212                pre_standalone: Vec::new(),
1213                pre_valued: Vec::new(),
1214                bare_ok: toml.bare.unwrap_or(false),
1215                first_arg: toml.first_arg,
1216                first_arg_level,
1217                first_arg_standalone: toml.first_arg_standalone,
1218                first_arg_valued: toml.first_arg_valued,
1219                first_arg_loopback_valued: toml.first_arg_loopback_valued,
1220                credential_first_arg: toml.credential_first_arg,
1221                // Declared on `[command.wrapper]`, which this arm is the no-wrapper case of.
1222                toolchain_selector: false,
1223            },
1224        });
1225    }
1226
1227    let policy = build_policy(
1228        toml.standalone, toml.valued, toml.optional_valued, toml.bare, toml.max_positional, toml.tolerate_unknown_short,
1229        toml.tolerate_unknown_long, toml.numeric_dash,
1230    );
1231
1232    let level = toml.level.unwrap_or(TomlLevel::Inert).into();
1233
1234    if !toml.first_arg.is_empty() {
1235        return Ok(CommandSpec {
1236            name: toml.name,
1237            description: desc,
1238            aliases: toml.aliases,
1239            url: toml.url,
1240            category: cat,
1241            researched_version,
1242            examples_safe,
1243            examples_denied,
1244            eval_safe,
1245            eval_safe_flags: eval_safe_flags.clone(),
1246            eval_safe_flag_values: eval_safe_flag_values.clone(),
1247            eval_safe_required_flags: eval_safe_required_flags.clone(),
1248            path_gate: toml.path_gate,
1249            archetype_flags: archetype_flags.clone(),
1250            behavior: behavior.clone(),
1251            output,
1252            writes_cwd,
1253            env_assignment_positionals,
1254            kind: DispatchKind::FirstArg {
1255                patterns: toml.first_arg,
1256                level,
1257                standalone: toml.first_arg_standalone,
1258                valued: toml.first_arg_valued,
1259                loopback_valued: toml.first_arg_loopback_valued,
1260            },
1261        });
1262    }
1263
1264    if !toml.write_flags.is_empty() {
1265        return Ok(CommandSpec {
1266            name: toml.name,
1267            description: desc,
1268            aliases: toml.aliases,
1269            url: toml.url,
1270            category: cat,
1271            researched_version,
1272            examples_safe,
1273            examples_denied,
1274            eval_safe,
1275            eval_safe_flags: eval_safe_flags.clone(),
1276            eval_safe_flag_values: eval_safe_flag_values.clone(),
1277            eval_safe_required_flags: eval_safe_required_flags.clone(),
1278            path_gate: toml.path_gate,
1279            archetype_flags: archetype_flags.clone(),
1280            behavior: behavior.clone(),
1281            output,
1282            writes_cwd,
1283            env_assignment_positionals,
1284            kind: DispatchKind::WriteFlagged { policy, base_level: level, write_flags: toml.write_flags },
1285        });
1286    }
1287
1288    if !toml.require_any.is_empty() {
1289        return Ok(CommandSpec {
1290            name: toml.name,
1291            description: desc,
1292            aliases: toml.aliases,
1293            url: toml.url,
1294            category: cat,
1295            researched_version,
1296            examples_safe,
1297            examples_denied,
1298            eval_safe,
1299            eval_safe_flags: eval_safe_flags.clone(),
1300            eval_safe_flag_values: eval_safe_flag_values.clone(),
1301            eval_safe_required_flags: eval_safe_required_flags.clone(),
1302            path_gate: toml.path_gate,
1303            archetype_flags: archetype_flags.clone(),
1304            behavior: behavior.clone(),
1305            output,
1306            writes_cwd,
1307            env_assignment_positionals,
1308            kind: DispatchKind::RequireAny { require_any: toml.require_any, policy, level, accept_bare_help: false },
1309        });
1310    }
1311
1312    Ok(CommandSpec {
1313        env_assignment_positionals,
1314        name: toml.name,
1315        description: desc,
1316        aliases: toml.aliases,
1317        url: toml.url,
1318        category: cat,
1319        researched_version,
1320        examples_safe,
1321        examples_denied,
1322        eval_safe,
1323        eval_safe_flags,
1324        eval_safe_flag_values,
1325        eval_safe_required_flags,
1326        path_gate: toml.path_gate,
1327        archetype_flags,
1328        behavior,
1329        output,
1330        writes_cwd,
1331        kind: DispatchKind::Policy { policy, level },
1332    })
1333}
1334
1335/// Build every command definition in `source`, or the first reason one is invalid.
1336///
1337/// Returns `Err` rather than panicking because this reads USER files too — see the `fail!` macro at
1338/// the top of this module. The built-in `commands/*.toml` are compiled in and unwrap the result at
1339/// their call site, so a broken definition in this repository still stops the process loudly.
1340pub fn load_toml(source: &str, category: &str) -> Result<Vec<CommandSpec>, String> {
1341    let file: TomlFile = match toml::from_str(source) {
1342        Ok(f) => f,
1343        Err(e) => {
1344            let preview: String = source.chars().take(80).collect();
1345            fail!("invalid TOML command definition: {e}\n  source begins: {preview}");
1346        }
1347    };
1348    file.command
1349        .into_iter()
1350        .filter(|cmd| !cmd.candidate.unwrap_or(false))
1351        .map(|cmd| build_command(cmd, category))
1352        .collect()
1353}
1354
1355pub fn build_registry(specs: Vec<CommandSpec>) -> HashMap<String, CommandSpec> {
1356    let mut map = HashMap::new();
1357    for spec in specs {
1358        insert_spec(&mut map, spec);
1359    }
1360    map
1361}
1362
1363/// Insert a CommandSpec into the registry, registering both its canonical
1364/// name and each alias. Existing entries for the same command name are
1365/// removed first, so a custom-TOML override of `gh` replaces every
1366/// built-in alias of `gh` rather than leaving stale aliases pointing at
1367/// the old spec.
1368pub fn insert_spec(map: &mut HashMap<String, CommandSpec>, spec: CommandSpec) {
1369    map.retain(|_, s| s.name != spec.name);
1370    for alias in &spec.aliases {
1371        map.insert(
1372            alias.clone(),
1373            CommandSpec {
1374                // Carried, not defaulted: `typeset` is an alias of `declare`, and dropping this here
1375                // would let the alias put a variable into the environment unclassified while the
1376                // canonical spelling denied.
1377                env_assignment_positionals: spec.env_assignment_positionals,
1378                name: spec.name.clone(),
1379                description: spec.description.clone(),
1380                aliases: vec![],
1381                url: spec.url.clone(),
1382                category: spec.category.clone(),
1383                researched_version: spec.researched_version.clone(),
1384                examples_safe: vec![],
1385                examples_denied: vec![],
1386                eval_safe: spec.eval_safe,
1387                eval_safe_flags: spec.eval_safe_flags.clone(),
1388                eval_safe_flag_values: spec.eval_safe_flag_values.clone(),
1389                eval_safe_required_flags: spec.eval_safe_required_flags.clone(),
1390                // Aliases are canonicalized (`registry::canonical_name`) before `should_deny` and
1391                // before the engine's behavior lookup, so the canonical spec's `path_gate` /
1392                // `behavior` is what's consulted — the alias entry never needs either.
1393                path_gate: None,
1394                archetype_flags: Vec::new(),
1395                behavior: None,
1396                output: None,
1397                writes_cwd: None,
1398                kind: spec.kind.clone(),
1399            },
1400        );
1401    }
1402    map.insert(spec.name.clone(), spec);
1403}