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) => {
191            let b = t.as_bytes();
192            // An option-like token (starts with `-` and isn't a negative
193            // number) is never the value of a preceding valued flag. Consuming
194            // it would let an execution-enabling flag ride in as a bogus
195            // "value" — e.g. `node --check --require=evil.js`, where node runs
196            // the `--require` preload even in syntax-check mode. Negative
197            // numbers (`head -n -5`) and a lone `-` (stdin) are real values.
198            !(b.len() > 1 && b[0] == b'-' && !b[1].is_ascii_digit())
199        }
200    }
201}
202
203pub fn check_flags<S: FlagSet + ?Sized, V: FlagSet + ?Sized>(
204    tokens: &[Token],
205    standalone: &S,
206    valued: &V,
207    bare: bool,
208    max_positional: Option<usize>,
209    tolerance: FlagTolerance,
210) -> bool {
211    if tokens.len() == 1 {
212        return bare;
213    }
214
215    let mut i = 1;
216    let mut positionals: usize = 0;
217    while i < tokens.len() {
218        let t = &tokens[i];
219
220        if *t == "--" {
221            positionals += tokens.len() - i - 1;
222            break;
223        }
224
225        if !t.starts_with('-') {
226            positionals += 1;
227            i += 1;
228            continue;
229        }
230
231        if tolerance.numeric_dash && t.len() > 1 && t[1..].bytes().all(|b| b.is_ascii_digit()) {
232            i += 1;
233            continue;
234        }
235
236        if standalone.contains_flag(t) {
237            i += 1;
238            continue;
239        }
240
241        if valued.contains_flag(t) {
242            if consumes_next_value(tokens.get(i + 1)) {
243                i += 2;
244            } else {
245                i += 1;
246            }
247            continue;
248        }
249
250        if let Some(flag) = t.as_str().split_once('=').map(|(f, _)| f) {
251            if valued.contains_flag(flag) {
252                i += 1;
253                continue;
254            }
255            // `--foo=value` forms are governed by the long-flag tolerance.
256            if tolerance.unknown.allows_long() {
257                positionals += 1;
258                i += 1;
259                continue;
260            }
261            return false;
262        }
263
264        if t.starts_with("--") {
265            if tolerance.unknown.allows_long() {
266                positionals += 1;
267                i += 1;
268                continue;
269            }
270            return false;
271        }
272
273        let bytes = t.as_bytes();
274        let mut j = 1;
275        while j < bytes.len() {
276            let b = bytes[j];
277            let is_last = j == bytes.len() - 1;
278            if standalone.contains_short(b) {
279                j += 1;
280                continue;
281            }
282            if valued.contains_short(b) {
283                if is_last && consumes_next_value(tokens.get(i + 1)) {
284                    i += 1;
285                }
286                break;
287            }
288            if tolerance.unknown.allows_short() {
289                positionals += 1;
290                break;
291            }
292            return false;
293        }
294        i += 1;
295    }
296    max_positional.is_none_or(|max| positionals <= max)
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    static TEST_POLICY: FlagPolicy = FlagPolicy {
304        standalone: WordSet::flags(&[
305            "--color", "--count", "--help", "--recursive", "--version", "-H", "-c", "-i", "-l", "-n", "-o", "-r", "-s", "-v", "-w",
306        ]),
307        valued: WordSet::flags(&["--after-context", "--before-context", "--max-count", "-A", "-B", "-m"]),
308        bare: false,
309        max_positional: None,
310        tolerance: FlagTolerance::strict(),
311    };
312
313    fn toks(words: &[&str]) -> Vec<Token> {
314        words.iter().map(|s| Token::from_test(s)).collect()
315    }
316
317    #[test]
318    fn bare_denied_when_bare_false() {
319        assert!(!check(&toks(&["grep"]), &TEST_POLICY));
320    }
321
322    #[test]
323    fn bare_allowed_when_bare_true() {
324        let policy = FlagPolicy {
325            standalone: WordSet::flags(&[]),
326            valued: WordSet::flags(&[]),
327            bare: true,
328            max_positional: None,
329            tolerance: FlagTolerance::strict(),
330        };
331        assert!(check(&toks(&["uname"]), &policy));
332    }
333
334    /// What a flag declared in BOTH lists actually does — measured, because the answer decides
335    /// whether an optional-value flag needs new machinery or only a name.
336    ///
337    /// TODO.md recorded that the two declarations "contradict each other and only one can be
338    /// honoured". That is not what the walk does. `standalone` is consulted first, so the BARE
339    /// spelling matches there and cannot swallow the next token; the `=` branch below it consults
340    /// `valued`, so the GLUED spelling matches too. Both are honoured, for different spellings —
341    /// which is exactly optional-value semantics, and matches getopt, where an optional argument
342    /// must be glued (`--flag=x`, never `--flag x`).
343    ///
344    /// The gap is the SHORT glued form: `-r0` reaches the cluster loop, which walks byte by byte
345    /// and has no notion of "the rest of this token is my value", so it fails on `0`.
346    #[test]
347    fn a_flag_declared_in_both_lists_already_takes_an_optional_value() {
348        static BOTH: FlagPolicy = FlagPolicy {
349            standalone: WordSet::flags(&["--long", "-r"]),
350            valued: WordSet::flags(&["--long", "-r"]),
351            bare: true,
352            max_positional: Some(0),
353            tolerance: FlagTolerance::strict(),
354        };
355        assert!(check(&toks(&["zstd", "--long"]), &BOTH), "bare long form");
356        assert!(check(&toks(&["zstd", "--long=27"]), &BOTH), "glued long form");
357        assert!(check(&toks(&["zstd", "-r"]), &BOTH), "bare short form");
358
359        // The bare form must NOT eat the next token — that swallow is how a path becomes a
360        // positional and slips the flag gate.
361        assert!(!check(&toks(&["zstd", "--long", "somefile"]), &BOTH), "the bare form must not consume the following token as its value");
362
363        // The short glued form is the one shape this does not cover.
364        assert!(!check(&toks(&["7z", "-r0"]), &BOTH), "short-glued is NOT handled today");
365    }
366
367    #[test]
368    fn standalone_long_flag() {
369        assert!(check(&toks(&["grep", "--recursive", "pattern", "."]), &TEST_POLICY));
370    }
371
372    #[test]
373    fn standalone_short_flag() {
374        assert!(check(&toks(&["grep", "-r", "pattern", "."]), &TEST_POLICY));
375    }
376
377    #[test]
378    fn valued_long_flag_space() {
379        assert!(check(&toks(&["grep", "--max-count", "5", "pattern"]), &TEST_POLICY));
380    }
381
382    #[test]
383    fn valued_long_flag_eq() {
384        assert!(check(&toks(&["grep", "--max-count=5", "pattern"]), &TEST_POLICY));
385    }
386
387    #[test]
388    fn valued_short_flag_space() {
389        assert!(check(&toks(&["grep", "-m", "5", "pattern"]), &TEST_POLICY));
390    }
391
392    #[test]
393    fn combined_standalone_short() {
394        assert!(check(&toks(&["grep", "-rn", "pattern", "."]), &TEST_POLICY));
395    }
396
397    #[test]
398    fn combined_short_with_valued_last() {
399        assert!(check(&toks(&["grep", "-rnm", "5", "pattern"]), &TEST_POLICY));
400    }
401
402    #[test]
403    fn combined_short_valued_mid_consumes_rest() {
404        assert!(check(&toks(&["grep", "-rmn", "pattern"]), &TEST_POLICY));
405    }
406
407    #[test]
408    fn unknown_long_flag_denied() {
409        assert!(!check(&toks(&["grep", "--exec", "cmd"]), &TEST_POLICY));
410    }
411
412    #[test]
413    fn unknown_short_flag_denied() {
414        assert!(!check(&toks(&["grep", "-z", "pattern"]), &TEST_POLICY));
415    }
416
417    #[test]
418    fn unknown_combined_short_denied() {
419        assert!(!check(&toks(&["grep", "-rz", "pattern"]), &TEST_POLICY));
420    }
421
422    #[test]
423    fn unknown_long_eq_denied() {
424        assert!(!check(&toks(&["grep", "--output=file.txt", "pattern"]), &TEST_POLICY));
425    }
426
427    #[test]
428    fn double_dash_stops_checking() {
429        assert!(check(&toks(&["grep", "--", "--not-a-flag", "file"]), &TEST_POLICY));
430    }
431
432    #[test]
433    fn positional_args_allowed() {
434        assert!(check(&toks(&["grep", "pattern", "file.txt", "other.txt"]), &TEST_POLICY));
435    }
436
437    #[test]
438    fn mixed_flags_and_positional() {
439        assert!(check(&toks(&["grep", "-rn", "--color", "--max-count", "10", "pattern", "."]), &TEST_POLICY,));
440    }
441
442    #[test]
443    fn valued_short_in_explicit_form() {
444        assert!(check(&toks(&["grep", "-A", "3", "-B", "3", "pattern"]), &TEST_POLICY));
445    }
446
447    #[test]
448    fn bare_dash_allowed_as_stdin() {
449        assert!(check(&toks(&["grep", "pattern", "-"]), &TEST_POLICY));
450    }
451
452    #[test]
453    fn valued_flag_at_end_without_value() {
454        assert!(check(&toks(&["grep", "--max-count"]), &TEST_POLICY));
455    }
456
457    #[test]
458    fn single_short_in_wordset_and_byte_array() {
459        assert!(check(&toks(&["grep", "-c", "pattern"]), &TEST_POLICY));
460    }
461
462    static SYNTAX_CHECK_POLICY: FlagPolicy = FlagPolicy {
463        standalone: WordSet::flags(&["--help", "-h"]),
464        valued: WordSet::flags(&["--check", "-c"]),
465        bare: false,
466        max_positional: Some(0),
467        tolerance: FlagTolerance::strict(),
468    };
469
470    #[test]
471    fn valued_flag_consumes_path_value() {
472        assert!(check(&toks(&["node", "--check", "app.js"]), &SYNTAX_CHECK_POLICY));
473        assert!(check(&toks(&["node", "-c", "app.js"]), &SYNTAX_CHECK_POLICY));
474    }
475
476    #[test]
477    fn valued_flag_does_not_swallow_following_long_option() {
478        assert!(!check(&toks(&["node", "--check", "--require=./evil.js"]), &SYNTAX_CHECK_POLICY,));
479    }
480
481    #[test]
482    fn valued_short_does_not_swallow_following_option() {
483        assert!(!check(&toks(&["node", "-c", "-r./evil.js"]), &SYNTAX_CHECK_POLICY));
484    }
485
486    #[test]
487    fn valued_flag_still_consumes_negative_number() {
488        let policy = FlagPolicy {
489            standalone: WordSet::flags(&[]),
490            valued: WordSet::flags(&["-n"]),
491            bare: false,
492            max_positional: Some(1),
493            tolerance: FlagTolerance::strict(),
494        };
495        assert!(check(&toks(&["head", "-n", "-5", "file"]), &policy));
496    }
497
498    static LIMITED_POLICY: FlagPolicy = FlagPolicy {
499        standalone: WordSet::flags(&["--count", "-c", "-d", "-i", "-u"]),
500        valued: WordSet::flags(&["--skip-fields", "-f", "-s"]),
501        bare: true,
502        max_positional: Some(1),
503        tolerance: FlagTolerance::strict(),
504    };
505
506    #[test]
507    fn max_positional_within_limit() {
508        assert!(check(&toks(&["uniq", "input.txt"]), &LIMITED_POLICY));
509    }
510
511    #[test]
512    fn max_positional_exceeded() {
513        assert!(!check(&toks(&["uniq", "input.txt", "output.txt"]), &LIMITED_POLICY));
514    }
515
516    #[test]
517    fn max_positional_with_flags_within_limit() {
518        assert!(check(&toks(&["uniq", "-c", "-f", "3", "input.txt"]), &LIMITED_POLICY));
519    }
520
521    #[test]
522    fn max_positional_with_flags_exceeded() {
523        assert!(!check(&toks(&["uniq", "-c", "input.txt", "output.txt"]), &LIMITED_POLICY));
524    }
525
526    #[test]
527    fn max_positional_after_double_dash() {
528        assert!(!check(&toks(&["uniq", "--", "input.txt", "output.txt"]), &LIMITED_POLICY));
529    }
530
531    #[test]
532    fn max_positional_bare_allowed() {
533        assert!(check(&toks(&["uniq"]), &LIMITED_POLICY));
534    }
535
536    static BOTH_TOLERANCES_POLICY: FlagPolicy = FlagPolicy {
537        standalone: WordSet::flags(&["-E", "-e", "-n"]),
538        valued: WordSet::flags(&[]),
539        bare: true,
540        max_positional: None,
541        tolerance: FlagTolerance { unknown: UnknownTolerance::Both, numeric_dash: false },
542    };
543
544    #[test]
545    fn both_tolerances_accept_unknown_long() {
546        assert!(check(&toks(&["echo", "--unknown", "hello"]), &BOTH_TOLERANCES_POLICY));
547    }
548
549    #[test]
550    fn both_tolerances_accept_unknown_short() {
551        assert!(check(&toks(&["echo", "-x", "hello"]), &BOTH_TOLERANCES_POLICY));
552    }
553
554    #[test]
555    fn both_tolerances_accept_triple_dash() {
556        assert!(check(&toks(&["echo", "---"]), &BOTH_TOLERANCES_POLICY));
557    }
558
559    #[test]
560    fn both_tolerances_known_flags_still_work() {
561        assert!(check(&toks(&["echo", "-n", "hello"]), &BOTH_TOLERANCES_POLICY));
562    }
563
564    #[test]
565    fn both_tolerances_combo_known_short() {
566        assert!(check(&toks(&["echo", "-ne", "hello"]), &BOTH_TOLERANCES_POLICY));
567    }
568
569    #[test]
570    fn both_tolerances_combo_unknown_short_byte() {
571        assert!(check(&toks(&["echo", "-nx", "hello"]), &BOTH_TOLERANCES_POLICY));
572    }
573
574    #[test]
575    fn both_tolerances_unknown_eq_form() {
576        assert!(check(&toks(&["echo", "--foo=bar"]), &BOTH_TOLERANCES_POLICY));
577    }
578
579    // Narrow tolerance: short-only
580    // tolerate_unknown_short = true accepts unknown single-dash tokens
581    // (-X, -mayDie, -help) as positional, while leaving double-dash unknowns
582    // strict. This is the safer setting because most modern destructive
583    // flags are double-dash.
584
585    static SHORT_ONLY_POLICY: FlagPolicy = FlagPolicy {
586        standalone: WordSet::flags(&["--help"]),
587        valued: WordSet::flags(&[]),
588        bare: false,
589        max_positional: None,
590        tolerance: FlagTolerance { unknown: UnknownTolerance::Short, numeric_dash: false },
591    };
592
593    #[test]
594    fn short_only_accepts_unknown_dash_letter() {
595        assert!(check(&toks(&["sample", "-mayDie"]), &SHORT_ONLY_POLICY));
596    }
597
598    #[test]
599    fn short_only_accepts_single_dash_long_word() {
600        // pdftotext-style: `-help`, `-layout`, `-version` (single dash + word)
601        assert!(check(&toks(&["pdftotext", "-layout"]), &SHORT_ONLY_POLICY));
602    }
603
604    #[test]
605    fn short_only_denies_unknown_double_dash() {
606        // The whole point of the narrow split: --evil-flag must not slip
607        // through when only short-tolerance is on.
608        assert!(!check(&toks(&["sample", "--evil-flag"]), &SHORT_ONLY_POLICY));
609    }
610
611    #[test]
612    fn short_only_denies_unknown_eq_form() {
613        assert!(!check(&toks(&["sample", "--evil=value"]), &SHORT_ONLY_POLICY));
614    }
615
616    #[test]
617    fn short_only_known_long_flag_still_works() {
618        assert!(check(&toks(&["sample", "--help"]), &SHORT_ONLY_POLICY));
619    }
620
621    // Narrow tolerance: long-only
622    // tolerate_unknown_long = true accepts unknown double-dash tokens as
623    // positional. This is the dangerous form; reserved for tools like AWS
624    // CLI whose long-flag surface is genuinely unbounded.
625
626    static LONG_ONLY_POLICY: FlagPolicy = FlagPolicy {
627        standalone: WordSet::flags(&["--help"]),
628        valued: WordSet::flags(&[]),
629        bare: false,
630        max_positional: None,
631        tolerance: FlagTolerance { unknown: UnknownTolerance::Long, numeric_dash: false },
632    };
633
634    #[test]
635    fn long_only_accepts_unknown_double_dash() {
636        assert!(check(&toks(&["aws", "--some-aws-flag"]), &LONG_ONLY_POLICY));
637    }
638
639    #[test]
640    fn long_only_accepts_unknown_eq_form() {
641        assert!(check(&toks(&["aws", "--filter=Name=tag,Values=foo"]), &LONG_ONLY_POLICY,));
642    }
643
644    #[test]
645    fn long_only_denies_unknown_short_dash() {
646        assert!(!check(&toks(&["aws", "-x"]), &LONG_ONLY_POLICY));
647    }
648
649    // Both tolerances false: strict
650
651    static STRICT_POLICY: FlagPolicy = FlagPolicy {
652        standalone: WordSet::flags(&["--help"]),
653        valued: WordSet::flags(&[]),
654        bare: false,
655        max_positional: None,
656        tolerance: FlagTolerance::strict(),
657    };
658
659    #[test]
660    fn strict_denies_unknown_short() {
661        assert!(!check(&toks(&["foo", "-evil"]), &STRICT_POLICY));
662    }
663
664    #[test]
665    fn strict_denies_unknown_long() {
666        assert!(!check(&toks(&["foo", "--evil"]), &STRICT_POLICY));
667    }
668
669    #[test]
670    fn strict_known_flag_passes() {
671        assert!(check(&toks(&["foo", "--help"]), &STRICT_POLICY));
672    }
673
674    #[test]
675    fn both_tolerances_with_max_positional() {
676        let policy = FlagPolicy {
677            standalone: WordSet::flags(&["-n"]),
678            valued: WordSet::flags(&[]),
679            bare: true,
680            max_positional: Some(2),
681            tolerance: FlagTolerance { unknown: UnknownTolerance::Both, numeric_dash: false },
682        };
683        assert!(check(&toks(&["echo", "--unknown", "hello"]), &policy));
684        assert!(!check(&toks(&["echo", "--a", "--b", "--c"]), &policy));
685    }
686
687    static NUMERIC_DASH_POLICY: FlagPolicy = FlagPolicy {
688        standalone: WordSet::flags(&["--help", "--quiet", "--verbose", "--version", "-V", "-h", "-q", "-v", "-z"]),
689        valued: WordSet::flags(&["--bytes", "--lines", "-c", "-n"]),
690        bare: true,
691        max_positional: None,
692        tolerance: FlagTolerance { numeric_dash: true, ..FlagTolerance::strict() },
693    };
694
695    #[test]
696    fn numeric_dash_single_digit() {
697        assert!(check(&toks(&["head", "-5"]), &NUMERIC_DASH_POLICY));
698    }
699
700    #[test]
701    fn numeric_dash_multi_digit() {
702        assert!(check(&toks(&["head", "-20"]), &NUMERIC_DASH_POLICY));
703    }
704
705    #[test]
706    fn numeric_dash_large_number() {
707        assert!(check(&toks(&["head", "-1000"]), &NUMERIC_DASH_POLICY));
708    }
709
710    #[test]
711    fn numeric_dash_with_file_arg() {
712        assert!(check(&toks(&["head", "-20", "file.txt"]), &NUMERIC_DASH_POLICY));
713    }
714
715    #[test]
716    fn numeric_dash_with_other_flags() {
717        assert!(check(&toks(&["head", "-q", "-20", "file.txt"]), &NUMERIC_DASH_POLICY));
718    }
719
720    #[test]
721    fn numeric_dash_zero() {
722        assert!(check(&toks(&["head", "-0"]), &NUMERIC_DASH_POLICY));
723    }
724
725    #[test]
726    fn numeric_dash_still_rejects_unknown_flags() {
727        assert!(!check(&toks(&["head", "-x"]), &NUMERIC_DASH_POLICY));
728    }
729
730    #[test]
731    fn numeric_dash_rejects_mixed_alpha_num() {
732        assert!(!check(&toks(&["head", "-20x"]), &NUMERIC_DASH_POLICY));
733    }
734
735    #[test]
736    fn numeric_dash_disabled_rejects_multi_digit() {
737        assert!(!check(&toks(&["grep", "-20", "pattern"]), &TEST_POLICY));
738    }
739
740    #[test]
741    fn looks_like_path_accepts_relative() {
742        assert!(looks_like_path("./Tiltfile"));
743        assert!(looks_like_path("path/to/file"));
744    }
745
746    #[test]
747    fn looks_like_path_accepts_dotted() {
748        assert!(looks_like_path("Tiltfile.dev"));
749        assert!(looks_like_path("file.rb"));
750    }
751
752    #[test]
753    fn looks_like_path_accepts_stdin_dash() {
754        assert!(looks_like_path("-"));
755    }
756
757    #[test]
758    fn looks_like_path_rejects_flag() {
759        assert!(!looks_like_path("--help"));
760        assert!(!looks_like_path("-x"));
761    }
762
763    #[test]
764    fn looks_like_path_rejects_bare_word() {
765        assert!(!looks_like_path("Tiltfile"));
766        assert!(!looks_like_path("up"));
767    }
768
769    #[test]
770    fn looks_like_path_rejects_empty() {
771        assert!(!looks_like_path(""));
772    }
773
774    #[test]
775    fn positional_shape_path_matches() {
776        assert!(PositionalShape::Path.matches("./file.rb"));
777        assert!(!PositionalShape::Path.matches("--flag"));
778    }
779
780    #[test]
781    fn positional_shape_from_name() {
782        assert_eq!(PositionalShape::from_name("path"), Some(PositionalShape::Path));
783        assert_eq!(PositionalShape::from_name("nope"), None);
784    }
785}