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_verb_chain(toml: TomlVerbChain) -> VerbChainSpec {
80 VerbChainSpec {
81 level: toml.level.unwrap_or(TomlLevel::Inert).into(),
82 separator: toml.separator.unwrap_or_else(|| "then".to_string()),
83 main_standalone: toml.main_standalone,
84 main_valued: toml.main_valued,
85 main_variadic: toml.main_variadic,
86 verbs: toml.verbs.into_iter().collect(),
87 }
88}
89
90fn build_fallback(parent: &str, toml: TomlFallback) -> FallbackSpec {
91 let policy = build_policy(
92 toml.standalone,
93 toml.valued,
94 toml.bare,
95 toml.max_positional,
96 toml.tolerate_unknown_short,
97 toml.tolerate_unknown_long,
98 toml.numeric_dash,
99 );
100 let level: SafetyLevel = toml.level.unwrap_or(TomlLevel::Inert).into();
101 let positional_shape = toml.positional_shape.as_deref().map(|name| {
102 crate::policy::PositionalShape::from_name(name).unwrap_or_else(|| {
103 panic!(
104 "{}: unknown fallback positional_shape `{}` (known: path)",
105 parent, name
106 )
107 })
108 });
109 let executor = toml.executor.as_deref().map(|name| {
110 ExecutorKind::from_name(name)
111 .unwrap_or_else(|| panic!("{parent}: unknown fallback executor `{name}` (known: file, project)"))
112 });
113 FallbackSpec {
114 policy,
115 level,
116 positional_shape,
117 executor,
118 executor_redirect_flag: toml.executor_redirect_flag,
119 }
120}
121
122fn allow_all_policy() -> OwnedPolicy {
123 OwnedPolicy {
124 standalone: Vec::new(),
125 valued: Vec::new(),
126 bare: true,
127 max_positional: None,
128 tolerance: FlagTolerance { unknown: UnknownTolerance::Both, numeric_dash: false },
129 }
130}
131
132fn check_no_legacy_positional_style(name: &str, ps: Option<bool>) {
133 if ps.is_some() {
134 panic!(
135 "command '{name}': `positional_style` was removed. Use \
136 `tolerate_unknown_short = true` for tools with single-dash \
137 flags (pdftotext -help, sample -mayDie). Use \
138 `tolerate_unknown_long = true` ONLY for tools whose \
139 double-dash flag surface is genuinely unbounded (AWS CLI \
140 style); double-dash unknowns silently pass when this is \
141 on, which has caused safety bugs. Most tools need neither."
142 );
143 }
144}
145
146fn filter_candidates(subs: Vec<TomlSub>) -> impl Iterator<Item = TomlSub> {
147 subs.into_iter().filter(|s| !s.candidate.unwrap_or(false))
148}
149
150fn first_arg_matches(name: &str, patterns: &[String]) -> bool {
152 patterns.iter().any(|p| match p.strip_suffix('*') {
153 Some(prefix) => name.starts_with(prefix),
154 None => p == name,
155 })
156}
157
158fn assert_no_candidate_shadowed_by_glob(parent: &str, subs: &[TomlSub], first_arg: &[String]) {
165 if first_arg.is_empty() {
166 return;
167 }
168 for s in subs {
169 if s.candidate.unwrap_or(false) && first_arg_matches(&s.name, first_arg) {
170 panic!(
171 "'{parent}' sub `{}` is `candidate = true` but its name is MATCHED by the sibling \
172 first_arg glob {first_arg:?} — it would fall through the filter and AUTO-APPROVE \
173 (a silent deny→allow inversion). Use `profile`/an explicit sub-sub to deny it, or \
174 drop it from the glob.",
175 s.name,
176 );
177 }
178 }
179}
180
181pub(super) fn build_subs(
186 parent: &str,
187 toml: TomlSub,
188 handler_policies: &std::collections::HashMap<String, OwnedPolicy>,
189) -> Vec<SubSpec> {
190 let aliases = toml.aliases.clone();
191 let canonical = build_sub(parent, toml, handler_policies);
192 let mut out = Vec::with_capacity(1 + aliases.len());
193 for alias in aliases {
194 out.push(SubSpec {
195 name: alias,
196 kind: canonical.kind.clone(),
197 policy_ref: canonical.policy_ref.clone(),
198 profile: canonical.profile.clone(),
199 flags: canonical.flags.clone(),
200 eval_safe: canonical.eval_safe,
201 eval_safe_flags: canonical.eval_safe_flags.clone(),
202 eval_safe_flag_values: canonical.eval_safe_flag_values.clone(),
203 eval_safe_required_flags: canonical.eval_safe_required_flags.clone(),
204 network_destination: canonical.network_destination,
205 destination_flag: canonical.destination_flag.clone(),
206 output_path_flags: canonical.output_path_flags.clone(),
207 });
208 }
209 out.push(canonical);
210 out
211}
212
213fn assert_sub_provenance(parent: &str, toml: &TomlSub) {
220 let cited = |o: &Option<String>| o.as_deref().is_some_and(|s| !s.trim().is_empty());
221 let judged = |o: &Option<String>| o.as_deref().is_none_or(|s| !s.trim().is_empty());
223 if let Some(p) = &toml.profile {
224 assert!(
225 crate::engine::archetype::archetype(p).is_some(),
226 "{parent} sub `{}`: profile `{p}` is not a known archetype (archetypes.toml)",
227 toml.name,
228 );
229 assert!(cited(&toml.fact), "{parent} sub `{}`: `profile` requires a `fact`", toml.name);
230 assert!(cited(&toml.source), "{parent} sub `{}`: `profile` requires a `source`", toml.name);
231 assert!(judged(&toml.judgment), "{parent} sub `{}`: `judgment`, if given, must not be blank", toml.name);
232 assert!(toml.sub.is_empty(), "{parent} sub `{}`: a profiled sub must be a leaf (no nested subs)", toml.name);
235 }
236 assert!(
239 toml.network_destination != Some(true) || toml.profile.is_some(),
240 "{parent} sub `{}`: `network_destination` requires a `profile`",
241 toml.name,
242 );
243 assert!(
244 toml.destination_flag.is_none() || toml.network_destination == Some(true),
245 "{parent} sub `{}`: `destination_flag` requires `network_destination`",
246 toml.name,
247 );
248 assert!(
251 toml.output_path_flags.is_empty() || toml.profile.is_some(),
252 "{parent} sub `{}`: `output_path_flags` requires a `profile`",
253 toml.name,
254 );
255 for f in &toml.flag {
256 assert!(
257 f.classifies == "unclassified" || crate::engine::archetype::archetype(&f.classifies).is_some(),
258 "{parent} sub `{}` flag `{}`: classifies `{}` is not a known archetype",
259 toml.name, f.name, f.classifies,
260 );
261 assert!(cited(&f.fact), "{parent} sub `{}` flag `{}`: requires a `fact`", toml.name, f.name);
262 assert!(cited(&f.source), "{parent} sub `{}` flag `{}`: requires a `source`", toml.name, f.name);
263 assert!(judged(&f.judgment), "{parent} sub `{}` flag `{}`: `judgment`, if given, must not be blank", toml.name, f.name);
264 assert!(
265 !(f.when_absent == Some(true) && f.value_prefix.is_some()),
266 "{parent} sub `{}` flag `{}`: `when_absent` and `value_prefix` are mutually exclusive",
267 toml.name, f.name,
268 );
269 }
270}
271
272pub(super) fn build_sub(
273 parent: &str,
274 mut toml: TomlSub,
275 handler_policies: &std::collections::HashMap<String, OwnedPolicy>,
276) -> SubSpec {
277 check_no_legacy_positional_style(&toml.name, toml.positional_style);
278 let name = toml.name.clone();
279 let policy_ref = toml.policy.clone();
280 let profile = toml.profile.clone();
281 assert_sub_provenance(parent, &toml);
282 assert_no_candidate_shadowed_by_glob(&format!("{parent} {}", toml.name), &toml.sub, &toml.first_arg);
283 let flags = std::mem::take(&mut toml.flag)
284 .into_iter()
285 .map(|f| crate::registry::types::FlagProvenance {
286 name: f.name,
287 classifies: f.classifies,
288 value_prefix: f.value_prefix,
289 when_absent: f.when_absent.unwrap_or(false),
290 })
291 .collect();
292 if profile.is_some() {
298 toml.bare = Some(false);
299 toml.standalone = Vec::new();
300 toml.valued = Vec::new();
301 toml.max_positional = Some(0);
302 }
303 let eval_safe = toml.eval_safe.unwrap_or(false);
304 let eval_safe_flags = std::mem::take(&mut toml.eval_safe_flags);
305 let eval_safe_flag_values = std::mem::take(&mut toml.eval_safe_flag_values);
306 let eval_safe_required_flags = std::mem::take(&mut toml.eval_safe_required_flags);
307 let network_destination = toml.network_destination.unwrap_or(false);
308 let destination_flag = toml.destination_flag.clone();
309 let output_path_flags = toml.output_path_flags.clone();
310 let valued_for_check = toml.valued.clone();
311 assert_eval_safe_flags_require_tag(parent, &name, eval_safe, &eval_safe_flags);
312 assert_eval_safe_flag_values_consistent(parent, &name, &eval_safe_flags, &eval_safe_flag_values);
313 assert_eval_safe_valued_flags_declared(parent, &name, &eval_safe_flags, &valued_for_check, &eval_safe_flag_values);
314 assert_eval_safe_required_flags_consistent(parent, &name, &eval_safe_flags, &eval_safe_required_flags);
315 assert_sub_eval_safe_only_on_leaf(parent, &toml);
316 SubSpec {
317 name,
318 kind: build_sub_kind(parent, toml, handler_policies),
319 policy_ref,
320 profile,
321 flags,
322 eval_safe,
323 eval_safe_flags,
324 eval_safe_flag_values,
325 eval_safe_required_flags,
326 network_destination,
327 destination_flag,
328 output_path_flags,
329 }
330}
331
332fn assert_eval_safe_flag_values_consistent(
333 parent: &str,
334 name: &str,
335 eval_safe_flags: &[String],
336 eval_safe_flag_values: &std::collections::HashMap<String, Vec<String>>,
337) {
338 for (flag, values) in eval_safe_flag_values {
339 if !eval_safe_flags.iter().any(|f| f == flag) {
340 panic!(
341 "command '{parent}' sub `{name}` lists `{flag}` in \
342 `eval_safe_flag_values` but not in `eval_safe_flags`. \
343 A value allowlist only takes effect when the flag itself \
344 is allowed. Add `{flag}` to `eval_safe_flags` or remove \
345 the value entry."
346 );
347 }
348 for value in values {
349 if value.is_empty() || !value.chars().all(is_bare_literal_char) {
350 panic!(
351 "command '{parent}' sub `{name}` has eval_safe_flag_values \
352 for `{flag}` containing value `{value:?}` with characters \
353 outside `[a-zA-Z0-9_./=-]`. The allowed-value set must \
354 itself be bare-literal so it can never embed a shell-\
355 expansion trigger into the substituted invocation."
356 );
357 }
358 }
359 }
360}
361
362fn is_bare_literal_char(c: char) -> bool {
363 c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | '=')
364}
365
366fn assert_eval_safe_required_flags_consistent(
367 parent: &str,
368 name: &str,
369 eval_safe_flags: &[String],
370 eval_safe_required_flags: &[String],
371) {
372 for flag in eval_safe_required_flags {
373 if !eval_safe_flags.iter().any(|f| f == flag) {
374 panic!(
375 "command '{parent}' sub `{name}` lists `{flag}` in \
376 `eval_safe_required_flags` but not in `eval_safe_flags`. \
377 A required-flag constraint must be a subset of the \
378 allowed-flag set — otherwise the flag is required AND \
379 immediately denied. Add `{flag}` to `eval_safe_flags` or \
380 remove it from `eval_safe_required_flags`."
381 );
382 }
383 }
384}
385
386fn assert_eval_safe_valued_flags_declared(
394 parent: &str,
395 name: &str,
396 eval_safe_flags: &[String],
397 valued: &[String],
398 eval_safe_flag_values: &std::collections::HashMap<String, Vec<String>>,
399) {
400 for flag in eval_safe_flags {
401 if !valued.iter().any(|v| v == flag) {
402 continue;
403 }
404 if !eval_safe_flag_values.contains_key(flag) {
405 panic!(
406 "command '{parent}' sub `{name}` lists `{flag}` in \
407 `eval_safe_flags` AND in `valued`, but `{flag}` has no \
408 entry in `eval_safe_flag_values`. Every valued flag \
409 tagged eval-safe must declare its value posture: \
410 either a concrete allowlist of safe values \
411 (`{flag} = [\"value-a\", \"value-b\"]`) or the \
412 explicit-unrestricted form (`{flag} = []`) signaling \
413 the contributor vetted that any value preserves shell-\
414 init output. Omitting an entry means the walker can't \
415 tell whether the value-following-flag is supposed to \
416 be checked, and a future short alias of `{flag}` \
417 could silently widen the eval-safe surface."
418 );
419 }
420 }
421}
422
423fn assert_eval_safe_flags_require_tag(parent: &str, name: &str, eval_safe: bool, flags: &[String]) {
424 if !flags.is_empty() && !eval_safe {
425 panic!(
426 "command '{parent}' sub `{name}` declares `eval_safe_flags` without \
427 `eval_safe = true`. The flag allowlist only takes effect when the \
428 sub is tagged eval-safe. Add `eval_safe = true` or drop \
429 `eval_safe_flags`."
430 );
431 }
432}
433
434fn assert_sub_eval_safe_only_on_leaf(parent: &str, toml: &TomlSub) {
435 if toml.eval_safe != Some(true) {
436 return;
437 }
438 if !toml.sub.is_empty() {
439 panic!(
440 "command '{parent}' sub `{}` sets `eval_safe = true` AND has \
441 nested [[command.sub.sub]] blocks. eval_safe must be tagged on \
442 a leaf node — move the tag onto the specific sub-sub that emits \
443 shell-init code. Otherwise the walker accepts any unmatched \
444 sub-sub name as a positional, which is counter-intuitive.",
445 toml.name,
446 );
447 }
448 if toml.handler.is_some() {
449 panic!(
450 "command '{parent}' sub `{}` sets `eval_safe = true` AND \
451 `handler = \"...\"`. Handler-based subs run Rust dispatch logic \
452 whose shape the eval walker cannot introspect — eval-safety \
453 requires a declarative leaf the registry can reason about.",
454 toml.name,
455 );
456 }
457 if toml.delegate_after.is_some() || toml.delegate_skip.is_some() {
458 panic!(
459 "command '{parent}' sub `{}` sets `eval_safe = true` AND \
460 delegates to an inner command (delegate_after / delegate_skip). \
461 The inner command's output is unrelated to this sub's vetting \
462 — drop `eval_safe`.",
463 toml.name,
464 );
465 }
466}
467
468fn assert_eval_safe_tagged_command_has_researched_version(toml: &TomlCommand) {
469 let command_tagged = toml.eval_safe == Some(true);
470 let any_sub_tagged = toml_has_any_eval_safe_sub(&toml.sub);
471 if !command_tagged && !any_sub_tagged {
472 return;
473 }
474 if toml.researched_version.is_none() {
475 panic!(
476 "command '{}' has `eval_safe = true` (on the command or a sub) \
477 but no `researched_version`. eval-safe tags pin a per-tag trust \
478 claim against a specific upstream snapshot — add the version \
479 you researched (e.g. `researched_version = \"v2026.5.3\"`) so \
480 the next contributor knows what to diff against.",
481 toml.name,
482 );
483 }
484}
485
486fn toml_has_any_eval_safe_sub(subs: &[TomlSub]) -> bool {
487 subs.iter().any(|s| s.eval_safe == Some(true) || toml_has_any_eval_safe_sub(&s.sub))
488}
489
490fn assert_command_eval_safe_only_on_leaf(toml: &TomlCommand) {
491 if toml.eval_safe != Some(true) {
492 return;
493 }
494 if !toml.sub.is_empty() {
495 panic!(
496 "command '{}' sets `eval_safe = true` at the command level AND \
497 has [[command.sub]] blocks. Move the tag onto the specific \
498 sub that emits shell-init code (e.g. `mise activate`) — \
499 command-level tagging on a structured command is counter-\
500 intuitive.",
501 toml.name,
502 );
503 }
504 if toml.wrapper.is_some() {
513 panic!(
514 "command '{}' sets `eval_safe = true` AND `[command.wrapper]`. \
515 Wrappers forward to an inner command — tagging the wrapper \
516 would tag every wrapped invocation. Drop `eval_safe`.",
517 toml.name,
518 );
519 }
520 if toml.deny.unwrap_or(false) {
521 panic!(
522 "command '{}' sets both `deny = true` and `eval_safe = true`. \
523 These are contradictory — deny silently dominates. Drop one.",
524 toml.name,
525 );
526 }
527}
528
529fn build_sub_kind(
530 parent: &str,
531 toml: TomlSub,
532 handler_policies: &std::collections::HashMap<String, OwnedPolicy>,
533) -> DispatchKind {
534 if let Some(handler_name) = toml.handler {
535 return DispatchKind::Custom {
536 handler_name,
537 doc_body: toml.doc_body,
538 subs: Vec::new(),
539 fallback: None,
540 handler_policies: std::collections::HashMap::new(),
541 matrices: Vec::new(),
542 };
543 }
544 if toml.allow_all.unwrap_or(false) {
545 return DispatchKind::Policy {
546 policy: allow_all_policy(),
547 level: toml.level.unwrap_or(TomlLevel::Inert).into(),
548 };
549 }
550 if let Some(sep) = toml.delegate_after {
551 return DispatchKind::DelegateAfterSeparator { separator: sep };
552 }
553 if let Some(skip) = toml.delegate_skip {
554 return DispatchKind::DelegateSkip { skip };
555 }
556 if !toml.sub.is_empty() || !toml.credential_first_arg.is_empty() {
560 let first_arg_level = toml.level.unwrap_or(TomlLevel::Inert).into();
567 return DispatchKind::Branching {
568 subs: filter_candidates(toml.sub)
569 .flat_map(|s| build_subs(parent, s, handler_policies))
570 .collect(),
571 bare_flags: Vec::new(),
572 bare_ok: toml.nested_bare.unwrap_or(false),
573 pre_standalone: toml.standalone,
574 pre_valued: toml.valued,
575 first_arg: toml.first_arg,
576 first_arg_level,
577 credential_first_arg: toml.credential_first_arg,
578 };
579 }
580 build_policy_sub_kind(parent, toml, handler_policies)
581}
582
583fn build_policy_sub_kind(
584 parent: &str,
585 toml: TomlSub,
586 handler_policies: &std::collections::HashMap<String, OwnedPolicy>,
587) -> DispatchKind {
588 let policy = if let Some(key) = &toml.policy {
589 if !toml.standalone.is_empty() || !toml.valued.is_empty() {
590 panic!(
591 "command '{parent}' sub `{}` sets both `policy = \"{}\"` and \
592 inline standalone/valued — pick one. Either drop the inline \
593 lists (and rely on the referenced handler_policy) or drop \
594 the `policy` field.",
595 toml.name, key,
596 );
597 }
598 handler_policies.get(key).cloned().unwrap_or_else(|| {
599 panic!(
600 "command '{parent}' sub `{}` references handler_policy \
601 `{key}` which is not declared. Add a \
602 [command.handler_policy.{key}] block or fix the typo.",
603 toml.name,
604 )
605 })
606 } else {
607 build_policy(
608 toml.standalone,
609 toml.valued,
610 toml.bare,
611 toml.max_positional,
612 toml.tolerate_unknown_short,
613 toml.tolerate_unknown_long,
614 toml.numeric_dash,
615 )
616 };
617 let level: SafetyLevel = toml.level.unwrap_or(TomlLevel::Inert).into();
618 if let Some(name) = toml.executor.as_deref() {
619 let kind = ExecutorKind::from_name(name).unwrap_or_else(|| {
620 panic!("command '{parent}' sub `{}`: unknown executor `{name}` (known: file, project)", toml.name)
621 });
622 let shape = toml.positional_shape.as_deref().map(|s| {
623 crate::policy::PositionalShape::from_name(s)
624 .unwrap_or_else(|| panic!("command '{parent}' sub `{}`: unknown positional_shape `{s}`", toml.name))
625 });
626 return DispatchKind::Executor {
627 policy,
628 level,
629 kind,
630 redirect_flag: toml.executor_redirect_flag,
631 shape,
632 };
633 }
634 if !toml.write_flags.is_empty() {
635 return DispatchKind::WriteFlagged {
636 policy,
637 base_level: level,
638 write_flags: toml.write_flags,
639 };
640 }
641 if let Some(guard) = toml.guard {
642 let mut require_any = vec![guard];
643 if let Some(short) = toml.guard_short {
644 require_any.push(short);
645 }
646 return DispatchKind::RequireAny {
647 require_any,
648 policy,
649 level,
650 accept_bare_help: true,
651 };
652 }
653 if !toml.first_arg.is_empty() {
654 return DispatchKind::FirstArg { patterns: toml.first_arg, level };
655 }
656 if !toml.require_any.is_empty() {
657 return DispatchKind::RequireAny {
658 require_any: toml.require_any,
659 policy,
660 level,
661 accept_bare_help: false,
662 };
663 }
664 DispatchKind::Policy { policy, level }
665}
666
667fn assert_flat_or_structured(toml: &TomlCommand) {
675 if toml.sub.is_empty() {
676 return;
677 }
678 let mut conflicts = Vec::new();
679 if !toml.standalone.is_empty() {
680 conflicts.push("standalone");
681 }
682 if !toml.valued.is_empty() {
683 conflicts.push("valued");
684 }
685 if toml.max_positional.is_some() {
686 conflicts.push("max_positional");
687 }
688 if toml.tolerate_unknown_short.is_some() {
689 conflicts.push("tolerate_unknown_short");
690 }
691 if toml.tolerate_unknown_long.is_some() {
692 conflicts.push("tolerate_unknown_long");
693 }
694 if toml.numeric_dash.is_some() {
695 conflicts.push("numeric_dash");
696 }
697 if !conflicts.is_empty() {
698 panic!(
699 "command '{}' mixes flat-style top-level fields ({}) with [[command.sub]] blocks. \
700 When subs are present these fields are silently dropped. \
701 Either drop the subs (if the command is flat) or move global \
702 flags into a [command.wrapper] block.",
703 toml.name,
704 conflicts.join(", "),
705 );
706 }
707}
708
709fn assert_matrix_policy_keys_exist(toml: &TomlCommand) {
710 if toml.matrix.is_empty() {
711 return;
712 }
713 for matrix in &toml.matrix {
714 for (action_name, action) in &matrix.actions {
715 let policy_key = match action {
716 TomlMatrixAction::Policy(k) => k,
717 TomlMatrixAction::Detailed(d) => &d.policy,
718 };
719 if !toml.handler_policy.contains_key(policy_key) {
720 panic!(
721 "command '{}' matrix action `{}` references \
722 handler_policy `{}` which is not declared. \
723 Add a [command.handler_policy.{}] block or fix the typo.",
724 toml.name, action_name, policy_key, policy_key,
725 );
726 }
727 }
728 }
729}
730
731fn assert_matrix_no_duplicate_parent_action(toml: &TomlCommand) {
732 if toml.matrix.len() < 2 {
733 return;
734 }
735 let mut seen: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();
736 for matrix in &toml.matrix {
737 for parent in &matrix.parents {
738 for action in matrix.actions.keys() {
739 let key = (parent.clone(), action.clone());
740 if !seen.insert(key) {
741 panic!(
742 "command '{}' matrix has duplicate (parent, action) pair \
743 (`{}`, `{}`). The first match would silently win — \
744 consolidate into one matrix block or remove the duplicate.",
745 toml.name, parent, action,
746 );
747 }
748 }
749 }
750 }
751}
752
753fn assert_fallback_requires_handler(toml: &TomlCommand) {
754 if toml.fallback.is_some() && toml.handler.is_none() {
755 panic!(
756 "command '{}' declares [command.fallback] without a handler. \
757 Fallback grammars are only consulted via \
758 registry::try_fallback_grammar() from a Rust handler — without \
759 handler = \"...\" the block is silently dropped. \
760 Either set handler or remove [command.fallback].",
761 toml.name,
762 );
763 }
764}
765
766fn lower_behavior(name: &str, b: Option<&TomlBehavior>) -> Option<BehaviorSpec> {
771 use crate::engine::facet::{FacetTerm, Operation};
772 let b = b?;
773 let operation = Operation::from_term(&b.operation)
774 .unwrap_or_else(|| panic!("command '{name}': unknown behavior operation `{}`", b.operation));
775 let positionals = match b.positionals.as_str() {
776 "none" => PositionalRole::None,
777 "read" => PositionalRole::Read,
778 "write" => PositionalRole::Write,
779 "pattern-then-read" => PositionalRole::PatternThenRead,
780 "transfer" => PositionalRole::Transfer,
781 other => panic!("command '{name}': unknown behavior positionals `{other}` (known: none, read, write, pattern-then-read, transfer)"),
782 };
783 let scale = match b.scale.as_deref() {
784 None | Some("single") => ScaleModel::Single,
785 Some("breadth") => ScaleModel::Breadth,
786 Some(other) => panic!("command '{name}': unknown behavior scale `{other}` (known: single, breadth)"),
787 };
788 let hook = match b.hook.as_deref() {
789 None => None,
790 Some("grep") => Some(BehaviorHook::Grep),
791 Some("dd") => Some(BehaviorHook::Dd),
792 Some("tar") => Some(BehaviorHook::Tar),
793 Some("sed") => Some(BehaviorHook::Sed),
794 Some(other) => panic!("command '{name}': unknown behavior hook `{other}` (known: grep, dd, tar, sed)"),
795 };
796 let (short, long) = split_flag_forms(&b.standalone);
797 let (valued_short, valued_long) = split_flag_forms(&b.valued);
798 let mut unbounded_flags = Vec::new();
799 let mut path_flags = Vec::new();
800 for (flag, delta) in &b.flags {
801 if delta.scale.as_deref() == Some("unbounded") {
802 unbounded_flags.push(flag.clone());
803 } else if let Some(other) = delta.scale.as_deref() {
804 panic!("command '{name}': behavior flag `{flag}` has unknown scale `{other}` (known: unbounded)");
805 }
806 if let Some(kind) = delta.kind.as_deref() {
807 let role = match kind {
808 "read" => PathRole::Read,
809 "write" => PathRole::Write,
810 other => panic!("command '{name}': behavior flag `{flag}` has unknown kind `{other}` (known: read, write)"),
811 };
812 if !b.valued.contains(flag) {
813 panic!("command '{name}': behavior path-flag `{flag}` (kind = {kind}) must also be listed in `valued`");
814 }
815 let (short, long) = if let Some(rest) = flag.strip_prefix("--") {
816 (None, Some(format!("--{rest}")))
817 } else if let Some(rest) = flag.strip_prefix('-') {
818 if rest.len() == 1 {
819 (Some(rest.as_bytes()[0]), None)
820 } else {
821 panic!("command '{name}': behavior path-flag `{flag}` must be a single-char short or a `--long`");
822 }
823 } else {
824 panic!("command '{name}': behavior path-flag `{flag}` must start with `-`");
825 };
826 path_flags.push(PathFlag { short, long, role });
827 }
828 }
829 let transfer = lower_transfer(name, b.transfer.as_ref());
830 match (positionals, &transfer) {
832 (PositionalRole::Transfer, None) => {
833 panic!("command '{name}': positionals = \"transfer\" requires a [command.behavior.transfer] block")
834 }
835 (role, Some(_)) if role != PositionalRole::Transfer => {
836 panic!("command '{name}': [command.behavior.transfer] is only valid with positionals = \"transfer\"")
837 }
838 _ => {}
839 }
840 Some(BehaviorSpec {
841 operation,
842 positionals,
843 scale,
844 short,
845 valued_short,
846 long,
847 valued_long,
848 numeric_shorthand: b.numeric_shorthand.unwrap_or(false),
849 unbounded_flags,
850 path_flags,
851 hook,
852 transfer,
853 })
854}
855
856fn lower_transfer(name: &str, t: Option<&TomlTransfer>) -> Option<TransferSpec> {
860 let t = t?;
861 let source = match t.source.as_str() {
862 "observe" => TransferSource::Observe,
863 "relocate" => TransferSource::Relocate,
864 other => panic!("command '{name}': unknown transfer source `{other}` (known: observe, relocate)"),
865 };
866 if !t.no_clobber_flags.is_empty() && !t.clobber_flags.is_empty() {
867 panic!("command '{name}': transfer declares both no_clobber_flags and clobber_flags (mutually exclusive)");
868 }
869 Some(TransferSpec {
870 source,
871 no_clobber_flags: t.no_clobber_flags.clone(),
872 clobber_flags: t.clobber_flags.clone(),
873 recursive_flags: t.recursive_flags.clone(),
874 })
875}
876
877fn split_flag_forms(tokens: &[String]) -> (Vec<u8>, Vec<String>) {
881 let mut short = Vec::new();
882 let mut long = Vec::new();
883 for t in tokens {
884 if t.starts_with("--") {
885 long.push(t.clone());
886 } else if let Some(rest) = t.strip_prefix('-') {
887 if rest.len() == 1 {
888 short.push(rest.as_bytes()[0]);
889 } else {
890 long.push(t.clone());
891 }
892 }
893 }
894 (short, long)
895}
896
897fn build_command_archetype_flags(
902 cmd: &str,
903 flags: Vec<TomlSubFlag>,
904) -> Vec<crate::registry::types::FlagProvenance> {
905 let cited = |o: &Option<String>| o.as_deref().is_some_and(|s| !s.trim().is_empty());
906 let judged = |o: &Option<String>| o.as_deref().is_none_or(|s| !s.trim().is_empty());
907 flags
908 .into_iter()
909 .map(|f| {
910 assert!(
911 f.classifies == "unclassified"
912 || crate::engine::archetype::archetype(&f.classifies).is_some(),
913 "command `{cmd}` flag `{}`: classifies `{}` is not a known archetype",
914 f.name, f.classifies,
915 );
916 assert!(cited(&f.fact), "command `{cmd}` flag `{}`: requires a `fact`", f.name);
917 assert!(cited(&f.source), "command `{cmd}` flag `{}`: requires a `source`", f.name);
918 assert!(judged(&f.judgment), "command `{cmd}` flag `{}`: `judgment`, if given, must not be blank", f.name);
919 assert!(
920 !(f.when_absent == Some(true) && f.value_prefix.is_some()),
921 "command `{cmd}` flag `{}`: `when_absent` and `value_prefix` are mutually exclusive",
922 f.name,
923 );
924 crate::registry::types::FlagProvenance {
925 name: f.name,
926 classifies: f.classifies,
927 value_prefix: f.value_prefix,
928 when_absent: f.when_absent.unwrap_or(false),
929 }
930 })
931 .collect()
932}
933
934#[allow(clippy::too_many_lines)]
935pub(super) fn build_command(toml: TomlCommand, category: &str) -> CommandSpec {
936 assert_flat_or_structured(&toml);
937 assert_fallback_requires_handler(&toml);
938 assert_matrix_policy_keys_exist(&toml);
939 assert_no_candidate_shadowed_by_glob(&toml.name, &toml.sub, &toml.first_arg);
940 assert_matrix_no_duplicate_parent_action(&toml);
941 assert_command_eval_safe_only_on_leaf(&toml);
942 assert_eval_safe_tagged_command_has_researched_version(&toml);
943 check_no_legacy_positional_style(&toml.name, toml.positional_style);
944 let cat = category.to_string();
945 let desc = toml.description.unwrap_or_default();
946 let researched_version = toml.researched_version;
947 let examples_safe = toml.examples_safe;
948 let examples_denied = toml.examples_denied;
949 let eval_safe = toml.eval_safe.unwrap_or(false);
950 let eval_safe_flags = toml.eval_safe_flags;
951 let eval_safe_flag_values = toml.eval_safe_flag_values;
952 let eval_safe_required_flags = toml.eval_safe_required_flags;
953 if !eval_safe_flags.is_empty() && !eval_safe {
954 panic!(
955 "command '{}' declares `eval_safe_flags` without `eval_safe = true`. \
956 The flag allowlist only takes effect when the command is tagged \
957 eval-safe. Add `eval_safe = true` or drop `eval_safe_flags`.",
958 toml.name,
959 );
960 }
961 assert_eval_safe_flag_values_consistent(
962 &toml.name,
963 "<command>",
964 &eval_safe_flags,
965 &eval_safe_flag_values,
966 );
967 assert_eval_safe_valued_flags_declared(
968 &toml.name,
969 "<command>",
970 &eval_safe_flags,
971 &toml.valued,
972 &eval_safe_flag_values,
973 );
974 assert_eval_safe_required_flags_consistent(
975 &toml.name,
976 "<command>",
977 &eval_safe_flags,
978 &eval_safe_required_flags,
979 );
980 let behavior = lower_behavior(&toml.name, toml.behavior.as_ref());
981 let archetype_flags = build_command_archetype_flags(&toml.name, toml.flag);
982 if toml.deny.unwrap_or(false) {
983 return CommandSpec {
984 name: toml.name,
985 description: desc,
986 aliases: toml.aliases,
987 url: toml.url,
988 category: cat,
989 researched_version,
990 examples_safe,
991 examples_denied,
992 eval_safe,
993 eval_safe_flags: eval_safe_flags.clone(),
994 eval_safe_flag_values: eval_safe_flag_values.clone(),
995 eval_safe_required_flags: eval_safe_required_flags.clone(),
996 path_gate: toml.path_gate,
997 archetype_flags: archetype_flags.clone(),
998 behavior: behavior.clone(),
999 kind: DispatchKind::Policy {
1000 policy: OwnedPolicy {
1001 standalone: Vec::new(),
1002 valued: Vec::new(),
1003 bare: false,
1004 max_positional: Some(0),
1005 tolerance: FlagTolerance::default(),
1006 },
1007 level: SafetyLevel::Inert,
1008 },
1009 };
1010 }
1011 if let Some(vc) = toml.verb_chain {
1012 return CommandSpec {
1013 name: toml.name,
1014 description: desc,
1015 aliases: toml.aliases,
1016 url: toml.url,
1017 category: cat,
1018 researched_version,
1019 examples_safe,
1020 examples_denied,
1021 eval_safe,
1022 eval_safe_flags: eval_safe_flags.clone(),
1023 eval_safe_flag_values: eval_safe_flag_values.clone(),
1024 eval_safe_required_flags: eval_safe_required_flags.clone(),
1025 path_gate: toml.path_gate,
1026 archetype_flags: archetype_flags.clone(),
1027 behavior: behavior.clone(),
1028 kind: DispatchKind::VerbChain(build_verb_chain(vc)),
1029 };
1030 }
1031
1032 if let Some(handler_name) = toml.handler {
1033 let handler_policies: std::collections::HashMap<String, OwnedPolicy> = toml
1036 .handler_policy
1037 .into_iter()
1038 .map(|(k, v)| (k, build_handler_policy(v)))
1039 .collect();
1040 let parent_name = toml.name.clone();
1041 let subs = filter_candidates(toml.sub)
1042 .flat_map(|s| build_subs(&parent_name, s, &handler_policies))
1043 .collect();
1044 let fallback = toml.fallback.map(|f| build_fallback(&toml.name, f));
1045 let matrices = toml
1046 .matrix
1047 .into_iter()
1048 .map(build_matrix)
1049 .collect();
1050 return CommandSpec {
1051 name: toml.name,
1052 description: desc,
1053 aliases: toml.aliases,
1054 url: toml.url,
1055 category: cat,
1056 researched_version,
1057 examples_safe,
1058 examples_denied,
1059 eval_safe,
1060 eval_safe_flags: eval_safe_flags.clone(),
1061 eval_safe_flag_values: eval_safe_flag_values.clone(),
1062 eval_safe_required_flags: eval_safe_required_flags.clone(),
1063 path_gate: toml.path_gate,
1064 archetype_flags: archetype_flags.clone(),
1065 behavior: behavior.clone(),
1066 kind: DispatchKind::Custom {
1067 handler_name,
1068 doc_body: toml.doc_body,
1069 subs,
1070 fallback,
1071 handler_policies,
1072 matrices,
1073 },
1074 };
1075 }
1076
1077 if let Some(w) = toml.wrapper {
1078 if !toml.sub.is_empty() || !toml.bare_flags.is_empty() {
1079 let first_arg_level = toml.level.unwrap_or(TomlLevel::Inert).into();
1080 let parent_name = toml.name.clone();
1081 return CommandSpec {
1082 name: toml.name,
1083 description: desc,
1084 aliases: toml.aliases,
1085 url: toml.url,
1086 category: cat,
1087 researched_version,
1088 examples_safe,
1089 examples_denied,
1090 eval_safe,
1091 eval_safe_flags: eval_safe_flags.clone(),
1092 eval_safe_flag_values: eval_safe_flag_values.clone(),
1093 eval_safe_required_flags: eval_safe_required_flags.clone(),
1094 path_gate: toml.path_gate,
1095 archetype_flags: archetype_flags.clone(),
1096 behavior: behavior.clone(),
1097 kind: DispatchKind::Branching {
1098 bare_flags: toml.bare_flags,
1099 subs: filter_candidates(toml.sub)
1100 .flat_map(|s| build_subs(&parent_name, s, &std::collections::HashMap::new()))
1101 .collect(),
1102 pre_standalone: w.standalone,
1103 pre_valued: w.valued,
1104 bare_ok: toml.bare.unwrap_or(false),
1105 first_arg: toml.first_arg,
1106 first_arg_level,
1107 credential_first_arg: toml.credential_first_arg,
1108 },
1109 };
1110 }
1111 return CommandSpec {
1112 name: toml.name,
1113 description: desc,
1114 aliases: toml.aliases,
1115 url: toml.url,
1116 category: cat,
1117 researched_version,
1118 examples_safe,
1119 examples_denied,
1120 eval_safe,
1121 eval_safe_flags: eval_safe_flags.clone(),
1122 eval_safe_flag_values: eval_safe_flag_values.clone(),
1123 eval_safe_required_flags: eval_safe_required_flags.clone(),
1124 path_gate: toml.path_gate,
1125 archetype_flags: archetype_flags.clone(),
1126 behavior: behavior.clone(),
1127 kind: DispatchKind::Wrapper {
1128 standalone: w.standalone,
1129 valued: w.valued,
1130 positional_skip: w.positional_skip.unwrap_or(0),
1131 separator: w.separator,
1132 bare_ok: w.bare_ok.unwrap_or(false),
1133 },
1134 };
1135 }
1136
1137 if !toml.sub.is_empty() || !toml.bare_flags.is_empty() {
1138 let first_arg_level = toml.level.unwrap_or(TomlLevel::Inert).into();
1139 let parent_name = toml.name.clone();
1140 return CommandSpec {
1141 name: toml.name,
1142 description: desc,
1143 aliases: toml.aliases,
1144 url: toml.url,
1145 category: cat,
1146 researched_version,
1147 examples_safe,
1148 examples_denied,
1149 eval_safe,
1150 eval_safe_flags: eval_safe_flags.clone(),
1151 eval_safe_flag_values: eval_safe_flag_values.clone(),
1152 eval_safe_required_flags: eval_safe_required_flags.clone(),
1153 path_gate: toml.path_gate,
1154 archetype_flags: archetype_flags.clone(),
1155 behavior: behavior.clone(),
1156 kind: DispatchKind::Branching {
1157 bare_flags: toml.bare_flags,
1158 subs: filter_candidates(toml.sub)
1159 .flat_map(|s| build_subs(&parent_name, s, &std::collections::HashMap::new()))
1160 .collect(),
1161 pre_standalone: Vec::new(),
1162 pre_valued: Vec::new(),
1163 bare_ok: toml.bare.unwrap_or(false),
1164 first_arg: toml.first_arg,
1165 first_arg_level,
1166 credential_first_arg: toml.credential_first_arg,
1167 },
1168 };
1169 }
1170
1171 let policy = build_policy(
1172 toml.standalone,
1173 toml.valued,
1174 toml.bare,
1175 toml.max_positional,
1176 toml.tolerate_unknown_short,
1177 toml.tolerate_unknown_long,
1178 toml.numeric_dash,
1179 );
1180
1181 let level = toml.level.unwrap_or(TomlLevel::Inert).into();
1182
1183 if !toml.first_arg.is_empty() {
1184 return CommandSpec {
1185 name: toml.name,
1186 description: desc,
1187 aliases: toml.aliases,
1188 url: toml.url,
1189 category: cat,
1190 researched_version,
1191 examples_safe,
1192 examples_denied,
1193 eval_safe,
1194 eval_safe_flags: eval_safe_flags.clone(),
1195 eval_safe_flag_values: eval_safe_flag_values.clone(),
1196 eval_safe_required_flags: eval_safe_required_flags.clone(),
1197 path_gate: toml.path_gate,
1198 archetype_flags: archetype_flags.clone(),
1199 behavior: behavior.clone(),
1200 kind: DispatchKind::FirstArg {
1201 patterns: toml.first_arg,
1202 level,
1203 },
1204 };
1205 }
1206
1207 if !toml.write_flags.is_empty() {
1208 return CommandSpec {
1209 name: toml.name,
1210 description: desc,
1211 aliases: toml.aliases,
1212 url: toml.url,
1213 category: cat,
1214 researched_version,
1215 examples_safe,
1216 examples_denied,
1217 eval_safe,
1218 eval_safe_flags: eval_safe_flags.clone(),
1219 eval_safe_flag_values: eval_safe_flag_values.clone(),
1220 eval_safe_required_flags: eval_safe_required_flags.clone(),
1221 path_gate: toml.path_gate,
1222 archetype_flags: archetype_flags.clone(),
1223 behavior: behavior.clone(),
1224 kind: DispatchKind::WriteFlagged {
1225 policy,
1226 base_level: level,
1227 write_flags: toml.write_flags,
1228 },
1229 };
1230 }
1231
1232 if !toml.require_any.is_empty() {
1233 return CommandSpec {
1234 name: toml.name,
1235 description: desc,
1236 aliases: toml.aliases,
1237 url: toml.url,
1238 category: cat,
1239 researched_version,
1240 examples_safe,
1241 examples_denied,
1242 eval_safe,
1243 eval_safe_flags: eval_safe_flags.clone(),
1244 eval_safe_flag_values: eval_safe_flag_values.clone(),
1245 eval_safe_required_flags: eval_safe_required_flags.clone(),
1246 path_gate: toml.path_gate,
1247 archetype_flags: archetype_flags.clone(),
1248 behavior: behavior.clone(),
1249 kind: DispatchKind::RequireAny {
1250 require_any: toml.require_any,
1251 policy,
1252 level,
1253 accept_bare_help: false,
1254 },
1255 };
1256 }
1257
1258 CommandSpec {
1259 name: toml.name,
1260 description: desc,
1261 aliases: toml.aliases,
1262 url: toml.url,
1263 category: cat,
1264 researched_version,
1265 examples_safe,
1266 examples_denied,
1267 eval_safe,
1268 eval_safe_flags,
1269 eval_safe_flag_values,
1270 eval_safe_required_flags,
1271 path_gate: toml.path_gate,
1272 archetype_flags,
1273 behavior,
1274 kind: DispatchKind::Policy {
1275 policy,
1276 level,
1277 },
1278 }
1279}
1280
1281pub fn load_toml(source: &str, category: &str) -> Vec<CommandSpec> {
1282 let file: TomlFile = match toml::from_str(source) {
1283 Ok(f) => f,
1284 Err(e) => {
1285 let preview: String = source.chars().take(80).collect();
1286 panic!("invalid TOML command definition: {e}\n source begins: {preview}");
1287 }
1288 };
1289 file.command.into_iter()
1290 .filter(|cmd| !cmd.candidate.unwrap_or(false))
1291 .map(|cmd| build_command(cmd, category))
1292 .collect()
1293}
1294
1295pub fn build_registry(specs: Vec<CommandSpec>) -> HashMap<String, CommandSpec> {
1296 let mut map = HashMap::new();
1297 for spec in specs {
1298 insert_spec(&mut map, spec);
1299 }
1300 map
1301}
1302
1303pub fn insert_spec(map: &mut HashMap<String, CommandSpec>, spec: CommandSpec) {
1309 map.retain(|_, s| s.name != spec.name);
1310 for alias in &spec.aliases {
1311 map.insert(alias.clone(), CommandSpec {
1312 name: spec.name.clone(),
1313 description: spec.description.clone(),
1314 aliases: vec![],
1315 url: spec.url.clone(),
1316 category: spec.category.clone(),
1317 researched_version: spec.researched_version.clone(),
1318 examples_safe: vec![],
1319 examples_denied: vec![],
1320 eval_safe: spec.eval_safe,
1321 eval_safe_flags: spec.eval_safe_flags.clone(),
1322 eval_safe_flag_values: spec.eval_safe_flag_values.clone(),
1323 eval_safe_required_flags: spec.eval_safe_required_flags.clone(),
1324 path_gate: None,
1328 archetype_flags: Vec::new(),
1329 behavior: None,
1330 kind: spec.kind.clone(),
1331 });
1332 }
1333 map.insert(spec.name.clone(), spec);
1334}