Skip to main content

safe_chains/
policy.rs

1use crate::parse::{Token, WordSet};
2
3/// Whether unrecognized flag-shaped tokens are denied or silently accepted
4/// as positional arguments. The default (Strict) makes the allowlist
5/// authoritative — any unrecognized `-X` or `--foo` is denied.
6#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
7pub enum UnknownTolerance {
8    /// Deny every unrecognized flag-shaped token. The safe default.
9    #[default]
10    Strict,
11    /// Accept unknown single-dash tokens (`-X`, `-help`, `-mayDie`) as
12    /// positional. Reject unknown double-dash. Use for tools like
13    /// `pdftotext` that have single-dash long flags.
14    Short,
15    /// Accept unknown double-dash tokens (`--foo`, `--foo=value`) as
16    /// positional. Reject unknown single-dash. Dangerous: most modern
17    /// destructive flags are double-dash, so enabling this can silently
18    /// accept mutating options. Reserved for tools with genuinely
19    /// unbounded long-flag surfaces (AWS CLI service flags).
20    Long,
21    /// Accept both single-dash and double-dash unknowns as positional.
22    /// Most permissive; combines the cost of `Short` and `Long`.
23    Both,
24}
25
26impl UnknownTolerance {
27    pub const fn allows_short(self) -> bool {
28        matches!(self, Self::Short | Self::Both)
29    }
30    pub const fn allows_long(self) -> bool {
31        matches!(self, Self::Long | Self::Both)
32    }
33}
34
35/// How the dispatcher treats tokens that look like flags but aren't in the
36/// allowlist. `unknown` controls flag-shaped unknowns; `numeric_dash` opts
37/// into `-NUMBER` shorthand (e.g. `head -20`).
38#[derive(Clone, Copy, Debug, Default)]
39pub struct FlagTolerance {
40    pub unknown: UnknownTolerance,
41    pub numeric_dash: bool,
42}
43
44impl FlagTolerance {
45    /// Strict allowlist: deny every unrecognized flag-shaped token.
46    /// `const`-callable for use in static `FlagPolicy` literals.
47    pub const fn strict() -> Self {
48        Self { unknown: UnknownTolerance::Strict, numeric_dash: false }
49    }
50}
51
52/// Predicate over the first positional token of a fallback grammar.
53/// Lets a TOML-declared fallback say "the first positional must look
54/// like a path" without the handler hardcoding the test.
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub enum PositionalShape {
57    /// Looks like a file path: contains `/`, contains `.`, or is `-`
58    /// (the conventional stdin marker). Rejects flag-shaped tokens.
59    Path,
60    /// A go LOCAL package/file argument: `.`/`..`, a `./`-, `../`-, or
61    /// `/`-anchored path, or a `*.go` file. This is exactly Go's rule for
62    /// "this is a filesystem path, not an import path" — a BARE import path
63    /// (`rsc.io/x@latest`, `example.com/cmd`, `bin/tool`) is resolved via the
64    /// module system and, with `@version`, DOWNLOADS AND RUNS remote code, so
65    /// it must not be treated as a worktree-local executor.
66    GoPackage,
67}
68
69impl PositionalShape {
70    pub fn matches(self, token: &str) -> bool {
71        match self {
72            Self::Path => looks_like_path(token),
73            Self::GoPackage => is_go_local_package(token),
74        }
75    }
76
77    pub fn from_name(name: &str) -> Option<Self> {
78        match name {
79            "path" => Some(Self::Path),
80            "go-package" => Some(Self::GoPackage),
81            _ => None,
82        }
83    }
84}
85
86/// Whether `token` is a go LOCAL package/file (a filesystem path), as opposed to a
87/// module import path. See [`PositionalShape::GoPackage`].
88pub fn is_go_local_package(token: &str) -> bool {
89    token == "." || token == ".." || token.starts_with("./") || token.starts_with("../") || token.starts_with('/') || token.ends_with(".go")
90}
91
92/// Heuristic for "this token looks like a file path." Used by the
93/// `path` `PositionalShape`. Conservative on purpose — a bare word
94/// like `Tiltfile` is a valid filename in cwd but the heuristic
95/// rejects it to avoid swallowing flag-less subcommands. Callers
96/// that want bare-name acceptance should match a sub block instead.
97///
98/// A leading `~` counts, and its absence was a hole rather than part of the conservatism: the
99/// shell expands `~`, `~/x` and `~user` to a home directory, so all four are paths — but a BARE
100/// `~` carries neither `/` nor `.`, so it failed the test and every consumer that pre-filters on
101/// it skipped the operand entirely. `rg x ~` searched `$HOME` and printed matches to the model
102/// while `rg x ~/` denied; `od ~`, `tee ~`, `shred ~` and `awk '{print}' ~` did the same. The
103/// commands whose programs legitimately contain `~` (awk's match operator, an rg pattern) exclude
104/// it by POSITION before reaching here, so recognizing it costs them nothing.
105pub fn looks_like_path(token: &str) -> bool {
106    if token.is_empty() {
107        return false;
108    }
109    if token.starts_with('-') {
110        return token == "-";
111    }
112    token.starts_with('~') || token.contains('/') || token.contains('.')
113}
114
115pub trait FlagSet {
116    fn contains_flag(&self, token: &str) -> bool;
117    fn contains_short(&self, byte: u8) -> bool;
118}
119
120impl FlagSet for WordSet {
121    fn contains_flag(&self, token: &str) -> bool {
122        self.contains(token)
123    }
124    fn contains_short(&self, byte: u8) -> bool {
125        self.contains_short(byte)
126    }
127}
128
129impl FlagSet for [String] {
130    fn contains_flag(&self, token: &str) -> bool {
131        self.iter().any(|f| f.as_str() == token)
132    }
133    fn contains_short(&self, byte: u8) -> bool {
134        self.iter().any(|f| f.len() == 2 && f.as_bytes()[1] == byte)
135    }
136}
137
138impl FlagSet for Vec<String> {
139    fn contains_flag(&self, token: &str) -> bool {
140        self.as_slice().contains_flag(token)
141    }
142    fn contains_short(&self, byte: u8) -> bool {
143        self.as_slice().contains_short(byte)
144    }
145}
146
147pub struct FlagPolicy {
148    pub standalone: WordSet,
149    pub valued: WordSet,
150    pub bare: bool,
151    pub max_positional: Option<usize>,
152    pub tolerance: FlagTolerance,
153}
154
155impl FlagPolicy {
156    pub fn describe(&self) -> String {
157        use crate::docs::wordset_items;
158        let mut lines = Vec::new();
159        let standalone = wordset_items(&self.standalone);
160        if !standalone.is_empty() {
161            lines.push(format!("- Allowed standalone flags: {standalone}"));
162        }
163        let valued = wordset_items(&self.valued);
164        if !valued.is_empty() {
165            lines.push(format!("- Allowed valued flags: {valued}"));
166        }
167        if self.bare {
168            lines.push("- Bare invocation allowed".to_string());
169        }
170        if self.tolerance.unknown != UnknownTolerance::Strict {
171            lines.push("- Hyphen-prefixed positional arguments accepted".to_string());
172        }
173        if self.tolerance.numeric_dash {
174            lines.push("- Numeric shorthand accepted (e.g. -20 for -n 20)".to_string());
175        }
176        if lines.is_empty() && !self.bare {
177            return "- Positional arguments only".to_string();
178        }
179        lines.join("\n")
180    }
181}
182
183pub fn check(tokens: &[Token], policy: &FlagPolicy) -> bool {
184    check_flags(tokens, &policy.standalone, &policy.valued, policy.bare, policy.max_positional, policy.tolerance)
185}
186
187pub(crate) fn consumes_next_value(next: Option<&Token>) -> bool {
188    match next {
189        None => false,
190        Some(t) if crate::cst::opaque::is_flag_probe(t.as_str()) => true,
191        Some(t) => {
192            let b = t.as_bytes();
193            // An option-like token (starts with `-` and isn't a negative
194            // number) is never the value of a preceding valued flag. Consuming
195            // it would let an execution-enabling flag ride in as a bogus
196            // "value" — e.g. `node --check --require=evil.js`, where node runs
197            // the `--require` preload even in syntax-check mode. Negative
198            // numbers (`head -n -5`) and a lone `-` (stdin) are real values.
199            !(b.len() > 1 && b[0] == b'-' && !b[1].is_ascii_digit())
200        }
201    }
202}
203
204pub fn check_flags<S: FlagSet + ?Sized, V: FlagSet + ?Sized>(
205    tokens: &[Token],
206    standalone: &S,
207    valued: &V,
208    bare: bool,
209    max_positional: Option<usize>,
210    tolerance: FlagTolerance,
211) -> bool {
212    if tokens.len() == 1 {
213        return bare;
214    }
215
216    let mut i = 1;
217    let mut positionals: usize = 0;
218    while i < tokens.len() {
219        let t = &tokens[i];
220
221        if *t == "--" {
222            positionals += tokens.len() - i - 1;
223            break;
224        }
225
226        if !t.starts_with('-') {
227            positionals += 1;
228            i += 1;
229            continue;
230        }
231
232        if tolerance.numeric_dash && t.len() > 1 && t[1..].bytes().all(|b| b.is_ascii_digit()) {
233            i += 1;
234            continue;
235        }
236
237        if standalone.contains_flag(t) {
238            i += 1;
239            continue;
240        }
241
242        if valued.contains_flag(t) {
243            if consumes_next_value(tokens.get(i + 1)) {
244                i += 2;
245            } else {
246                i += 1;
247            }
248            continue;
249        }
250
251        if let Some(flag) = t.as_str().split_once('=').map(|(f, _)| f) {
252            if valued.contains_flag(flag) {
253                i += 1;
254                continue;
255            }
256            // `--foo=value` forms are governed by the long-flag tolerance.
257            if tolerance.unknown.allows_long() {
258                positionals += 1;
259                i += 1;
260                continue;
261            }
262            return false;
263        }
264
265        if t.starts_with("--") {
266            if tolerance.unknown.allows_long() {
267                positionals += 1;
268                i += 1;
269                continue;
270            }
271            return false;
272        }
273
274        let bytes = t.as_bytes();
275        let mut j = 1;
276        while j < bytes.len() {
277            let b = bytes[j];
278            let is_last = j == bytes.len() - 1;
279            if standalone.contains_short(b) {
280                j += 1;
281                continue;
282            }
283            if valued.contains_short(b) {
284                if is_last && consumes_next_value(tokens.get(i + 1)) {
285                    i += 1;
286                }
287                break;
288            }
289            if tolerance.unknown.allows_short() {
290                positionals += 1;
291                break;
292            }
293            return false;
294        }
295        i += 1;
296    }
297    max_positional.is_none_or(|max| positionals <= max)
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    static TEST_POLICY: FlagPolicy = FlagPolicy {
305        standalone: WordSet::flags(&[
306            "--color", "--count", "--help", "--recursive", "--version", "-H", "-c", "-i", "-l", "-n", "-o", "-r", "-s", "-v", "-w",
307        ]),
308        valued: WordSet::flags(&["--after-context", "--before-context", "--max-count", "-A", "-B", "-m"]),
309        bare: false,
310        max_positional: None,
311        tolerance: FlagTolerance::strict(),
312    };
313
314    fn toks(words: &[&str]) -> Vec<Token> {
315        words.iter().map(|s| Token::from_test(s)).collect()
316    }
317
318    #[test]
319    fn bare_denied_when_bare_false() {
320        assert!(!check(&toks(&["grep"]), &TEST_POLICY));
321    }
322
323    #[test]
324    fn bare_allowed_when_bare_true() {
325        let policy = FlagPolicy {
326            standalone: WordSet::flags(&[]),
327            valued: WordSet::flags(&[]),
328            bare: true,
329            max_positional: None,
330            tolerance: FlagTolerance::strict(),
331        };
332        assert!(check(&toks(&["uname"]), &policy));
333    }
334
335    /// What a flag declared in BOTH lists actually does — measured, because the answer decides
336    /// whether an optional-value flag needs new machinery or only a name.
337    ///
338    /// TODO.md recorded that the two declarations "contradict each other and only one can be
339    /// honoured". That is not what the walk does. `standalone` is consulted first, so the BARE
340    /// spelling matches there and cannot swallow the next token; the `=` branch below it consults
341    /// `valued`, so the GLUED spelling matches too. Both are honoured, for different spellings —
342    /// which is exactly optional-value semantics, and matches getopt, where an optional argument
343    /// must be glued (`--flag=x`, never `--flag x`).
344    ///
345    /// The gap is the SHORT glued form: `-r0` reaches the cluster loop, which walks byte by byte
346    /// and has no notion of "the rest of this token is my value", so it fails on `0`.
347    #[test]
348    fn a_flag_declared_in_both_lists_already_takes_an_optional_value() {
349        static BOTH: FlagPolicy = FlagPolicy {
350            standalone: WordSet::flags(&["--long", "-r"]),
351            valued: WordSet::flags(&["--long", "-r"]),
352            bare: true,
353            max_positional: Some(0),
354            tolerance: FlagTolerance::strict(),
355        };
356        assert!(check(&toks(&["zstd", "--long"]), &BOTH), "bare long form");
357        assert!(check(&toks(&["zstd", "--long=27"]), &BOTH), "glued long form");
358        assert!(check(&toks(&["zstd", "-r"]), &BOTH), "bare short form");
359
360        // The bare form must NOT eat the next token — that swallow is how a path becomes a
361        // positional and slips the flag gate.
362        assert!(!check(&toks(&["zstd", "--long", "somefile"]), &BOTH), "the bare form must not consume the following token as its value");
363
364        // The short glued form is the one shape this does not cover.
365        assert!(!check(&toks(&["7z", "-r0"]), &BOTH), "short-glued is NOT handled today");
366    }
367
368    #[test]
369    fn standalone_long_flag() {
370        assert!(check(&toks(&["grep", "--recursive", "pattern", "."]), &TEST_POLICY));
371    }
372
373    #[test]
374    fn standalone_short_flag() {
375        assert!(check(&toks(&["grep", "-r", "pattern", "."]), &TEST_POLICY));
376    }
377
378    #[test]
379    fn valued_long_flag_space() {
380        assert!(check(&toks(&["grep", "--max-count", "5", "pattern"]), &TEST_POLICY));
381    }
382
383    #[test]
384    fn valued_long_flag_eq() {
385        assert!(check(&toks(&["grep", "--max-count=5", "pattern"]), &TEST_POLICY));
386    }
387
388    #[test]
389    fn valued_short_flag_space() {
390        assert!(check(&toks(&["grep", "-m", "5", "pattern"]), &TEST_POLICY));
391    }
392
393    #[test]
394    fn combined_standalone_short() {
395        assert!(check(&toks(&["grep", "-rn", "pattern", "."]), &TEST_POLICY));
396    }
397
398    #[test]
399    fn combined_short_with_valued_last() {
400        assert!(check(&toks(&["grep", "-rnm", "5", "pattern"]), &TEST_POLICY));
401    }
402
403    #[test]
404    fn combined_short_valued_mid_consumes_rest() {
405        assert!(check(&toks(&["grep", "-rmn", "pattern"]), &TEST_POLICY));
406    }
407
408    #[test]
409    fn unknown_long_flag_denied() {
410        assert!(!check(&toks(&["grep", "--exec", "cmd"]), &TEST_POLICY));
411    }
412
413    #[test]
414    fn unknown_short_flag_denied() {
415        assert!(!check(&toks(&["grep", "-z", "pattern"]), &TEST_POLICY));
416    }
417
418    #[test]
419    fn unknown_combined_short_denied() {
420        assert!(!check(&toks(&["grep", "-rz", "pattern"]), &TEST_POLICY));
421    }
422
423    #[test]
424    fn unknown_long_eq_denied() {
425        assert!(!check(&toks(&["grep", "--output=file.txt", "pattern"]), &TEST_POLICY));
426    }
427
428    #[test]
429    fn double_dash_stops_checking() {
430        assert!(check(&toks(&["grep", "--", "--not-a-flag", "file"]), &TEST_POLICY));
431    }
432
433    #[test]
434    fn positional_args_allowed() {
435        assert!(check(&toks(&["grep", "pattern", "file.txt", "other.txt"]), &TEST_POLICY));
436    }
437
438    #[test]
439    fn mixed_flags_and_positional() {
440        assert!(check(&toks(&["grep", "-rn", "--color", "--max-count", "10", "pattern", "."]), &TEST_POLICY,));
441    }
442
443    #[test]
444    fn valued_short_in_explicit_form() {
445        assert!(check(&toks(&["grep", "-A", "3", "-B", "3", "pattern"]), &TEST_POLICY));
446    }
447
448    #[test]
449    fn bare_dash_allowed_as_stdin() {
450        assert!(check(&toks(&["grep", "pattern", "-"]), &TEST_POLICY));
451    }
452
453    #[test]
454    fn valued_flag_at_end_without_value() {
455        assert!(check(&toks(&["grep", "--max-count"]), &TEST_POLICY));
456    }
457
458    #[test]
459    fn single_short_in_wordset_and_byte_array() {
460        assert!(check(&toks(&["grep", "-c", "pattern"]), &TEST_POLICY));
461    }
462
463    static SYNTAX_CHECK_POLICY: FlagPolicy = FlagPolicy {
464        standalone: WordSet::flags(&["--help", "-h"]),
465        valued: WordSet::flags(&["--check", "-c"]),
466        bare: false,
467        max_positional: Some(0),
468        tolerance: FlagTolerance::strict(),
469    };
470
471    #[test]
472    fn valued_flag_consumes_path_value() {
473        assert!(check(&toks(&["node", "--check", "app.js"]), &SYNTAX_CHECK_POLICY));
474        assert!(check(&toks(&["node", "-c", "app.js"]), &SYNTAX_CHECK_POLICY));
475    }
476
477    #[test]
478    fn valued_flag_does_not_swallow_following_long_option() {
479        assert!(!check(&toks(&["node", "--check", "--require=./evil.js"]), &SYNTAX_CHECK_POLICY,));
480    }
481
482    #[test]
483    fn valued_short_does_not_swallow_following_option() {
484        assert!(!check(&toks(&["node", "-c", "-r./evil.js"]), &SYNTAX_CHECK_POLICY));
485    }
486
487    #[test]
488    fn valued_flag_still_consumes_negative_number() {
489        let policy = FlagPolicy {
490            standalone: WordSet::flags(&[]),
491            valued: WordSet::flags(&["-n"]),
492            bare: false,
493            max_positional: Some(1),
494            tolerance: FlagTolerance::strict(),
495        };
496        assert!(check(&toks(&["head", "-n", "-5", "file"]), &policy));
497    }
498
499    static LIMITED_POLICY: FlagPolicy = FlagPolicy {
500        standalone: WordSet::flags(&["--count", "-c", "-d", "-i", "-u"]),
501        valued: WordSet::flags(&["--skip-fields", "-f", "-s"]),
502        bare: true,
503        max_positional: Some(1),
504        tolerance: FlagTolerance::strict(),
505    };
506
507    #[test]
508    fn max_positional_within_limit() {
509        assert!(check(&toks(&["uniq", "input.txt"]), &LIMITED_POLICY));
510    }
511
512    #[test]
513    fn max_positional_exceeded() {
514        assert!(!check(&toks(&["uniq", "input.txt", "output.txt"]), &LIMITED_POLICY));
515    }
516
517    #[test]
518    fn max_positional_with_flags_within_limit() {
519        assert!(check(&toks(&["uniq", "-c", "-f", "3", "input.txt"]), &LIMITED_POLICY));
520    }
521
522    #[test]
523    fn max_positional_with_flags_exceeded() {
524        assert!(!check(&toks(&["uniq", "-c", "input.txt", "output.txt"]), &LIMITED_POLICY));
525    }
526
527    #[test]
528    fn max_positional_after_double_dash() {
529        assert!(!check(&toks(&["uniq", "--", "input.txt", "output.txt"]), &LIMITED_POLICY));
530    }
531
532    #[test]
533    fn max_positional_bare_allowed() {
534        assert!(check(&toks(&["uniq"]), &LIMITED_POLICY));
535    }
536
537    static BOTH_TOLERANCES_POLICY: FlagPolicy = FlagPolicy {
538        standalone: WordSet::flags(&["-E", "-e", "-n"]),
539        valued: WordSet::flags(&[]),
540        bare: true,
541        max_positional: None,
542        tolerance: FlagTolerance { unknown: UnknownTolerance::Both, numeric_dash: false },
543    };
544
545    #[test]
546    fn both_tolerances_accept_unknown_long() {
547        assert!(check(&toks(&["echo", "--unknown", "hello"]), &BOTH_TOLERANCES_POLICY));
548    }
549
550    #[test]
551    fn both_tolerances_accept_unknown_short() {
552        assert!(check(&toks(&["echo", "-x", "hello"]), &BOTH_TOLERANCES_POLICY));
553    }
554
555    #[test]
556    fn both_tolerances_accept_triple_dash() {
557        assert!(check(&toks(&["echo", "---"]), &BOTH_TOLERANCES_POLICY));
558    }
559
560    #[test]
561    fn both_tolerances_known_flags_still_work() {
562        assert!(check(&toks(&["echo", "-n", "hello"]), &BOTH_TOLERANCES_POLICY));
563    }
564
565    #[test]
566    fn both_tolerances_combo_known_short() {
567        assert!(check(&toks(&["echo", "-ne", "hello"]), &BOTH_TOLERANCES_POLICY));
568    }
569
570    #[test]
571    fn both_tolerances_combo_unknown_short_byte() {
572        assert!(check(&toks(&["echo", "-nx", "hello"]), &BOTH_TOLERANCES_POLICY));
573    }
574
575    #[test]
576    fn both_tolerances_unknown_eq_form() {
577        assert!(check(&toks(&["echo", "--foo=bar"]), &BOTH_TOLERANCES_POLICY));
578    }
579
580    // Narrow tolerance: short-only
581    // tolerate_unknown_short = true accepts unknown single-dash tokens
582    // (-X, -mayDie, -help) as positional, while leaving double-dash unknowns
583    // strict. This is the safer setting because most modern destructive
584    // flags are double-dash.
585
586    static SHORT_ONLY_POLICY: FlagPolicy = FlagPolicy {
587        standalone: WordSet::flags(&["--help"]),
588        valued: WordSet::flags(&[]),
589        bare: false,
590        max_positional: None,
591        tolerance: FlagTolerance { unknown: UnknownTolerance::Short, numeric_dash: false },
592    };
593
594    #[test]
595    fn short_only_accepts_unknown_dash_letter() {
596        assert!(check(&toks(&["sample", "-mayDie"]), &SHORT_ONLY_POLICY));
597    }
598
599    #[test]
600    fn short_only_accepts_single_dash_long_word() {
601        // pdftotext-style: `-help`, `-layout`, `-version` (single dash + word)
602        assert!(check(&toks(&["pdftotext", "-layout"]), &SHORT_ONLY_POLICY));
603    }
604
605    #[test]
606    fn short_only_denies_unknown_double_dash() {
607        // The whole point of the narrow split: --evil-flag must not slip
608        // through when only short-tolerance is on.
609        assert!(!check(&toks(&["sample", "--evil-flag"]), &SHORT_ONLY_POLICY));
610    }
611
612    #[test]
613    fn short_only_denies_unknown_eq_form() {
614        assert!(!check(&toks(&["sample", "--evil=value"]), &SHORT_ONLY_POLICY));
615    }
616
617    #[test]
618    fn short_only_known_long_flag_still_works() {
619        assert!(check(&toks(&["sample", "--help"]), &SHORT_ONLY_POLICY));
620    }
621
622    // Narrow tolerance: long-only
623    // tolerate_unknown_long = true accepts unknown double-dash tokens as
624    // positional. This is the dangerous form; reserved for tools like AWS
625    // CLI whose long-flag surface is genuinely unbounded.
626
627    static LONG_ONLY_POLICY: FlagPolicy = FlagPolicy {
628        standalone: WordSet::flags(&["--help"]),
629        valued: WordSet::flags(&[]),
630        bare: false,
631        max_positional: None,
632        tolerance: FlagTolerance { unknown: UnknownTolerance::Long, numeric_dash: false },
633    };
634
635    #[test]
636    fn long_only_accepts_unknown_double_dash() {
637        assert!(check(&toks(&["aws", "--some-aws-flag"]), &LONG_ONLY_POLICY));
638    }
639
640    #[test]
641    fn long_only_accepts_unknown_eq_form() {
642        assert!(check(&toks(&["aws", "--filter=Name=tag,Values=foo"]), &LONG_ONLY_POLICY,));
643    }
644
645    #[test]
646    fn long_only_denies_unknown_short_dash() {
647        assert!(!check(&toks(&["aws", "-x"]), &LONG_ONLY_POLICY));
648    }
649
650    // Both tolerances false: strict
651
652    static STRICT_POLICY: FlagPolicy = FlagPolicy {
653        standalone: WordSet::flags(&["--help"]),
654        valued: WordSet::flags(&[]),
655        bare: false,
656        max_positional: None,
657        tolerance: FlagTolerance::strict(),
658    };
659
660    #[test]
661    fn strict_denies_unknown_short() {
662        assert!(!check(&toks(&["foo", "-evil"]), &STRICT_POLICY));
663    }
664
665    #[test]
666    fn strict_denies_unknown_long() {
667        assert!(!check(&toks(&["foo", "--evil"]), &STRICT_POLICY));
668    }
669
670    #[test]
671    fn strict_known_flag_passes() {
672        assert!(check(&toks(&["foo", "--help"]), &STRICT_POLICY));
673    }
674
675    #[test]
676    fn both_tolerances_with_max_positional() {
677        let policy = FlagPolicy {
678            standalone: WordSet::flags(&["-n"]),
679            valued: WordSet::flags(&[]),
680            bare: true,
681            max_positional: Some(2),
682            tolerance: FlagTolerance { unknown: UnknownTolerance::Both, numeric_dash: false },
683        };
684        assert!(check(&toks(&["echo", "--unknown", "hello"]), &policy));
685        assert!(!check(&toks(&["echo", "--a", "--b", "--c"]), &policy));
686    }
687
688    static NUMERIC_DASH_POLICY: FlagPolicy = FlagPolicy {
689        standalone: WordSet::flags(&["--help", "--quiet", "--verbose", "--version", "-V", "-h", "-q", "-v", "-z"]),
690        valued: WordSet::flags(&["--bytes", "--lines", "-c", "-n"]),
691        bare: true,
692        max_positional: None,
693        tolerance: FlagTolerance { numeric_dash: true, ..FlagTolerance::strict() },
694    };
695
696    #[test]
697    fn numeric_dash_single_digit() {
698        assert!(check(&toks(&["head", "-5"]), &NUMERIC_DASH_POLICY));
699    }
700
701    #[test]
702    fn numeric_dash_multi_digit() {
703        assert!(check(&toks(&["head", "-20"]), &NUMERIC_DASH_POLICY));
704    }
705
706    #[test]
707    fn numeric_dash_large_number() {
708        assert!(check(&toks(&["head", "-1000"]), &NUMERIC_DASH_POLICY));
709    }
710
711    #[test]
712    fn numeric_dash_with_file_arg() {
713        assert!(check(&toks(&["head", "-20", "file.txt"]), &NUMERIC_DASH_POLICY));
714    }
715
716    #[test]
717    fn numeric_dash_with_other_flags() {
718        assert!(check(&toks(&["head", "-q", "-20", "file.txt"]), &NUMERIC_DASH_POLICY));
719    }
720
721    #[test]
722    fn numeric_dash_zero() {
723        assert!(check(&toks(&["head", "-0"]), &NUMERIC_DASH_POLICY));
724    }
725
726    #[test]
727    fn numeric_dash_still_rejects_unknown_flags() {
728        assert!(!check(&toks(&["head", "-x"]), &NUMERIC_DASH_POLICY));
729    }
730
731    #[test]
732    fn numeric_dash_rejects_mixed_alpha_num() {
733        assert!(!check(&toks(&["head", "-20x"]), &NUMERIC_DASH_POLICY));
734    }
735
736    #[test]
737    fn numeric_dash_disabled_rejects_multi_digit() {
738        assert!(!check(&toks(&["grep", "-20", "pattern"]), &TEST_POLICY));
739    }
740
741    #[test]
742    fn looks_like_path_accepts_relative() {
743        assert!(looks_like_path("./Tiltfile"));
744        assert!(looks_like_path("path/to/file"));
745    }
746
747    #[test]
748    fn looks_like_path_accepts_dotted() {
749        assert!(looks_like_path("Tiltfile.dev"));
750        assert!(looks_like_path("file.rb"));
751    }
752
753    #[test]
754    fn looks_like_path_accepts_stdin_dash() {
755        assert!(looks_like_path("-"));
756    }
757
758    #[test]
759    fn looks_like_path_rejects_flag() {
760        assert!(!looks_like_path("--help"));
761        assert!(!looks_like_path("-x"));
762    }
763
764    #[test]
765    fn looks_like_path_rejects_bare_word() {
766        assert!(!looks_like_path("Tiltfile"));
767        assert!(!looks_like_path("up"));
768    }
769
770    #[test]
771    fn looks_like_path_rejects_empty() {
772        assert!(!looks_like_path(""));
773    }
774
775    #[test]
776    fn positional_shape_path_matches() {
777        assert!(PositionalShape::Path.matches("./file.rb"));
778        assert!(!PositionalShape::Path.matches("--flag"));
779    }
780
781    #[test]
782    fn positional_shape_from_name() {
783        assert_eq!(PositionalShape::from_name("path"), Some(PositionalShape::Path));
784        assert_eq!(PositionalShape::from_name("nope"), None);
785    }
786}