Skip to main content

skiff_cli/
filter.rs

1//! Command include/exclude/method filtering (bake mode).
2
3use crate::model::CommandDef;
4
5/// Filter by HTTP method whitelist, then include globs, then exclude globs.
6///
7/// MCP commands (`method` is None) pass the methods filter unchanged.
8pub fn filter_commands(
9    commands: Vec<CommandDef>,
10    include: &[String],
11    exclude: &[String],
12    methods: &[String],
13) -> Vec<CommandDef> {
14    let mut result = commands;
15    if !methods.is_empty() {
16        let upper: Vec<String> = methods.iter().map(|m| m.to_uppercase()).collect();
17        result.retain(|c| {
18            c.method
19                .as_ref()
20                .map(|m| upper.iter().any(|u| u == &m.to_uppercase()))
21                .unwrap_or(true)
22        });
23    }
24    if !include.is_empty() {
25        result.retain(|c| include.iter().any(|pat| glob_match(pat, &c.name)));
26    }
27    if !exclude.is_empty() {
28        result.retain(|c| !exclude.iter().any(|pat| glob_match(pat, &c.name)));
29    }
30    result
31}
32
33/// Minimal glob: `*` matches any substring (fnmatch-style for bake patterns).
34fn glob_match(pattern: &str, name: &str) -> bool {
35    if !pattern.contains('*') {
36        return pattern == name;
37    }
38    let parts: Vec<&str> = pattern.split('*').collect();
39    if parts.len() == 1 {
40        return pattern == name;
41    }
42    let mut rest = name;
43    if !parts[0].is_empty() {
44        if !rest.starts_with(parts[0]) {
45            return false;
46        }
47        rest = &rest[parts[0].len()..];
48    }
49    for (i, part) in parts.iter().enumerate().skip(1) {
50        if part.is_empty() {
51            if i == parts.len() - 1 {
52                return true;
53            }
54            continue;
55        }
56        if i == parts.len() - 1 {
57            return rest.ends_with(part);
58        }
59        if let Some(idx) = rest.find(part) {
60            rest = &rest[idx + part.len()..];
61        } else {
62            return false;
63        }
64    }
65    true
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    fn cmds() -> Vec<CommandDef> {
73        vec![
74            CommandDef {
75                name: "list-pets".into(),
76                method: Some("GET".into()),
77                ..Default::default()
78            },
79            CommandDef {
80                name: "create-pet".into(),
81                method: Some("POST".into()),
82                ..Default::default()
83            },
84            CommandDef {
85                name: "delete-pet".into(),
86                method: Some("DELETE".into()),
87                ..Default::default()
88            },
89            CommandDef {
90                name: "update-pet".into(),
91                method: Some("PUT".into()),
92                ..Default::default()
93            },
94            CommandDef {
95                name: "echo".into(),
96                tool_name: Some("echo".into()),
97                ..Default::default()
98            },
99        ]
100    }
101
102    fn names(cmds: &[CommandDef]) -> Vec<&str> {
103        cmds.iter().map(|c| c.name.as_str()).collect()
104    }
105
106    #[test]
107    fn glob_basics() {
108        assert!(glob_match("list-*", "list-pets"));
109        assert!(!glob_match("list-*", "create-pet"));
110        assert!(glob_match("*-pet", "get-pet"));
111        assert!(glob_match("*", "anything"));
112    }
113
114    #[test]
115    fn no_filters() {
116        let c = cmds();
117        assert_eq!(names(&filter_commands(c.clone(), &[], &[], &[])), names(&c));
118    }
119
120    #[test]
121    fn methods_filter() {
122        let methods = vec!["GET".into(), "POST".into()];
123        let result = filter_commands(cmds(), &[], &[], &methods);
124        let n = names(&result);
125        assert!(n.contains(&"list-pets"));
126        assert!(n.contains(&"create-pet"));
127        assert!(!n.contains(&"delete-pet"));
128        assert!(!n.contains(&"update-pet"));
129        assert!(n.contains(&"echo")); // MCP passes through
130    }
131
132    #[test]
133    fn include_filter() {
134        let include = vec!["list-*".into()];
135        let result = filter_commands(cmds(), &include, &[], &[]);
136        assert_eq!(names(&result), vec!["list-pets"]);
137    }
138
139    #[test]
140    fn exclude_filter() {
141        let exclude = vec!["delete-*".into(), "update-*".into()];
142        let result = filter_commands(cmds(), &[], &exclude, &[]);
143        let n = names(&result);
144        assert!(n.contains(&"list-pets"));
145        assert!(n.contains(&"create-pet"));
146        assert!(!n.contains(&"delete-pet"));
147        assert!(!n.contains(&"update-pet"));
148    }
149
150    #[test]
151    fn combined_filters() {
152        let methods = vec!["GET".into(), "POST".into()];
153        let exclude = vec!["create-*".into()];
154        let result = filter_commands(cmds(), &[], &exclude, &methods);
155        assert_eq!(names(&result), vec!["list-pets", "echo"]);
156    }
157
158    #[test]
159    fn include_and_exclude() {
160        let include = vec!["*-pet".into()];
161        let exclude = vec!["delete-*".into()];
162        let result = filter_commands(cmds(), &include, &exclude, &[]);
163        let n = names(&result);
164        assert!(n.contains(&"create-pet"));
165        assert!(n.contains(&"update-pet"));
166        assert!(!n.contains(&"delete-pet"));
167        assert!(!n.contains(&"list-pets"));
168    }
169
170    #[test]
171    fn case_insensitive_methods() {
172        let methods = vec!["get".into()];
173        let result = filter_commands(cmds(), &[], &[], &methods);
174        assert!(names(&result).contains(&"list-pets"));
175    }
176}