1use std::path::PathBuf;
2
3use crate::verdict::Decision;
4
5use super::types::{ConfigDirective, Rule, RuleTarget};
6
7#[derive(Debug)]
9enum Token {
10 Bare(String),
11 Quoted(String),
12}
13
14fn 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
55fn 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
73pub 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}