Skip to main content

rippy_cli/config/
parser.rs

1use std::path::PathBuf;
2
3use crate::verdict::Decision;
4
5use super::types::{ConfigDirective, Rule, RuleTarget};
6
7/// A token from a config line, tagged as quoted or unquoted.
8#[derive(Debug)]
9enum Token {
10    Bare(String),
11    Quoted(String),
12}
13
14/// Tokenize a config line, respecting quoted strings.
15/// Returns tagged tokens so callers can distinguish patterns from messages.
16fn tokenize_config_line(line: &str) -> Vec<Token> {
17    let mut tokens = Vec::new();
18    let mut chars = line.chars().peekable();
19
20    while let Some(&ch) = chars.peek() {
21        if ch.is_whitespace() {
22            chars.next();
23            continue;
24        }
25        if ch == '"' {
26            chars.next();
27            let mut s = String::new();
28            loop {
29                match chars.next() {
30                    None | Some('"') => break,
31                    Some('\\') => {
32                        if let Some(escaped) = chars.next() {
33                            s.push(escaped);
34                        }
35                    }
36                    Some(c) => s.push(c),
37                }
38            }
39            tokens.push(Token::Quoted(s));
40        } else {
41            let mut s = String::new();
42            while let Some(&c) = chars.peek() {
43                if c.is_whitespace() {
44                    break;
45                }
46                s.push(c);
47                chars.next();
48            }
49            tokens.push(Token::Bare(s));
50        }
51    }
52    tokens
53}
54
55/// From a token list (after the keyword), extract the pattern (all bare tokens
56/// joined by spaces) and the optional message (first quoted token).
57fn extract_pattern_and_message(tokens: &[Token]) -> (String, Option<String>) {
58    let mut bare_parts = Vec::new();
59    let mut message = None;
60    for token in tokens {
61        match token {
62            Token::Bare(s) => bare_parts.push(s.as_str()),
63            Token::Quoted(s) => {
64                if message.is_none() {
65                    message = Some(s.clone());
66                }
67            }
68        }
69    }
70    (bare_parts.join(" "), message)
71}
72
73/// Parse a single config line into a `ConfigDirective`.
74///
75/// # Errors
76///
77/// Returns an error string if the line contains an unknown directive or
78/// invalid syntax.
79pub fn parse_rule(line: &str) -> Result<ConfigDirective, String> {
80    let tokens = tokenize_config_line(line);
81    let keyword = match tokens.first() {
82        Some(Token::Bare(k)) => k.as_str(),
83        Some(Token::Quoted(_)) => return Err("directive cannot be quoted".into()),
84        None => return Err("empty rule".into()),
85    };
86    let rest = &tokens[1..];
87
88    match keyword {
89        "allow" | "ask" | "deny" => parse_command_rule(keyword, rest),
90        "allow-redirect" | "ask-redirect" | "deny-redirect" => parse_redirect_rule(keyword, rest),
91        "after" => parse_after_rule(rest),
92        "allow-mcp" | "ask-mcp" | "deny-mcp" => parse_mcp_rule(keyword, rest),
93        "allow-read" | "ask-read" | "deny-read" => parse_file_rule(keyword, rest, "read"),
94        "allow-write" | "ask-write" | "deny-write" => parse_file_rule(keyword, rest, "write"),
95        "allow-edit" | "ask-edit" | "deny-edit" => parse_file_rule(keyword, rest, "edit"),
96        "set" => parse_set_directive(rest),
97        "alias" => parse_alias_directive(rest),
98        "cd-allow" => parse_cd_allow_directive(rest),
99        _ => Err(format!("unknown directive: {keyword}")),
100    }
101}
102
103pub fn parse_action_word(word: &str) -> Option<Decision> {
104    match word {
105        "allow" => Some(Decision::Allow),
106        "ask" => Some(Decision::Ask),
107        "deny" => Some(Decision::Deny),
108        _ => None,
109    }
110}
111
112fn parse_command_rule(keyword: &str, rest: &[Token]) -> Result<ConfigDirective, String> {
113    let (pattern_str, message) = extract_pattern_and_message(rest);
114    if pattern_str.is_empty() {
115        return Err(format!("{keyword} requires a pattern"));
116    }
117    let mut rule = Rule::new(RuleTarget::Command, parse_rule_kind(keyword), &pattern_str);
118    if let Some(msg) = message {
119        rule = rule.with_message(msg);
120    }
121    Ok(ConfigDirective::Rule(rule))
122}
123
124fn parse_redirect_rule(keyword: &str, rest: &[Token]) -> Result<ConfigDirective, String> {
125    let (pattern_str, message) = extract_pattern_and_message(rest);
126    if pattern_str.is_empty() {
127        return Err(format!("{keyword} requires a path pattern"));
128    }
129    let base_kind = keyword.split('-').next().unwrap_or("ask");
130    let mut rule = Rule::new(
131        RuleTarget::Redirect,
132        parse_rule_kind(base_kind),
133        &pattern_str,
134    );
135    if let Some(msg) = message {
136        rule = rule.with_message(msg);
137    }
138    Ok(ConfigDirective::Rule(rule))
139}
140
141fn parse_after_rule(rest: &[Token]) -> Result<ConfigDirective, String> {
142    let (pattern_str, message) = extract_pattern_and_message(rest);
143    let message = message.ok_or("after requires a pattern and quoted message")?;
144    if pattern_str.is_empty() {
145        return Err("after requires a pattern".into());
146    }
147    let rule = Rule::new(RuleTarget::After, Decision::Allow, &pattern_str).with_message(message);
148    Ok(ConfigDirective::Rule(rule))
149}
150
151fn parse_mcp_rule(keyword: &str, rest: &[Token]) -> Result<ConfigDirective, String> {
152    let (pattern_str, _) = extract_pattern_and_message(rest);
153    if pattern_str.is_empty() {
154        return Err(format!("{keyword} requires a tool pattern"));
155    }
156    let base_kind = keyword.split('-').next().unwrap_or("ask");
157    let rule = Rule::new(RuleTarget::Mcp, parse_rule_kind(base_kind), &pattern_str);
158    Ok(ConfigDirective::Rule(rule))
159}
160
161fn parse_file_rule(keyword: &str, rest: &[Token], op: &str) -> Result<ConfigDirective, String> {
162    let (pattern_str, message) = extract_pattern_and_message(rest);
163    if pattern_str.is_empty() {
164        return Err(format!("{keyword} requires a file path pattern"));
165    }
166    let base_kind = keyword.split('-').next().unwrap_or("ask");
167    let target = match op {
168        "read" => RuleTarget::FileRead,
169        "write" => RuleTarget::FileWrite,
170        "edit" => RuleTarget::FileEdit,
171        _ => return Err(format!("unknown file operation: {op}")),
172    };
173    let mut rule = Rule::new(target, parse_rule_kind(base_kind), &pattern_str);
174    if let Some(msg) = message {
175        rule = rule.with_message(msg);
176    }
177    Ok(ConfigDirective::Rule(rule))
178}
179
180fn parse_set_directive(rest: &[Token]) -> Result<ConfigDirective, String> {
181    let bare: Vec<&str> = rest
182        .iter()
183        .filter_map(|t| match t {
184            Token::Bare(s) => Some(s.as_str()),
185            Token::Quoted(_) => None,
186        })
187        .collect();
188    if bare.is_empty() {
189        return Err("set requires a key".into());
190    }
191    Ok(ConfigDirective::Set {
192        key: bare[0].to_owned(),
193        value: bare.get(1).copied().unwrap_or_default().to_owned(),
194    })
195}
196
197fn parse_alias_directive(rest: &[Token]) -> Result<ConfigDirective, String> {
198    let bare: Vec<&str> = rest
199        .iter()
200        .filter_map(|t| match t {
201            Token::Bare(s) => Some(s.as_str()),
202            Token::Quoted(_) => None,
203        })
204        .collect();
205    if bare.len() < 2 {
206        return Err("alias requires source and target".into());
207    }
208    Ok(ConfigDirective::Alias {
209        source: bare[0].to_owned(),
210        target: bare[1].to_owned(),
211    })
212}
213
214fn parse_cd_allow_directive(rest: &[Token]) -> Result<ConfigDirective, String> {
215    let (path_str, _) = extract_pattern_and_message(rest);
216    if path_str.is_empty() {
217        return Err("cd-allow requires a directory path".into());
218    }
219    Ok(ConfigDirective::CdAllow(PathBuf::from(path_str)))
220}
221
222fn parse_rule_kind(word: &str) -> Decision {
223    parse_action_word(word).unwrap_or(Decision::Ask)
224}
225
226#[cfg(test)]
227#[allow(clippy::unwrap_used, clippy::panic)]
228mod tests {
229    use super::*;
230    use crate::config::{ConfigDirective, RuleTarget};
231    use crate::verdict::Decision;
232
233    #[test]
234    fn parse_allow_rule() {
235        let d = parse_rule("allow git status").unwrap();
236        match d {
237            ConfigDirective::Rule(r) => {
238                assert_eq!(r.target, RuleTarget::Command);
239                assert_eq!(r.decision, Decision::Allow);
240                assert_eq!(r.pattern.as_str(), "git status");
241                assert!(r.message.is_none());
242            }
243            _ => panic!("expected Rule"),
244        }
245    }
246
247    #[test]
248    fn parse_deny_with_message() {
249        let d = parse_rule(r#"deny python "Use uv run python""#).unwrap();
250        match d {
251            ConfigDirective::Rule(r) => {
252                assert_eq!(r.target, RuleTarget::Command);
253                assert_eq!(r.decision, Decision::Deny);
254                assert_eq!(r.pattern.as_str(), "python");
255                assert_eq!(r.message.as_deref(), Some("Use uv run python"));
256            }
257            _ => panic!("expected Rule"),
258        }
259    }
260
261    #[test]
262    fn parse_deny_multi_word_pattern_with_message() {
263        let d = parse_rule(r#"deny rm -rf "use trash instead""#).unwrap();
264        match d {
265            ConfigDirective::Rule(r) => {
266                assert_eq!(r.target, RuleTarget::Command);
267                assert_eq!(r.decision, Decision::Deny);
268                assert_eq!(r.pattern.as_str(), "rm -rf");
269                assert_eq!(r.message.as_deref(), Some("use trash instead"));
270            }
271            _ => panic!("expected Rule"),
272        }
273    }
274
275    #[test]
276    fn parse_redirect_rule() {
277        let d = parse_rule("deny-redirect **/.env*").unwrap();
278        match d {
279            ConfigDirective::Rule(r) => {
280                assert_eq!(r.target, RuleTarget::Redirect);
281                assert_eq!(r.decision, Decision::Deny);
282                assert_eq!(r.pattern.as_str(), "**/.env*");
283            }
284            _ => panic!("expected Rule"),
285        }
286    }
287
288    #[test]
289    fn parse_after_rule() {
290        let d = parse_rule(r#"after git "committed successfully""#).unwrap();
291        match d {
292            ConfigDirective::Rule(r) => {
293                assert_eq!(r.target, RuleTarget::After);
294                assert_eq!(r.pattern.as_str(), "git");
295                assert_eq!(r.message.as_deref(), Some("committed successfully"));
296            }
297            _ => panic!("expected Rule"),
298        }
299    }
300
301    #[test]
302    fn parse_set_rule() {
303        let d = parse_rule("set default ask").unwrap();
304        match d {
305            ConfigDirective::Set { key, value } => {
306                assert_eq!(key, "default");
307                assert_eq!(value, "ask");
308            }
309            _ => panic!("expected Set"),
310        }
311    }
312
313    #[test]
314    fn parse_alias_rule() {
315        let d = parse_rule("alias ~/custom-git git").unwrap();
316        match d {
317            ConfigDirective::Alias { source, target } => {
318                assert_eq!(source, "~/custom-git");
319                assert_eq!(target, "git");
320            }
321            _ => panic!("expected Alias"),
322        }
323    }
324
325    #[test]
326    fn parse_mcp_rule() {
327        let d = parse_rule("deny-mcp dangerous_tool").unwrap();
328        match d {
329            ConfigDirective::Rule(r) => {
330                assert_eq!(r.target, RuleTarget::Mcp);
331                assert_eq!(r.decision, Decision::Deny);
332                assert_eq!(r.pattern.as_str(), "dangerous_tool");
333            }
334            _ => panic!("expected Rule"),
335        }
336    }
337
338    #[test]
339    fn tokenize_quoted_strings() {
340        let tokens = tokenize_config_line(r#"deny python "Use uv run python""#);
341        assert_eq!(tokens.len(), 3);
342        assert!(matches!(&tokens[0], Token::Bare(s) if s == "deny"));
343        assert!(matches!(&tokens[1], Token::Bare(s) if s == "python"));
344        assert!(matches!(&tokens[2], Token::Quoted(s) if s == "Use uv run python"));
345    }
346
347    #[test]
348    fn tokenize_escaped_quote() {
349        let tokens = tokenize_config_line(r#"deny test "say \"hello\"""#);
350        assert_eq!(tokens.len(), 3);
351        assert!(matches!(&tokens[2], Token::Quoted(s) if s == r#"say "hello""#));
352    }
353
354    #[test]
355    fn unknown_directive_errors() {
356        assert!(parse_rule("foobar something").is_err());
357    }
358
359    #[test]
360    fn parse_file_read_rule() {
361        let d = parse_rule(r#"deny-read **/.env* "no env files""#).unwrap();
362        match d {
363            ConfigDirective::Rule(r) => {
364                assert_eq!(r.target, RuleTarget::FileRead);
365                assert_eq!(r.decision, Decision::Deny);
366                assert!(r.pattern.matches(".env"));
367                assert!(r.pattern.matches("foo/.env.local"));
368                assert_eq!(r.message.as_deref(), Some("no env files"));
369            }
370            _ => panic!("expected Rule"),
371        }
372    }
373
374    #[test]
375    fn parse_file_write_rule() {
376        let d = parse_rule("allow-write /tmp/**").unwrap();
377        match d {
378            ConfigDirective::Rule(r) => {
379                assert_eq!(r.target, RuleTarget::FileWrite);
380                assert_eq!(r.decision, Decision::Allow);
381            }
382            _ => panic!("expected Rule"),
383        }
384    }
385}