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