1use crate::parse::{has_flag, Segment, Token};
2use crate::policy::{self, FlagPolicy};
3#[cfg(test)]
4use crate::policy::FlagStyle;
5
6pub type CheckFn = fn(&[Token], &dyn Fn(&Segment) -> bool) -> bool;
7
8pub enum SubDef {
9 Policy {
10 name: &'static str,
11 policy: &'static FlagPolicy,
12 },
13 Nested {
14 name: &'static str,
15 subs: &'static [SubDef],
16 },
17 Guarded {
18 name: &'static str,
19 guard_short: Option<&'static str>,
20 guard_long: &'static str,
21 policy: &'static FlagPolicy,
22 },
23 Custom {
24 name: &'static str,
25 check: CheckFn,
26 doc: &'static str,
27 test_suffix: Option<&'static str>,
28 },
29 Delegation {
30 name: &'static str,
31 skip: usize,
32 doc: &'static str,
33 },
34}
35
36pub struct CommandDef {
37 pub name: &'static str,
38 pub subs: &'static [SubDef],
39 pub bare_flags: &'static [&'static str],
40 pub help_eligible: bool,
41 pub url: &'static str,
42}
43
44impl SubDef {
45 pub fn name(&self) -> &'static str {
46 match self {
47 Self::Policy { name, .. }
48 | Self::Nested { name, .. }
49 | Self::Guarded { name, .. }
50 | Self::Custom { name, .. }
51 | Self::Delegation { name, .. } => name,
52 }
53 }
54
55 pub fn check(&self, tokens: &[Token], is_safe: &dyn Fn(&Segment) -> bool) -> bool {
56 match self {
57 Self::Policy { policy, .. } => {
58 if tokens.len() == 2 && (tokens[1] == "--help" || tokens[1] == "-h") {
59 return true;
60 }
61 policy::check(tokens, policy)
62 }
63 Self::Nested { subs, .. } => {
64 if tokens.len() < 2 {
65 return false;
66 }
67 let sub = tokens[1].as_str();
68 if tokens.len() == 2 && (sub == "--help" || sub == "-h") {
69 return true;
70 }
71 subs.iter()
72 .any(|s| s.name() == sub && s.check(&tokens[1..], is_safe))
73 }
74 Self::Guarded {
75 guard_short,
76 guard_long,
77 policy,
78 ..
79 } => {
80 if tokens.len() == 2 && (tokens[1] == "--help" || tokens[1] == "-h") {
81 return true;
82 }
83 has_flag(tokens, *guard_short, Some(guard_long))
84 && policy::check(tokens, policy)
85 }
86 Self::Custom { check: f, .. } => {
87 if tokens.len() == 2 && (tokens[1] == "--help" || tokens[1] == "-h") {
88 return true;
89 }
90 f(tokens, is_safe)
91 }
92 Self::Delegation { skip, .. } => {
93 if tokens.len() <= *skip {
94 return false;
95 }
96 let inner = Token::join(&tokens[*skip..]);
97 is_safe(&inner)
98 }
99 }
100 }
101}
102
103impl CommandDef {
104 pub fn check(&self, tokens: &[Token], is_safe: &dyn Fn(&Segment) -> bool) -> bool {
105 if tokens.len() < 2 {
106 return false;
107 }
108 let arg = tokens[1].as_str();
109 if self.help_eligible && tokens.len() == 2 && matches!(arg, "--help" | "-h" | "--version" | "-V") {
110 return true;
111 }
112 if tokens.len() == 2 && self.bare_flags.contains(&arg) {
113 return true;
114 }
115 self.subs
116 .iter()
117 .find(|s| s.name() == arg)
118 .is_some_and(|s| s.check(&tokens[1..], is_safe))
119 }
120
121 pub fn dispatch(
122 &self,
123 cmd: &str,
124 tokens: &[Token],
125 is_safe: &dyn Fn(&Segment) -> bool,
126 ) -> Option<bool> {
127 if cmd == self.name {
128 Some(self.check(tokens, is_safe))
129 } else {
130 None
131 }
132 }
133
134 pub fn to_doc(&self) -> crate::docs::CommandDoc {
135 let mut lines = Vec::new();
136
137 if !self.bare_flags.is_empty() {
138 lines.push(format!("- Info flags: {}", self.bare_flags.join(", ")));
139 }
140
141 let mut sub_lines: Vec<String> = Vec::new();
142 for sub in self.subs {
143 sub_doc_line(sub, "", &mut sub_lines);
144 }
145 sub_lines.sort();
146 lines.extend(sub_lines);
147
148 crate::docs::CommandDoc::handler(self.name, self.url, lines.join("\n"))
149 }
150}
151
152pub struct FlatDef {
153 pub name: &'static str,
154 pub policy: &'static FlagPolicy,
155 pub help_eligible: bool,
156 pub url: &'static str,
157}
158
159impl FlatDef {
160 pub fn dispatch(&self, cmd: &str, tokens: &[Token]) -> Option<bool> {
161 if cmd == self.name {
162 if self.help_eligible
163 && tokens.len() == 2
164 && matches!(tokens[1].as_str(), "--help" | "-h" | "--version" | "-V")
165 {
166 return Some(true);
167 }
168 Some(policy::check(tokens, self.policy))
169 } else {
170 None
171 }
172 }
173
174 pub fn to_doc(&self) -> crate::docs::CommandDoc {
175 crate::docs::CommandDoc::handler(self.name, self.url, self.policy.describe())
176 }
177}
178
179#[cfg(test)]
180impl FlatDef {
181 pub fn auto_test_reject_unknown(&self) {
182 if self.policy.flag_style == FlagStyle::Positional {
183 return;
184 }
185 let test = format!("{} --xyzzy-unknown-42", self.name);
186 assert!(
187 !crate::is_safe_command(&test),
188 "{}: accepted unknown flag: {test}",
189 self.name,
190 );
191 }
192}
193
194fn sub_doc_line(sub: &SubDef, prefix: &str, out: &mut Vec<String>) {
195 match sub {
196 SubDef::Policy { name, policy } => {
197 let summary = policy.flag_summary();
198 let label = if prefix.is_empty() {
199 (*name).to_string()
200 } else {
201 format!("{prefix} {name}")
202 };
203 if summary.is_empty() {
204 out.push(format!("- **{label}**"));
205 } else {
206 out.push(format!("- **{label}**: {summary}"));
207 }
208 }
209 SubDef::Nested { name, subs } => {
210 let path = if prefix.is_empty() {
211 (*name).to_string()
212 } else {
213 format!("{prefix} {name}")
214 };
215 for s in *subs {
216 sub_doc_line(s, &path, out);
217 }
218 }
219 SubDef::Guarded {
220 name,
221 guard_long,
222 policy,
223 ..
224 } => {
225 let summary = policy.flag_summary();
226 let label = if prefix.is_empty() {
227 (*name).to_string()
228 } else {
229 format!("{prefix} {name}")
230 };
231 if summary.is_empty() {
232 out.push(format!("- **{label}** (requires {guard_long})"));
233 } else {
234 out.push(format!("- **{label}** (requires {guard_long}): {summary}"));
235 }
236 }
237 SubDef::Custom { name, doc, .. } => {
238 if !doc.is_empty() && doc.trim().is_empty() {
239 return;
240 }
241 let label = if prefix.is_empty() {
242 (*name).to_string()
243 } else {
244 format!("{prefix} {name}")
245 };
246 if doc.is_empty() {
247 out.push(format!("- **{label}**"));
248 } else {
249 out.push(format!("- **{label}**: {doc}"));
250 }
251 }
252 SubDef::Delegation { name, doc, .. } => {
253 if doc.is_empty() {
254 return;
255 }
256 let label = if prefix.is_empty() {
257 (*name).to_string()
258 } else {
259 format!("{prefix} {name}")
260 };
261 out.push(format!("- **{label}**: {doc}"));
262 }
263 }
264}
265
266#[cfg(test)]
267impl CommandDef {
268 pub fn auto_test_reject_unknown(&self) {
269 let mut failures = Vec::new();
270
271 assert!(
272 !crate::is_safe_command(self.name),
273 "{}: accepted bare invocation",
274 self.name,
275 );
276
277 let test = format!("{} xyzzy-unknown-42", self.name);
278 assert!(
279 !crate::is_safe_command(&test),
280 "{}: accepted unknown subcommand: {test}",
281 self.name,
282 );
283
284 for sub in self.subs {
285 auto_test_sub(self.name, sub, &mut failures);
286 }
287 assert!(
288 failures.is_empty(),
289 "{}: unknown flags/subcommands accepted:\n{}",
290 self.name,
291 failures.join("\n"),
292 );
293 }
294}
295
296#[cfg(test)]
297fn auto_test_sub(prefix: &str, sub: &SubDef, failures: &mut Vec<String>) {
298 const UNKNOWN: &str = "--xyzzy-unknown-42";
299
300 match sub {
301 SubDef::Policy { name, policy } => {
302 if policy.flag_style == FlagStyle::Positional {
303 return;
304 }
305 let test = format!("{prefix} {name} {UNKNOWN}");
306 if crate::is_safe_command(&test) {
307 failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
308 }
309 }
310 SubDef::Nested { name, subs } => {
311 let path = format!("{prefix} {name}");
312 let test = format!("{path} xyzzy-unknown-42");
313 if crate::is_safe_command(&test) {
314 failures.push(format!("{path}: accepted unknown subcommand: {test}"));
315 }
316 for s in *subs {
317 auto_test_sub(&path, s, failures);
318 }
319 }
320 SubDef::Guarded {
321 name, guard_long, ..
322 } => {
323 let test = format!("{prefix} {name} {guard_long} {UNKNOWN}");
324 if crate::is_safe_command(&test) {
325 failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
326 }
327 }
328 SubDef::Custom {
329 name, test_suffix, ..
330 } => {
331 if let Some(suffix) = test_suffix {
332 let test = format!("{prefix} {name} {suffix} {UNKNOWN}");
333 if crate::is_safe_command(&test) {
334 failures.push(format!(
335 "{prefix} {name}: accepted unknown flag: {test}"
336 ));
337 }
338 }
339 }
340 SubDef::Delegation { .. } => {}
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347 use crate::parse::WordSet;
348 use crate::policy::FlagStyle;
349
350 fn toks(words: &[&str]) -> Vec<Token> {
351 words.iter().map(|s| Token::from_test(s)).collect()
352 }
353
354 fn no_safe(_: &Segment) -> bool {
355 false
356 }
357
358 static TEST_POLICY: FlagPolicy = FlagPolicy {
359 standalone: WordSet::new(&["--verbose"]),
360 standalone_short: b"v",
361 valued: WordSet::new(&["--output"]),
362 valued_short: b"o",
363 bare: true,
364 max_positional: None,
365 flag_style: FlagStyle::Strict,
366 };
367
368 static SIMPLE_CMD: CommandDef = CommandDef {
369 name: "mycmd",
370 subs: &[SubDef::Policy {
371 name: "build",
372 policy: &TEST_POLICY,
373 }],
374 bare_flags: &["--info"],
375 help_eligible: true,
376 url: "",
377 };
378
379 #[test]
380 fn bare_rejected() {
381 assert!(!SIMPLE_CMD.check(&toks(&["mycmd"]), &no_safe));
382 }
383
384 #[test]
385 fn bare_flag_accepted() {
386 assert!(SIMPLE_CMD.check(&toks(&["mycmd", "--info"]), &no_safe));
387 }
388
389 #[test]
390 fn bare_flag_with_extra_rejected() {
391 assert!(!SIMPLE_CMD.check(&toks(&["mycmd", "--info", "extra"]), &no_safe));
392 }
393
394 #[test]
395 fn policy_sub_bare() {
396 assert!(SIMPLE_CMD.check(&toks(&["mycmd", "build"]), &no_safe));
397 }
398
399 #[test]
400 fn policy_sub_with_flag() {
401 assert!(SIMPLE_CMD.check(&toks(&["mycmd", "build", "--verbose"]), &no_safe));
402 }
403
404 #[test]
405 fn policy_sub_unknown_flag() {
406 assert!(!SIMPLE_CMD.check(&toks(&["mycmd", "build", "--bad"]), &no_safe));
407 }
408
409 #[test]
410 fn unknown_sub_rejected() {
411 assert!(!SIMPLE_CMD.check(&toks(&["mycmd", "deploy"]), &no_safe));
412 }
413
414 #[test]
415 fn dispatch_matches() {
416 assert_eq!(
417 SIMPLE_CMD.dispatch("mycmd", &toks(&["mycmd", "build"]), &no_safe),
418 Some(true)
419 );
420 }
421
422 #[test]
423 fn dispatch_no_match() {
424 assert_eq!(
425 SIMPLE_CMD.dispatch("other", &toks(&["other", "build"]), &no_safe),
426 None
427 );
428 }
429
430 static NESTED_CMD: CommandDef = CommandDef {
431 name: "nested",
432 subs: &[SubDef::Nested {
433 name: "package",
434 subs: &[SubDef::Policy {
435 name: "describe",
436 policy: &TEST_POLICY,
437 }],
438 }],
439 bare_flags: &[],
440 help_eligible: false,
441 url: "",
442 };
443
444 #[test]
445 fn nested_sub() {
446 assert!(NESTED_CMD.check(&toks(&["nested", "package", "describe"]), &no_safe));
447 }
448
449 #[test]
450 fn nested_sub_with_flag() {
451 assert!(NESTED_CMD.check(
452 &toks(&["nested", "package", "describe", "--verbose"]),
453 &no_safe,
454 ));
455 }
456
457 #[test]
458 fn nested_bare_rejected() {
459 assert!(!NESTED_CMD.check(&toks(&["nested", "package"]), &no_safe));
460 }
461
462 #[test]
463 fn nested_unknown_sub_rejected() {
464 assert!(!NESTED_CMD.check(&toks(&["nested", "package", "deploy"]), &no_safe));
465 }
466
467 static GUARDED_POLICY: FlagPolicy = FlagPolicy {
468 standalone: WordSet::new(&["--all", "--check"]),
469 standalone_short: b"",
470 valued: WordSet::new(&[]),
471 valued_short: b"",
472 bare: false,
473 max_positional: None,
474 flag_style: FlagStyle::Strict,
475 };
476
477 static GUARDED_CMD: CommandDef = CommandDef {
478 name: "guarded",
479 subs: &[SubDef::Guarded {
480 name: "fmt",
481 guard_short: None,
482 guard_long: "--check",
483 policy: &GUARDED_POLICY,
484 }],
485 bare_flags: &[],
486 help_eligible: false,
487 url: "",
488 };
489
490 #[test]
491 fn guarded_with_guard() {
492 assert!(GUARDED_CMD.check(&toks(&["guarded", "fmt", "--check"]), &no_safe));
493 }
494
495 #[test]
496 fn guarded_without_guard() {
497 assert!(!GUARDED_CMD.check(&toks(&["guarded", "fmt"]), &no_safe));
498 }
499
500 #[test]
501 fn guarded_with_guard_and_flag() {
502 assert!(GUARDED_CMD.check(
503 &toks(&["guarded", "fmt", "--check", "--all"]),
504 &no_safe,
505 ));
506 }
507
508 fn safe_echo(seg: &Segment) -> bool {
509 seg.as_str() == "echo hello"
510 }
511
512 static DELEGATION_CMD: CommandDef = CommandDef {
513 name: "runner",
514 subs: &[SubDef::Delegation {
515 name: "run",
516 skip: 2,
517 doc: "run delegates to inner command.",
518 }],
519 bare_flags: &[],
520 help_eligible: false,
521 url: "",
522 };
523
524 #[test]
525 fn delegation_safe_inner() {
526 assert!(DELEGATION_CMD.check(
527 &toks(&["runner", "run", "stable", "echo", "hello"]),
528 &safe_echo,
529 ));
530 }
531
532 #[test]
533 fn delegation_unsafe_inner() {
534 assert!(!DELEGATION_CMD.check(
535 &toks(&["runner", "run", "stable", "rm", "-rf"]),
536 &no_safe,
537 ));
538 }
539
540 #[test]
541 fn delegation_no_inner() {
542 assert!(!DELEGATION_CMD.check(
543 &toks(&["runner", "run", "stable"]),
544 &no_safe,
545 ));
546 }
547
548 fn custom_check(tokens: &[Token], _is_safe: &dyn Fn(&Segment) -> bool) -> bool {
549 tokens.len() >= 2 && tokens[1] == "safe"
550 }
551
552 static CUSTOM_CMD: CommandDef = CommandDef {
553 name: "custom",
554 subs: &[SubDef::Custom {
555 name: "special",
556 check: custom_check,
557 doc: "special (safe only).",
558 test_suffix: Some("safe"),
559 }],
560 bare_flags: &[],
561 help_eligible: false,
562 url: "",
563 };
564
565 #[test]
566 fn custom_passes() {
567 assert!(CUSTOM_CMD.check(&toks(&["custom", "special", "safe"]), &no_safe));
568 }
569
570 #[test]
571 fn custom_fails() {
572 assert!(!CUSTOM_CMD.check(&toks(&["custom", "special", "bad"]), &no_safe));
573 }
574
575 #[test]
576 fn doc_simple() {
577 let doc = SIMPLE_CMD.to_doc();
578 assert_eq!(doc.name, "mycmd");
579 assert_eq!(
580 doc.description,
581 "- Info flags: --info\n- **build**: Flags: --verbose. Valued: --output"
582 );
583 }
584
585 #[test]
586 fn doc_nested() {
587 let doc = NESTED_CMD.to_doc();
588 assert_eq!(
589 doc.description,
590 "- **package describe**: Flags: --verbose. Valued: --output"
591 );
592 }
593
594 #[test]
595 fn doc_guarded() {
596 let doc = GUARDED_CMD.to_doc();
597 assert_eq!(
598 doc.description,
599 "- **fmt** (requires --check): Flags: --all, --check"
600 );
601 }
602
603 #[test]
604 fn doc_delegation() {
605 let doc = DELEGATION_CMD.to_doc();
606 assert_eq!(doc.description, "- **run**: run delegates to inner command.");
607 }
608
609 #[test]
610 fn doc_custom() {
611 let doc = CUSTOM_CMD.to_doc();
612 assert_eq!(doc.description, "- **special**: special (safe only).");
613 }
614}