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