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