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