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