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