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
8pub(super) fn build_policy(
9    standalone: Vec<String>,
10    valued: Vec<String>,
11    bare: Option<bool>,
12    max_positional: Option<usize>,
13    tolerate_unknown_short: Option<bool>,
14    tolerate_unknown_long: Option<bool>,
15    numeric_dash: Option<bool>,
16) -> OwnedPolicy {
17    let unknown = match (
18        tolerate_unknown_short.unwrap_or(false),
19        tolerate_unknown_long.unwrap_or(false),
20    ) {
21        (false, false) => UnknownTolerance::Strict,
22        (true, false) => UnknownTolerance::Short,
23        (false, true) => UnknownTolerance::Long,
24        (true, true) => UnknownTolerance::Both,
25    };
26    OwnedPolicy {
27        standalone,
28        valued,
29        bare: bare.unwrap_or(true),
30        max_positional,
31        tolerance: FlagTolerance {
32            unknown,
33            numeric_dash: numeric_dash.unwrap_or(false),
34        },
35    }
36}
37
38fn build_matrix(toml: TomlMatrix) -> MatrixSpec {
39    let actions = toml
40        .actions
41        .into_iter()
42        .map(|(name, action)| {
43            let built = match action {
44                TomlMatrixAction::Policy(policy_key) => MatrixAction {
45                    policy_key,
46                    guard: None,
47                    guard_short: None,
48                },
49                TomlMatrixAction::Detailed(d) => {
50                    MatrixAction {
51                        policy_key: d.policy,
52                        guard: d.guard,
53                        guard_short: d.guard_short,
54                    }
55                }
56            };
57            (name, built)
58        })
59        .collect();
60    MatrixSpec {
61        parents: toml.parents,
62        level: toml.level.into(),
63        actions,
64    }
65}
66
67fn build_handler_policy(toml: TomlHandlerPolicy) -> OwnedPolicy {
68    build_policy(
69        toml.standalone,
70        toml.valued,
71        toml.bare,
72        toml.max_positional,
73        toml.tolerate_unknown_short,
74        toml.tolerate_unknown_long,
75        toml.numeric_dash,
76    )
77}
78
79fn build_fallback(parent: &str, toml: TomlFallback) -> FallbackSpec {
80    let policy = build_policy(
81        toml.standalone,
82        toml.valued,
83        toml.bare,
84        toml.max_positional,
85        toml.tolerate_unknown_short,
86        toml.tolerate_unknown_long,
87        toml.numeric_dash,
88    );
89    let level: SafetyLevel = toml.level.unwrap_or(TomlLevel::Inert).into();
90    let positional_shape = toml.positional_shape.as_deref().map(|name| {
91        crate::policy::PositionalShape::from_name(name).unwrap_or_else(|| {
92            panic!(
93                "{}: unknown fallback positional_shape `{}` (known: path)",
94                parent, name
95            )
96        })
97    });
98    FallbackSpec {
99        policy,
100        level,
101        positional_shape,
102    }
103}
104
105fn allow_all_policy() -> OwnedPolicy {
106    OwnedPolicy {
107        standalone: Vec::new(),
108        valued: Vec::new(),
109        bare: true,
110        max_positional: None,
111        tolerance: FlagTolerance { unknown: UnknownTolerance::Both, numeric_dash: false },
112    }
113}
114
115fn check_no_legacy_positional_style(name: &str, ps: Option<bool>) {
116    if ps.is_some() {
117        panic!(
118            "command '{name}': `positional_style` was removed. Use \
119             `tolerate_unknown_short = true` for tools with single-dash \
120             flags (pdftotext -help, sample -mayDie). Use \
121             `tolerate_unknown_long = true` ONLY for tools whose \
122             double-dash flag surface is genuinely unbounded (AWS CLI \
123             style); double-dash unknowns silently pass when this is \
124             on, which has caused safety bugs. Most tools need neither."
125        );
126    }
127}
128
129fn filter_candidates(subs: Vec<TomlSub>) -> impl Iterator<Item = TomlSub> {
130    subs.into_iter().filter(|s| !s.candidate.unwrap_or(false))
131}
132
133/// Builds one SubSpec per alias (canonical name first, then each alias).
134/// All entries share the same kind via Clone — the dispatcher doesn't care
135/// which name the user invoked. `handler_policies` is consulted only by
136/// subs that set `policy = "key"`.
137pub(super) fn build_subs(
138    parent: &str,
139    toml: TomlSub,
140    handler_policies: &std::collections::HashMap<String, OwnedPolicy>,
141) -> Vec<SubSpec> {
142    let aliases = toml.aliases.clone();
143    let canonical = build_sub(parent, toml, handler_policies);
144    let mut out = Vec::with_capacity(1 + aliases.len());
145    for alias in aliases {
146        out.push(SubSpec {
147            name: alias,
148            kind: canonical.kind.clone(),
149            policy_ref: canonical.policy_ref.clone(),
150            eval_safe: canonical.eval_safe,
151            eval_safe_flags: canonical.eval_safe_flags.clone(),
152            eval_safe_flag_values: canonical.eval_safe_flag_values.clone(),
153            eval_safe_required_flags: canonical.eval_safe_required_flags.clone(),
154        });
155    }
156    out.push(canonical);
157    out
158}
159
160pub(super) fn build_sub(
161    parent: &str,
162    mut toml: TomlSub,
163    handler_policies: &std::collections::HashMap<String, OwnedPolicy>,
164) -> SubSpec {
165    check_no_legacy_positional_style(&toml.name, toml.positional_style);
166    let name = toml.name.clone();
167    let policy_ref = toml.policy.clone();
168    let eval_safe = toml.eval_safe.unwrap_or(false);
169    let eval_safe_flags = std::mem::take(&mut toml.eval_safe_flags);
170    let eval_safe_flag_values = std::mem::take(&mut toml.eval_safe_flag_values);
171    let eval_safe_required_flags = std::mem::take(&mut toml.eval_safe_required_flags);
172    let valued_for_check = toml.valued.clone();
173    assert_eval_safe_flags_require_tag(parent, &name, eval_safe, &eval_safe_flags);
174    assert_eval_safe_flag_values_consistent(parent, &name, &eval_safe_flags, &eval_safe_flag_values);
175    assert_eval_safe_valued_flags_declared(parent, &name, &eval_safe_flags, &valued_for_check, &eval_safe_flag_values);
176    assert_eval_safe_required_flags_consistent(parent, &name, &eval_safe_flags, &eval_safe_required_flags);
177    assert_sub_eval_safe_only_on_leaf(parent, &toml);
178    SubSpec {
179        name,
180        kind: build_sub_kind(parent, toml, handler_policies),
181        policy_ref,
182        eval_safe,
183        eval_safe_flags,
184        eval_safe_flag_values,
185        eval_safe_required_flags,
186    }
187}
188
189fn assert_eval_safe_flag_values_consistent(
190    parent: &str,
191    name: &str,
192    eval_safe_flags: &[String],
193    eval_safe_flag_values: &std::collections::HashMap<String, Vec<String>>,
194) {
195    for (flag, values) in eval_safe_flag_values {
196        if !eval_safe_flags.iter().any(|f| f == flag) {
197            panic!(
198                "command '{parent}' sub `{name}` lists `{flag}` in \
199                 `eval_safe_flag_values` but not in `eval_safe_flags`. \
200                 A value allowlist only takes effect when the flag itself \
201                 is allowed. Add `{flag}` to `eval_safe_flags` or remove \
202                 the value entry."
203            );
204        }
205        for value in values {
206            if value.is_empty() || !value.chars().all(is_bare_literal_char) {
207                panic!(
208                    "command '{parent}' sub `{name}` has eval_safe_flag_values \
209                     for `{flag}` containing value `{value:?}` with characters \
210                     outside `[a-zA-Z0-9_./=-]`. The allowed-value set must \
211                     itself be bare-literal so it can never embed a shell-\
212                     expansion trigger into the substituted invocation."
213                );
214            }
215        }
216    }
217}
218
219fn is_bare_literal_char(c: char) -> bool {
220    c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | '=')
221}
222
223fn assert_eval_safe_required_flags_consistent(
224    parent: &str,
225    name: &str,
226    eval_safe_flags: &[String],
227    eval_safe_required_flags: &[String],
228) {
229    for flag in eval_safe_required_flags {
230        if !eval_safe_flags.iter().any(|f| f == flag) {
231            panic!(
232                "command '{parent}' sub `{name}` lists `{flag}` in \
233                 `eval_safe_required_flags` but not in `eval_safe_flags`. \
234                 A required-flag constraint must be a subset of the \
235                 allowed-flag set — otherwise the flag is required AND \
236                 immediately denied. Add `{flag}` to `eval_safe_flags` or \
237                 remove it from `eval_safe_required_flags`."
238            );
239        }
240    }
241}
242
243/// Every valued flag in `eval_safe_flags` must declare its value
244/// posture in `eval_safe_flag_values`: either a concrete value
245/// allowlist (`["env", "fish"]`) OR the explicit-unrestricted form
246/// (`[]`). Without this, a contributor adding a short alias like
247/// `-f` for an already-tagged `--format` would silently widen the
248/// eval-safe surface to any value of `-f` — the v0.196.0 aws
249/// near-miss in disguise.
250fn assert_eval_safe_valued_flags_declared(
251    parent: &str,
252    name: &str,
253    eval_safe_flags: &[String],
254    valued: &[String],
255    eval_safe_flag_values: &std::collections::HashMap<String, Vec<String>>,
256) {
257    for flag in eval_safe_flags {
258        if !valued.iter().any(|v| v == flag) {
259            continue;
260        }
261        if !eval_safe_flag_values.contains_key(flag) {
262            panic!(
263                "command '{parent}' sub `{name}` lists `{flag}` in \
264                 `eval_safe_flags` AND in `valued`, but `{flag}` has no \
265                 entry in `eval_safe_flag_values`. Every valued flag \
266                 tagged eval-safe must declare its value posture: \
267                 either a concrete allowlist of safe values \
268                 (`{flag} = [\"value-a\", \"value-b\"]`) or the \
269                 explicit-unrestricted form (`{flag} = []`) signaling \
270                 the contributor vetted that any value preserves shell-\
271                 init output. Omitting an entry means the walker can't \
272                 tell whether the value-following-flag is supposed to \
273                 be checked, and a future short alias of `{flag}` \
274                 could silently widen the eval-safe surface."
275            );
276        }
277    }
278}
279
280fn assert_eval_safe_flags_require_tag(parent: &str, name: &str, eval_safe: bool, flags: &[String]) {
281    if !flags.is_empty() && !eval_safe {
282        panic!(
283            "command '{parent}' sub `{name}` declares `eval_safe_flags` without \
284             `eval_safe = true`. The flag allowlist only takes effect when the \
285             sub is tagged eval-safe. Add `eval_safe = true` or drop \
286             `eval_safe_flags`."
287        );
288    }
289}
290
291fn assert_sub_eval_safe_only_on_leaf(parent: &str, toml: &TomlSub) {
292    if toml.eval_safe != Some(true) {
293        return;
294    }
295    if !toml.sub.is_empty() {
296        panic!(
297            "command '{parent}' sub `{}` sets `eval_safe = true` AND has \
298             nested [[command.sub.sub]] blocks. eval_safe must be tagged on \
299             a leaf node — move the tag onto the specific sub-sub that emits \
300             shell-init code. Otherwise the walker accepts any unmatched \
301             sub-sub name as a positional, which is counter-intuitive.",
302            toml.name,
303        );
304    }
305    if toml.handler.is_some() {
306        panic!(
307            "command '{parent}' sub `{}` sets `eval_safe = true` AND \
308             `handler = \"...\"`. Handler-based subs run Rust dispatch logic \
309             whose shape the eval walker cannot introspect — eval-safety \
310             requires a declarative leaf the registry can reason about.",
311            toml.name,
312        );
313    }
314    if toml.delegate_after.is_some() || toml.delegate_skip.is_some() {
315        panic!(
316            "command '{parent}' sub `{}` sets `eval_safe = true` AND \
317             delegates to an inner command (delegate_after / delegate_skip). \
318             The inner command's output is unrelated to this sub's vetting \
319             — drop `eval_safe`.",
320            toml.name,
321        );
322    }
323}
324
325fn assert_eval_safe_tagged_command_has_researched_version(toml: &TomlCommand) {
326    let command_tagged = toml.eval_safe == Some(true);
327    let any_sub_tagged = toml_has_any_eval_safe_sub(&toml.sub);
328    if !command_tagged && !any_sub_tagged {
329        return;
330    }
331    if toml.researched_version.is_none() {
332        panic!(
333            "command '{}' has `eval_safe = true` (on the command or a sub) \
334             but no `researched_version`. eval-safe tags pin a per-tag trust \
335             claim against a specific upstream snapshot — add the version \
336             you researched (e.g. `researched_version = \"v2026.5.3\"`) so \
337             the next contributor knows what to diff against.",
338            toml.name,
339        );
340    }
341}
342
343fn toml_has_any_eval_safe_sub(subs: &[TomlSub]) -> bool {
344    subs.iter().any(|s| s.eval_safe == Some(true) || toml_has_any_eval_safe_sub(&s.sub))
345}
346
347fn assert_command_eval_safe_only_on_leaf(toml: &TomlCommand) {
348    if toml.eval_safe != Some(true) {
349        return;
350    }
351    if !toml.sub.is_empty() {
352        panic!(
353            "command '{}' sets `eval_safe = true` at the command level AND \
354             has [[command.sub]] blocks. Move the tag onto the specific \
355             sub that emits shell-init code (e.g. `mise activate`) — \
356             command-level tagging on a structured command is counter-\
357             intuitive.",
358            toml.name,
359        );
360    }
361    // Note: command-level eval_safe IS allowed alongside `handler =
362    // "..."`. The walker reads `spec.eval_safe` directly at the leaf
363    // — it does not need to introspect the handler's dispatch logic.
364    // The contributor takes responsibility for vouching that every
365    // invocation the handler accepts AND that passes the eval_safe_*
366    // flag checks produces shell-init stdout (typically narrowed via
367    // `eval_safe_required_flags`). See fzf's TOML for the canonical
368    // pattern.
369    if toml.wrapper.is_some() {
370        panic!(
371            "command '{}' sets `eval_safe = true` AND `[command.wrapper]`. \
372             Wrappers forward to an inner command — tagging the wrapper \
373             would tag every wrapped invocation. Drop `eval_safe`.",
374            toml.name,
375        );
376    }
377    if toml.deny.unwrap_or(false) {
378        panic!(
379            "command '{}' sets both `deny = true` and `eval_safe = true`. \
380             These are contradictory — deny silently dominates. Drop one.",
381            toml.name,
382        );
383    }
384}
385
386fn build_sub_kind(
387    parent: &str,
388    toml: TomlSub,
389    handler_policies: &std::collections::HashMap<String, OwnedPolicy>,
390) -> DispatchKind {
391    if let Some(handler_name) = toml.handler {
392        return DispatchKind::Custom {
393            handler_name,
394            doc_body: toml.doc_body,
395            subs: Vec::new(),
396            fallback: None,
397            handler_policies: std::collections::HashMap::new(),
398            matrices: Vec::new(),
399        };
400    }
401    if toml.allow_all.unwrap_or(false) {
402        return DispatchKind::Policy {
403            policy: allow_all_policy(),
404            level: toml.level.unwrap_or(TomlLevel::Inert).into(),
405        };
406    }
407    if let Some(sep) = toml.delegate_after {
408        return DispatchKind::DelegateAfterSeparator { separator: sep };
409    }
410    if let Some(skip) = toml.delegate_skip {
411        return DispatchKind::DelegateSkip { skip };
412    }
413    if !toml.sub.is_empty() {
414        return DispatchKind::Branching {
415            subs: filter_candidates(toml.sub)
416                .flat_map(|s| build_subs(parent, s, handler_policies))
417                .collect(),
418            bare_flags: Vec::new(),
419            bare_ok: toml.nested_bare.unwrap_or(false),
420            pre_standalone: toml.standalone,
421            pre_valued: toml.valued,
422            first_arg: Vec::new(),
423            first_arg_level: SafetyLevel::Inert,
424        };
425    }
426    build_policy_sub_kind(parent, toml, handler_policies)
427}
428
429fn build_policy_sub_kind(
430    parent: &str,
431    toml: TomlSub,
432    handler_policies: &std::collections::HashMap<String, OwnedPolicy>,
433) -> DispatchKind {
434    let policy = if let Some(key) = &toml.policy {
435        if !toml.standalone.is_empty() || !toml.valued.is_empty() {
436            panic!(
437                "command '{parent}' sub `{}` sets both `policy = \"{}\"` and \
438                 inline standalone/valued — pick one. Either drop the inline \
439                 lists (and rely on the referenced handler_policy) or drop \
440                 the `policy` field.",
441                toml.name, key,
442            );
443        }
444        handler_policies.get(key).cloned().unwrap_or_else(|| {
445            panic!(
446                "command '{parent}' sub `{}` references handler_policy \
447                 `{key}` which is not declared. Add a \
448                 [command.handler_policy.{key}] block or fix the typo.",
449                toml.name,
450            )
451        })
452    } else {
453        build_policy(
454            toml.standalone,
455            toml.valued,
456            toml.bare,
457            toml.max_positional,
458            toml.tolerate_unknown_short,
459            toml.tolerate_unknown_long,
460            toml.numeric_dash,
461        )
462    };
463    let level: SafetyLevel = toml.level.unwrap_or(TomlLevel::Inert).into();
464    if !toml.write_flags.is_empty() {
465        return DispatchKind::WriteFlagged {
466            policy,
467            base_level: level,
468            write_flags: toml.write_flags,
469        };
470    }
471    if let Some(guard) = toml.guard {
472        let mut require_any = vec![guard];
473        if let Some(short) = toml.guard_short {
474            require_any.push(short);
475        }
476        return DispatchKind::RequireAny {
477            require_any,
478            policy,
479            level,
480            accept_bare_help: true,
481        };
482    }
483    if !toml.first_arg.is_empty() {
484        return DispatchKind::FirstArg { patterns: toml.first_arg, level };
485    }
486    if !toml.require_any.is_empty() {
487        return DispatchKind::RequireAny {
488            require_any: toml.require_any,
489            policy,
490            level,
491            accept_bare_help: false,
492        };
493    }
494    DispatchKind::Policy { policy, level }
495}
496
497/// Diagnostic for a configuration class that silently breaks flag dispatch:
498/// a structured command (with `[[command.sub]]` blocks) cannot also use the
499/// flat-style top-level fields. When subs are present, top-level standalone/
500/// valued/max_positional/positional_style/numeric_dash are dropped — the
501/// dispatch routes through the Branching path. The fix is to either remove
502/// the subs (if the command is meant to be flat) or move global flags into
503/// a `[command.wrapper]` block.
504fn assert_flat_or_structured(toml: &TomlCommand) {
505    if toml.sub.is_empty() {
506        return;
507    }
508    let mut conflicts = Vec::new();
509    if !toml.standalone.is_empty() {
510        conflicts.push("standalone");
511    }
512    if !toml.valued.is_empty() {
513        conflicts.push("valued");
514    }
515    if toml.max_positional.is_some() {
516        conflicts.push("max_positional");
517    }
518    if toml.tolerate_unknown_short.is_some() {
519        conflicts.push("tolerate_unknown_short");
520    }
521    if toml.tolerate_unknown_long.is_some() {
522        conflicts.push("tolerate_unknown_long");
523    }
524    if toml.numeric_dash.is_some() {
525        conflicts.push("numeric_dash");
526    }
527    if !conflicts.is_empty() {
528        panic!(
529            "command '{}' mixes flat-style top-level fields ({}) with [[command.sub]] blocks. \
530             When subs are present these fields are silently dropped. \
531             Either drop the subs (if the command is flat) or move global \
532             flags into a [command.wrapper] block.",
533            toml.name,
534            conflicts.join(", "),
535        );
536    }
537}
538
539fn assert_matrix_policy_keys_exist(toml: &TomlCommand) {
540    if toml.matrix.is_empty() {
541        return;
542    }
543    for matrix in &toml.matrix {
544        for (action_name, action) in &matrix.actions {
545            let policy_key = match action {
546                TomlMatrixAction::Policy(k) => k,
547                TomlMatrixAction::Detailed(d) => &d.policy,
548            };
549            if !toml.handler_policy.contains_key(policy_key) {
550                panic!(
551                    "command '{}' matrix action `{}` references \
552                     handler_policy `{}` which is not declared. \
553                     Add a [command.handler_policy.{}] block or fix the typo.",
554                    toml.name, action_name, policy_key, policy_key,
555                );
556            }
557        }
558    }
559}
560
561fn assert_matrix_no_duplicate_parent_action(toml: &TomlCommand) {
562    if toml.matrix.len() < 2 {
563        return;
564    }
565    let mut seen: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();
566    for matrix in &toml.matrix {
567        for parent in &matrix.parents {
568            for action in matrix.actions.keys() {
569                let key = (parent.clone(), action.clone());
570                if !seen.insert(key) {
571                    panic!(
572                        "command '{}' matrix has duplicate (parent, action) pair \
573                         (`{}`, `{}`). The first match would silently win — \
574                         consolidate into one matrix block or remove the duplicate.",
575                        toml.name, parent, action,
576                    );
577                }
578            }
579        }
580    }
581}
582
583fn assert_fallback_requires_handler(toml: &TomlCommand) {
584    if toml.fallback.is_some() && toml.handler.is_none() {
585        panic!(
586            "command '{}' declares [command.fallback] without a handler. \
587             Fallback grammars are only consulted via \
588             registry::try_fallback_grammar() from a Rust handler — without \
589             handler = \"...\" the block is silently dropped. \
590             Either set handler or remove [command.fallback].",
591            toml.name,
592        );
593    }
594}
595
596#[allow(clippy::too_many_lines)]
597pub(super) fn build_command(toml: TomlCommand, category: &str) -> CommandSpec {
598    assert_flat_or_structured(&toml);
599    assert_fallback_requires_handler(&toml);
600    assert_matrix_policy_keys_exist(&toml);
601    assert_matrix_no_duplicate_parent_action(&toml);
602    assert_command_eval_safe_only_on_leaf(&toml);
603    assert_eval_safe_tagged_command_has_researched_version(&toml);
604    check_no_legacy_positional_style(&toml.name, toml.positional_style);
605    let cat = category.to_string();
606    let desc = toml.description.unwrap_or_default();
607    let researched_version = toml.researched_version;
608    let examples_safe = toml.examples_safe;
609    let examples_denied = toml.examples_denied;
610    let eval_safe = toml.eval_safe.unwrap_or(false);
611    let eval_safe_flags = toml.eval_safe_flags;
612    let eval_safe_flag_values = toml.eval_safe_flag_values;
613    let eval_safe_required_flags = toml.eval_safe_required_flags;
614    if !eval_safe_flags.is_empty() && !eval_safe {
615        panic!(
616            "command '{}' declares `eval_safe_flags` without `eval_safe = true`. \
617             The flag allowlist only takes effect when the command is tagged \
618             eval-safe. Add `eval_safe = true` or drop `eval_safe_flags`.",
619            toml.name,
620        );
621    }
622    assert_eval_safe_flag_values_consistent(
623        &toml.name,
624        "<command>",
625        &eval_safe_flags,
626        &eval_safe_flag_values,
627    );
628    assert_eval_safe_valued_flags_declared(
629        &toml.name,
630        "<command>",
631        &eval_safe_flags,
632        &toml.valued,
633        &eval_safe_flag_values,
634    );
635    assert_eval_safe_required_flags_consistent(
636        &toml.name,
637        "<command>",
638        &eval_safe_flags,
639        &eval_safe_required_flags,
640    );
641    if toml.deny.unwrap_or(false) {
642        return CommandSpec {
643            name: toml.name,
644            description: desc,
645            aliases: toml.aliases,
646            url: toml.url,
647            category: cat,
648            researched_version,
649            examples_safe,
650            examples_denied,
651            eval_safe,
652            eval_safe_flags: eval_safe_flags.clone(),
653            eval_safe_flag_values: eval_safe_flag_values.clone(),
654            eval_safe_required_flags: eval_safe_required_flags.clone(),
655            kind: DispatchKind::Policy {
656                policy: OwnedPolicy {
657                    standalone: Vec::new(),
658                    valued: Vec::new(),
659                    bare: false,
660                    max_positional: Some(0),
661                    tolerance: FlagTolerance::default(),
662                },
663                level: SafetyLevel::Inert,
664            },
665        };
666    }
667    if let Some(handler_name) = toml.handler {
668        // Build handler_policies first so subs that use `policy = "key"`
669        // can resolve the reference at build time.
670        let handler_policies: std::collections::HashMap<String, OwnedPolicy> = toml
671            .handler_policy
672            .into_iter()
673            .map(|(k, v)| (k, build_handler_policy(v)))
674            .collect();
675        let parent_name = toml.name.clone();
676        let subs = filter_candidates(toml.sub)
677            .flat_map(|s| build_subs(&parent_name, s, &handler_policies))
678            .collect();
679        let fallback = toml.fallback.map(|f| build_fallback(&toml.name, f));
680        let matrices = toml
681            .matrix
682            .into_iter()
683            .map(build_matrix)
684            .collect();
685        return CommandSpec {
686            name: toml.name,
687            description: desc,
688            aliases: toml.aliases,
689            url: toml.url,
690            category: cat,
691            researched_version,
692            examples_safe,
693            examples_denied,
694            eval_safe,
695            eval_safe_flags: eval_safe_flags.clone(),
696            eval_safe_flag_values: eval_safe_flag_values.clone(),
697            eval_safe_required_flags: eval_safe_required_flags.clone(),
698            kind: DispatchKind::Custom {
699                handler_name,
700                doc_body: toml.doc_body,
701                subs,
702                fallback,
703                handler_policies,
704                matrices,
705            },
706        };
707    }
708
709    if let Some(w) = toml.wrapper {
710        if !toml.sub.is_empty() || !toml.bare_flags.is_empty() {
711            let first_arg_level = toml.level.unwrap_or(TomlLevel::Inert).into();
712            let parent_name = toml.name.clone();
713            return CommandSpec {
714                name: toml.name,
715                description: desc,
716                aliases: toml.aliases,
717                url: toml.url,
718                category: cat,
719                researched_version,
720                examples_safe,
721                examples_denied,
722                eval_safe,
723                eval_safe_flags: eval_safe_flags.clone(),
724                eval_safe_flag_values: eval_safe_flag_values.clone(),
725                eval_safe_required_flags: eval_safe_required_flags.clone(),
726                kind: DispatchKind::Branching {
727                    bare_flags: toml.bare_flags,
728                    subs: filter_candidates(toml.sub)
729                        .flat_map(|s| build_subs(&parent_name, s, &std::collections::HashMap::new()))
730                        .collect(),
731                    pre_standalone: w.standalone,
732                    pre_valued: w.valued,
733                    bare_ok: toml.bare.unwrap_or(false),
734                    first_arg: toml.first_arg,
735                    first_arg_level,
736                },
737            };
738        }
739        return CommandSpec {
740            name: toml.name,
741            description: desc,
742            aliases: toml.aliases,
743            url: toml.url,
744            category: cat,
745            researched_version,
746            examples_safe,
747            examples_denied,
748            eval_safe,
749            eval_safe_flags: eval_safe_flags.clone(),
750            eval_safe_flag_values: eval_safe_flag_values.clone(),
751            eval_safe_required_flags: eval_safe_required_flags.clone(),
752            kind: DispatchKind::Wrapper {
753                standalone: w.standalone,
754                valued: w.valued,
755                positional_skip: w.positional_skip.unwrap_or(0),
756                separator: w.separator,
757                bare_ok: w.bare_ok.unwrap_or(false),
758            },
759        };
760    }
761
762    if !toml.sub.is_empty() || !toml.bare_flags.is_empty() {
763        let first_arg_level = toml.level.unwrap_or(TomlLevel::Inert).into();
764        let parent_name = toml.name.clone();
765        return CommandSpec {
766            name: toml.name,
767            description: desc,
768            aliases: toml.aliases,
769            url: toml.url,
770            category: cat,
771            researched_version,
772            examples_safe,
773            examples_denied,
774            eval_safe,
775            eval_safe_flags: eval_safe_flags.clone(),
776            eval_safe_flag_values: eval_safe_flag_values.clone(),
777            eval_safe_required_flags: eval_safe_required_flags.clone(),
778            kind: DispatchKind::Branching {
779                bare_flags: toml.bare_flags,
780                subs: filter_candidates(toml.sub)
781                    .flat_map(|s| build_subs(&parent_name, s, &std::collections::HashMap::new()))
782                    .collect(),
783                pre_standalone: Vec::new(),
784                pre_valued: Vec::new(),
785                bare_ok: toml.bare.unwrap_or(false),
786                first_arg: toml.first_arg,
787                first_arg_level,
788            },
789        };
790    }
791
792    let policy = build_policy(
793        toml.standalone,
794        toml.valued,
795        toml.bare,
796        toml.max_positional,
797        toml.tolerate_unknown_short,
798        toml.tolerate_unknown_long,
799        toml.numeric_dash,
800    );
801
802    let level = toml.level.unwrap_or(TomlLevel::Inert).into();
803
804    if !toml.first_arg.is_empty() {
805        return CommandSpec {
806            name: toml.name,
807            description: desc,
808            aliases: toml.aliases,
809            url: toml.url,
810            category: cat,
811            researched_version,
812            examples_safe,
813            examples_denied,
814            eval_safe,
815            eval_safe_flags: eval_safe_flags.clone(),
816            eval_safe_flag_values: eval_safe_flag_values.clone(),
817            eval_safe_required_flags: eval_safe_required_flags.clone(),
818            kind: DispatchKind::FirstArg {
819                patterns: toml.first_arg,
820                level,
821            },
822        };
823    }
824
825    if !toml.write_flags.is_empty() {
826        return CommandSpec {
827            name: toml.name,
828            description: desc,
829            aliases: toml.aliases,
830            url: toml.url,
831            category: cat,
832            researched_version,
833            examples_safe,
834            examples_denied,
835            eval_safe,
836            eval_safe_flags: eval_safe_flags.clone(),
837            eval_safe_flag_values: eval_safe_flag_values.clone(),
838            eval_safe_required_flags: eval_safe_required_flags.clone(),
839            kind: DispatchKind::WriteFlagged {
840                policy,
841                base_level: level,
842                write_flags: toml.write_flags,
843            },
844        };
845    }
846
847    if !toml.require_any.is_empty() {
848        return CommandSpec {
849            name: toml.name,
850            description: desc,
851            aliases: toml.aliases,
852            url: toml.url,
853            category: cat,
854            researched_version,
855            examples_safe,
856            examples_denied,
857            eval_safe,
858            eval_safe_flags: eval_safe_flags.clone(),
859            eval_safe_flag_values: eval_safe_flag_values.clone(),
860            eval_safe_required_flags: eval_safe_required_flags.clone(),
861            kind: DispatchKind::RequireAny {
862                require_any: toml.require_any,
863                policy,
864                level,
865                accept_bare_help: false,
866            },
867        };
868    }
869
870    CommandSpec {
871        name: toml.name,
872        description: desc,
873        aliases: toml.aliases,
874        url: toml.url,
875        category: cat,
876        researched_version,
877        examples_safe,
878        examples_denied,
879        eval_safe,
880        eval_safe_flags,
881        eval_safe_flag_values,
882        eval_safe_required_flags,
883        kind: DispatchKind::Policy {
884            policy,
885            level,
886        },
887    }
888}
889
890pub fn load_toml(source: &str, category: &str) -> Vec<CommandSpec> {
891    let file: TomlFile = match toml::from_str(source) {
892        Ok(f) => f,
893        Err(e) => {
894            let preview: String = source.chars().take(80).collect();
895            panic!("invalid TOML command definition: {e}\n  source begins: {preview}");
896        }
897    };
898    file.command.into_iter()
899        .filter(|cmd| !cmd.candidate.unwrap_or(false))
900        .map(|cmd| build_command(cmd, category))
901        .collect()
902}
903
904pub fn build_registry(specs: Vec<CommandSpec>) -> HashMap<String, CommandSpec> {
905    let mut map = HashMap::new();
906    for spec in specs {
907        insert_spec(&mut map, spec);
908    }
909    map
910}
911
912/// Insert a CommandSpec into the registry, registering both its canonical
913/// name and each alias. Existing entries for the same command name are
914/// removed first, so a custom-TOML override of `gh` replaces every
915/// built-in alias of `gh` rather than leaving stale aliases pointing at
916/// the old spec.
917pub fn insert_spec(map: &mut HashMap<String, CommandSpec>, spec: CommandSpec) {
918    map.retain(|_, s| s.name != spec.name);
919    for alias in &spec.aliases {
920        map.insert(alias.clone(), CommandSpec {
921            name: spec.name.clone(),
922            description: spec.description.clone(),
923            aliases: vec![],
924            url: spec.url.clone(),
925            category: spec.category.clone(),
926            researched_version: spec.researched_version.clone(),
927            examples_safe: vec![],
928            examples_denied: vec![],
929            eval_safe: spec.eval_safe,
930            eval_safe_flags: spec.eval_safe_flags.clone(),
931            eval_safe_flag_values: spec.eval_safe_flag_values.clone(),
932            eval_safe_required_flags: spec.eval_safe_required_flags.clone(),
933            kind: spec.kind.clone(),
934        });
935    }
936    map.insert(spec.name.clone(), spec);
937}