Skip to main content

yosh_plugin_api/
pattern.rs

1//! Glob-style argv allowlist patterns for the `commands:exec` capability.
2//!
3//! See `docs/superpowers/specs/2026-04-29-plugin-commands-exec-capability-design.md` §4.
4
5/// A parsed allowlist pattern. Matches against an argv slice of any
6/// `AsRef<str>` type (e.g. `&[String]`, `&[&str]`, `&[Cow<'_, str>]`).
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct CommandPattern {
9    pub tokens: Vec<String>,
10    pub has_glob_suffix: bool,
11}
12
13impl CommandPattern {
14    /// Parse a single pattern string. Tokens are whitespace-separated.
15    /// A trailing `:*` (no whitespace before it) marks the pattern as
16    /// a prefix match; otherwise the pattern is exact-length.
17    ///
18    /// Errors:
19    /// * empty / whitespace-only input
20    /// * a lone `:*` with no preceding tokens
21    pub fn parse(s: &str) -> Result<Self, String> {
22        let trimmed = s.trim();
23        if trimmed.is_empty() {
24            return Err("empty pattern".to_string());
25        }
26
27        let (body, has_glob_suffix) = if let Some(stripped) = trimmed.strip_suffix(":*") {
28            (stripped.trim_end(), true)
29        } else {
30            (trimmed, false)
31        };
32
33        if body.is_empty() {
34            return Err("pattern has `:*` but no tokens".to_string());
35        }
36
37        if body.contains(":*") {
38            return Err(
39                "`:*` may only appear as a trailing suffix on the whole pattern".to_string(),
40            );
41        }
42
43        let tokens: Vec<String> = body.split_whitespace().map(|t| t.to_string()).collect();
44
45        Ok(CommandPattern {
46            tokens,
47            has_glob_suffix,
48        })
49    }
50
51    /// Match this pattern against an argv slice (`[program, arg1, arg2, ...]`).
52    ///
53    /// Generic over `S: AsRef<str>` so callers can pass `&[String]`
54    /// (existing) or `&[&str]` / `&[Cow<'_, str>]` (the canonical-ABI
55    /// borrow path in `host_commands_exec`).
56    pub fn matches<S: AsRef<str>>(&self, argv: &[S]) -> bool {
57        if self.has_glob_suffix {
58            argv.len() >= self.tokens.len()
59                && self
60                    .tokens
61                    .iter()
62                    .zip(argv)
63                    .all(|(p, a)| p.as_str() == a.as_ref())
64        } else {
65            argv.len() == self.tokens.len()
66                && self
67                    .tokens
68                    .iter()
69                    .zip(argv)
70                    .all(|(p, a)| p.as_str() == a.as_ref())
71        }
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn parse_glob_suffix_separates_tokens() {
81        let p = CommandPattern::parse("git log:*").unwrap();
82        assert_eq!(p.tokens, vec!["git".to_string(), "log".to_string()]);
83        assert!(p.has_glob_suffix);
84    }
85
86    #[test]
87    fn parse_no_suffix_is_exact() {
88        let p = CommandPattern::parse("git log").unwrap();
89        assert_eq!(p.tokens, vec!["git".to_string(), "log".to_string()]);
90        assert!(!p.has_glob_suffix);
91    }
92
93    #[test]
94    fn parse_empty_string_errors() {
95        assert!(CommandPattern::parse("").is_err());
96        assert!(CommandPattern::parse("   ").is_err());
97    }
98
99    #[test]
100    fn parse_lone_glob_suffix_errors() {
101        assert!(CommandPattern::parse(":*").is_err());
102        assert!(CommandPattern::parse("  :*").is_err());
103    }
104
105    #[test]
106    fn match_glob_suffix_zero_extra() {
107        let p = CommandPattern::parse("git:*").unwrap();
108        assert!(p.matches(&["git".to_string()]));
109    }
110
111    #[test]
112    fn match_glob_suffix_many_extra() {
113        let p = CommandPattern::parse("git:*").unwrap();
114        assert!(p.matches(&["git".to_string(), "log".to_string(), "-p".to_string(),]));
115    }
116
117    #[test]
118    fn match_exact_requires_equal_length() {
119        let p = CommandPattern::parse("git status").unwrap();
120        assert!(p.matches(&["git".to_string(), "status".to_string()]));
121        assert!(!p.matches(&[
122            "git".to_string(),
123            "status".to_string(),
124            "--porcelain".to_string()
125        ]));
126        assert!(!p.matches(&["git".to_string()]));
127    }
128
129    #[test]
130    fn match_literal_compare() {
131        let p = CommandPattern::parse("git:*").unwrap();
132        assert!(!p.matches(&["/usr/bin/git".to_string(), "status".to_string()]));
133    }
134
135    #[test]
136    fn parse_mid_string_glob_suffix_errors() {
137        assert!(CommandPattern::parse("git:*:*").is_err());
138        assert!(CommandPattern::parse("git:* status:*").is_err());
139        assert!(CommandPattern::parse("foo:* bar").is_err());
140    }
141
142    #[test]
143    fn match_glob_suffix_subcommand_lock() {
144        let p = CommandPattern::parse("git status:*").unwrap();
145        assert!(p.matches(&["git".to_string(), "status".to_string()]));
146        assert!(p.matches(&[
147            "git".to_string(),
148            "status".to_string(),
149            "--porcelain".to_string()
150        ]));
151        assert!(!p.matches(&["git".to_string(), "log".to_string()]));
152    }
153
154    #[test]
155    fn matches_accepts_str_slice_argv() {
156        // Locks down the §4.1 follow-up generalization: matcher must
157        // accept &[&str] (used by host_commands_exec after the borrow
158        // rollout) as well as &[String] (existing callsites).
159        let p = CommandPattern::parse("/bin/echo:*").unwrap();
160        let argv: &[&str] = &["/bin/echo", "a", "b"];
161        assert!(p.matches(argv));
162    }
163}