1use std::fmt::Write as _;
7use std::path::Path;
8
9use serde::Deserialize;
10
11use crate::config::{ConfigDirective, Rule, RuleTarget};
12use crate::error::RippyError;
13use crate::pattern::Pattern;
14use crate::verdict::Decision;
15
16#[derive(Debug, Deserialize)]
22pub struct TomlConfig {
23 pub meta: Option<TomlMeta>,
26 pub settings: Option<TomlSettings>,
27 pub cd: Option<TomlCd>,
28 pub git: Option<TomlGit>,
29 #[serde(default)]
30 pub rules: Vec<TomlRule>,
31 #[serde(default)]
32 pub aliases: Vec<TomlAlias>,
33}
34
35#[derive(Debug, Deserialize)]
39pub struct TomlMeta {
40 pub name: Option<String>,
41 pub tagline: Option<String>,
42 pub shield: Option<String>,
43 pub description: Option<String>,
44}
45
46#[derive(Debug, Deserialize)]
48pub struct TomlCd {
49 #[serde(default, rename = "allowed-dirs")]
51 pub allowed_dirs: Vec<String>,
52}
53
54#[derive(Debug, Deserialize)]
56pub struct TomlGit {
57 pub style: Option<String>,
59 #[serde(default)]
61 pub branches: Vec<TomlGitBranch>,
62}
63
64#[derive(Debug, Deserialize)]
66pub struct TomlGitBranch {
67 pub pattern: String,
69 pub style: String,
71}
72
73#[derive(Debug, Deserialize)]
75pub struct TomlSettings {
76 pub default: Option<String>,
77 pub log: Option<String>,
78 #[serde(rename = "log-full")]
79 pub log_full: Option<bool>,
80 pub tracking: Option<String>,
81 #[serde(rename = "self-protect")]
82 pub self_protect: Option<bool>,
83 #[serde(rename = "trust-project-configs")]
85 pub trust_project_configs: Option<bool>,
86 pub package: Option<String>,
88}
89
90#[derive(Debug, Deserialize)]
92pub struct TomlRule {
93 pub action: String,
94 pub pattern: Option<String>,
96 pub message: Option<String>,
97 pub risk: Option<String>,
99 pub when: Option<toml::Value>,
101 pub command: Option<String>,
103 pub subcommand: Option<String>,
104 pub subcommands: Option<Vec<String>>,
105 pub flags: Option<Vec<String>>,
106 #[serde(rename = "args-contain")]
107 pub args_contain: Option<String>,
108}
109
110#[derive(Debug, Deserialize)]
112pub struct TomlAlias {
113 pub source: String,
114 pub target: String,
115}
116
117pub fn parse_toml_config(content: &str, path: &Path) -> Result<Vec<ConfigDirective>, RippyError> {
128 let config: TomlConfig = toml::from_str(content).map_err(|e| RippyError::Config {
129 path: path.to_owned(),
130 line: 0,
131 message: e.to_string(),
132 })?;
133
134 toml_to_directives(&config).map_err(|msg| RippyError::Config {
135 path: path.to_owned(),
136 line: 0,
137 message: msg,
138 })
139}
140
141fn toml_to_directives(config: &TomlConfig) -> Result<Vec<ConfigDirective>, String> {
143 let mut directives = Vec::new();
144
145 if let Some(settings) = &config.settings {
146 settings_to_directives(settings, &mut directives);
147 }
148
149 if let Some(cd) = &config.cd {
150 for dir in &cd.allowed_dirs {
151 directives.push(ConfigDirective::CdAllow(std::path::PathBuf::from(dir)));
152 }
153 }
154
155 if let Some(git) = &config.git {
157 directives.extend(crate::git_styles::expand_git_config(git)?);
158 }
159
160 for rule in &config.rules {
161 directives.push(convert_rule(rule)?);
162 }
163
164 for alias in &config.aliases {
165 directives.push(ConfigDirective::Alias {
166 source: alias.source.clone(),
167 target: alias.target.clone(),
168 });
169 }
170
171 Ok(directives)
172}
173
174fn settings_to_directives(settings: &TomlSettings, out: &mut Vec<ConfigDirective>) {
176 if let Some(default) = &settings.default {
177 out.push(ConfigDirective::Set {
178 key: "default".to_string(),
179 value: default.clone(),
180 });
181 }
182 if let Some(log) = &settings.log {
183 out.push(ConfigDirective::Set {
184 key: "log".to_string(),
185 value: log.clone(),
186 });
187 }
188 if settings.log_full == Some(true) {
189 out.push(ConfigDirective::Set {
190 key: "log-full".to_string(),
191 value: String::new(),
192 });
193 }
194 if let Some(tracking) = &settings.tracking {
195 out.push(ConfigDirective::Set {
196 key: "tracking".to_string(),
197 value: tracking.clone(),
198 });
199 }
200 if settings.self_protect == Some(false) {
201 out.push(ConfigDirective::Set {
202 key: "self-protect".to_string(),
203 value: "off".to_string(),
204 });
205 }
206 if let Some(trust) = settings.trust_project_configs {
207 out.push(ConfigDirective::Set {
208 key: "trust-project-configs".to_string(),
209 value: if trust { "on" } else { "off" }.to_string(),
210 });
211 }
212 if let Some(package) = &settings.package {
213 out.push(ConfigDirective::Set {
214 key: "package".to_string(),
215 value: package.clone(),
216 });
217 }
218}
219
220fn convert_rule(toml_rule: &TomlRule) -> Result<ConfigDirective, String> {
222 let action = toml_rule.action.as_str();
223 let (target, decision) = parse_action_to_target(action)?;
224
225 let has_structured = toml_rule.command.is_some()
226 || toml_rule.subcommand.is_some()
227 || toml_rule.subcommands.is_some()
228 || toml_rule.flags.is_some()
229 || toml_rule.args_contain.is_some();
230
231 let mut rule = match &toml_rule.pattern {
233 Some(p) => Rule::new(target, decision, p),
234 None if has_structured => {
235 let mut r = Rule::new(target, decision, "*");
236 r.pattern = Pattern::any();
237 r
238 }
239 None => return Err("rule must have 'pattern' or structured fields".to_string()),
240 };
241
242 if let Some(msg) = &toml_rule.message {
243 rule = rule.with_message(msg.clone());
244 }
245
246 if target == RuleTarget::After && rule.message.is_none() {
248 return Err("'after' rules require a message field".to_string());
249 }
250
251 if let Some(when_value) = &toml_rule.when {
253 let conditions = crate::condition::parse_conditions(when_value)?;
254 rule = rule.with_conditions(conditions);
255 }
256
257 rule.command.clone_from(&toml_rule.command);
259 rule.subcommand.clone_from(&toml_rule.subcommand);
260 rule.subcommands.clone_from(&toml_rule.subcommands);
261 rule.flags.clone_from(&toml_rule.flags);
262 rule.args_contain.clone_from(&toml_rule.args_contain);
263
264 Ok(ConfigDirective::Rule(rule))
265}
266
267fn parse_action_to_target(action: &str) -> Result<(RuleTarget, Decision), String> {
269 match action {
270 "allow" | "ask" | "deny" => Ok((RuleTarget::Command, parse_decision(action))),
271 "after" => Ok((RuleTarget::After, Decision::Allow)),
272 _ => parse_compound_action(action),
273 }
274}
275
276fn parse_compound_action(action: &str) -> Result<(RuleTarget, Decision), String> {
277 let suffix = action.rsplit('-').next().unwrap_or("");
278 let target = match suffix {
279 "redirect" => RuleTarget::Redirect,
280 "mcp" => RuleTarget::Mcp,
281 "read" => RuleTarget::FileRead,
282 "write" => RuleTarget::FileWrite,
283 "edit" => RuleTarget::FileEdit,
284 _ => return Err(format!("unknown action: {action}")),
285 };
286 let base = action.split('-').next().unwrap_or("ask");
287 Ok((target, parse_decision(base)))
288}
289
290fn parse_decision(word: &str) -> Decision {
291 match word {
292 "allow" => Decision::Allow,
293 "deny" => Decision::Deny,
294 _ => Decision::Ask,
295 }
296}
297
298#[must_use]
304pub fn rules_to_toml(directives: &[ConfigDirective]) -> String {
305 let mut out = String::new();
306 emit_settings(directives, &mut out);
307 emit_rules(directives, &mut out);
308 emit_aliases(directives, &mut out);
309 out
310}
311
312fn emit_settings(directives: &[ConfigDirective], out: &mut String) {
313 let mut has_header = false;
314 for d in directives {
315 if let ConfigDirective::Set { key, value } = d {
316 if !has_header {
317 let _ = writeln!(out, "[settings]");
318 has_header = true;
319 }
320 if key == "log-full" {
321 let _ = writeln!(out, "log-full = true");
322 } else {
323 let _ = writeln!(out, "{key} = {value:?}");
324 }
325 }
326 }
327 if has_header {
328 out.push('\n');
329 }
330}
331
332fn emit_rules(directives: &[ConfigDirective], out: &mut String) {
333 for d in directives {
334 if let ConfigDirective::Rule(rule) = d {
335 emit_rule_entry(out, rule);
336 }
337 }
338}
339
340fn emit_rule_entry(out: &mut String, rule: &Rule) {
341 let _ = writeln!(out, "[[rules]]");
342 let _ = writeln!(out, "action = {:?}", rule.action_str());
343 if !rule.pattern.is_any() || !rule.has_structured_fields() {
345 let _ = writeln!(out, "pattern = {:?}", rule.pattern.raw());
346 }
347 if let Some(cmd) = &rule.command {
348 let _ = writeln!(out, "command = {cmd:?}");
349 }
350 if let Some(sub) = &rule.subcommand {
351 let _ = writeln!(out, "subcommand = {sub:?}");
352 }
353 if let Some(subs) = &rule.subcommands {
354 let _ = writeln!(out, "subcommands = {subs:?}");
355 }
356 if let Some(flags) = &rule.flags {
357 let _ = writeln!(out, "flags = {flags:?}");
358 }
359 if let Some(ac) = &rule.args_contain {
360 let _ = writeln!(out, "args-contain = {ac:?}");
361 }
362 if let Some(msg) = &rule.message {
363 let _ = writeln!(out, "message = {msg:?}");
364 }
365 out.push('\n');
366}
367
368fn emit_aliases(directives: &[ConfigDirective], out: &mut String) {
369 for d in directives {
370 if let ConfigDirective::Alias { source, target } = d {
371 let _ = writeln!(out, "[[aliases]]");
372 let _ = writeln!(out, "source = {source:?}");
373 let _ = writeln!(out, "target = {target:?}");
374 out.push('\n');
375 }
376 }
377}
378
379#[cfg(test)]
384#[allow(clippy::unwrap_used, clippy::panic)]
385mod tests {
386 use super::*;
387 use crate::config::Config;
388
389 #[test]
390 fn parse_settings() {
391 let toml = r#"
392[settings]
393default = "deny"
394log = "/tmp/rippy.log"
395log-full = true
396"#;
397 let directives = parse_toml_config(toml, Path::new("test.toml")).unwrap();
398 let config = Config::from_directives(directives);
399 assert_eq!(config.default_action, Some(Decision::Deny));
400 assert!(config.log_file.is_some());
401 assert!(config.log_full);
402 }
403
404 #[test]
405 fn parse_command_rules() {
406 let toml = r#"
407[[rules]]
408action = "allow"
409pattern = "git status"
410
411[[rules]]
412action = "deny"
413pattern = "rm -rf *"
414message = "Use trash instead"
415"#;
416 let directives = parse_toml_config(toml, Path::new("test.toml")).unwrap();
417 assert_eq!(directives.len(), 2);
418
419 let config = Config::from_directives(directives);
420 let v = config.match_command("git status", None).unwrap();
421 assert_eq!(v.decision, Decision::Allow);
422
423 let v = config.match_command("rm -rf /tmp", None).unwrap();
424 assert_eq!(v.decision, Decision::Deny);
425 assert_eq!(v.reason, "Use trash instead");
426 }
427
428 #[test]
429 fn parse_redirect_rules() {
430 let toml = r#"
431[[rules]]
432action = "deny-redirect"
433pattern = "**/.env*"
434message = "Do not write to env files"
435"#;
436 let directives = parse_toml_config(toml, Path::new("test.toml")).unwrap();
437 let config = Config::from_directives(directives);
438 let v = config.match_redirect(".env", None).unwrap();
439 assert_eq!(v.decision, Decision::Deny);
440 assert_eq!(v.reason, "Do not write to env files");
441 }
442
443 #[test]
444 fn parse_mcp_rules() {
445 let toml = r#"
446[[rules]]
447action = "allow-mcp"
448pattern = "mcp__github__*"
449"#;
450 let directives = parse_toml_config(toml, Path::new("test.toml")).unwrap();
451 let config = Config::from_directives(directives);
452 let v = config.match_mcp("mcp__github__create_issue").unwrap();
453 assert_eq!(v.decision, Decision::Allow);
454 }
455
456 #[test]
457 fn parse_after_rule() {
458 let toml = r#"
459[[rules]]
460action = "after"
461pattern = "git commit"
462message = "Don't forget to push"
463"#;
464 let directives = parse_toml_config(toml, Path::new("test.toml")).unwrap();
465 let config = Config::from_directives(directives);
466 let msg = config.match_after("git commit -m test").unwrap();
467 assert_eq!(msg, "Don't forget to push");
468 }
469
470 #[test]
471 fn after_requires_message() {
472 let toml = r#"
473[[rules]]
474action = "after"
475pattern = "git commit"
476"#;
477 let result = parse_toml_config(toml, Path::new("test.toml"));
478 assert!(result.is_err());
479 }
480
481 #[test]
482 fn unknown_action_errors() {
483 let toml = r#"
484[[rules]]
485action = "yolo"
486pattern = "rm -rf /"
487"#;
488 let result = parse_toml_config(toml, Path::new("test.toml"));
489 assert!(result.is_err());
490 }
491
492 #[test]
493 fn parse_aliases() {
494 let toml = r#"
495[[aliases]]
496source = "~/custom-git"
497target = "git"
498"#;
499 let directives = parse_toml_config(toml, Path::new("test.toml")).unwrap();
500 let config = Config::from_directives(directives);
501 assert_eq!(config.resolve_alias("~/custom-git"), "git");
502 }
503
504 #[test]
505 fn when_clause_parsed_into_conditions() {
506 let toml = r#"
507[[rules]]
508action = "ask"
509pattern = "docker run *"
510risk = "high"
511message = "Container execution"
512
513[rules.when]
514branch = { not = "main" }
515"#;
516 let directives = parse_toml_config(toml, Path::new("test.toml")).unwrap();
517 assert_eq!(directives.len(), 1);
519 match &directives[0] {
520 ConfigDirective::Rule(r) => {
521 assert_eq!(r.conditions.len(), 1);
522 }
523 _ => panic!("expected Rule"),
524 }
525 }
526
527 #[test]
528 fn malformed_toml_errors() {
529 let result = parse_toml_config("not valid [[[ toml", Path::new("bad.toml"));
530 assert!(result.is_err());
531 }
532
533 #[test]
534 fn roundtrip_rules() {
535 let toml_input = r#"
536[settings]
537default = "ask"
538
539[[rules]]
540action = "allow"
541pattern = "git status"
542
543[[rules]]
544action = "deny"
545pattern = "rm -rf *"
546message = "Use trash instead"
547
548[[rules]]
549action = "deny-redirect"
550pattern = "**/.env*"
551message = "protected"
552
553[[rules]]
554action = "after"
555pattern = "git commit"
556message = "push please"
557
558[[aliases]]
559source = "~/bin/git"
560target = "git"
561"#;
562 let directives = parse_toml_config(toml_input, Path::new("test.toml")).unwrap();
563 let serialized = rules_to_toml(&directives);
564 let re_parsed = parse_toml_config(&serialized, Path::new("test.toml")).unwrap();
565
566 let config1 = Config::from_directives(directives);
567 let config2 = Config::from_directives(re_parsed);
568
569 assert_eq!(
570 config1.match_command("git status", None).unwrap().decision,
571 config2.match_command("git status", None).unwrap().decision,
572 );
573 assert_eq!(
574 config1.match_command("rm -rf /tmp", None).unwrap().decision,
575 config2.match_command("rm -rf /tmp", None).unwrap().decision,
576 );
577 assert_eq!(config1.default_action, config2.default_action);
578 assert_eq!(
579 config1.resolve_alias("~/bin/git"),
580 config2.resolve_alias("~/bin/git"),
581 );
582 }
583
584 #[test]
585 fn roundtrip_mcp_rules() {
586 let toml_input = r#"
587[[rules]]
588action = "allow-mcp"
589pattern = "mcp__github__*"
590
591[[rules]]
592action = "deny-mcp"
593pattern = "mcp__dangerous__*"
594"#;
595 let directives = parse_toml_config(toml_input, Path::new("test.toml")).unwrap();
596 let serialized = rules_to_toml(&directives);
597 let re_parsed = parse_toml_config(&serialized, Path::new("test.toml")).unwrap();
598
599 let config = Config::from_directives(re_parsed);
600 assert_eq!(
601 config
602 .match_mcp("mcp__github__create_issue")
603 .unwrap()
604 .decision,
605 Decision::Allow,
606 );
607 assert_eq!(
608 config.match_mcp("mcp__dangerous__exec").unwrap().decision,
609 Decision::Deny,
610 );
611 }
612
613 #[test]
614 fn roundtrip_file_rules() {
615 let toml_input = r#"
616[[rules]]
617action = "deny-read"
618pattern = "**/.env*"
619message = "no env"
620
621[[rules]]
622action = "allow-write"
623pattern = "/tmp/**"
624
625[[rules]]
626action = "ask-edit"
627pattern = "**/vendor/**"
628message = "vendor files"
629"#;
630 let directives = parse_toml_config(toml_input, Path::new("test.toml")).unwrap();
631 let serialized = rules_to_toml(&directives);
632 let re_parsed = parse_toml_config(&serialized, Path::new("test.toml")).unwrap();
633
634 let config = Config::from_directives(re_parsed);
635 assert_eq!(
636 config.match_file_read(".env", None).unwrap().decision,
637 Decision::Deny,
638 );
639 assert_eq!(
640 config
641 .match_file_write("/tmp/out.txt", None)
642 .unwrap()
643 .decision,
644 Decision::Allow,
645 );
646 assert_eq!(
647 config
648 .match_file_edit("vendor/pkg/lib.rs", None)
649 .unwrap()
650 .decision,
651 Decision::Ask,
652 );
653 }
654
655 #[test]
656 fn all_action_variants() {
657 let toml_input = r#"
658[[rules]]
659action = "ask"
660pattern = "docker *"
661message = "confirm container"
662
663[[rules]]
664action = "allow-redirect"
665pattern = "/tmp/**"
666
667[[rules]]
668action = "ask-redirect"
669pattern = "/var/**"
670
671[[rules]]
672action = "ask-mcp"
673pattern = "mcp__unknown__*"
674"#;
675 let directives = parse_toml_config(toml_input, Path::new("test.toml")).unwrap();
676 let config = Config::from_directives(directives);
677
678 let v = config.match_command("docker run -it ubuntu", None).unwrap();
679 assert_eq!(v.decision, Decision::Ask);
680 assert_eq!(v.reason, "confirm container");
681
682 assert_eq!(
683 config
684 .match_redirect("/tmp/out.txt", None)
685 .unwrap()
686 .decision,
687 Decision::Allow,
688 );
689 assert_eq!(
690 config
691 .match_redirect("/var/log/out", None)
692 .unwrap()
693 .decision,
694 Decision::Ask,
695 );
696 assert_eq!(
697 config.match_mcp("mcp__unknown__tool").unwrap().decision,
698 Decision::Ask,
699 );
700 }
701
702 #[test]
703 fn empty_toml_produces_empty_config() {
704 let directives = parse_toml_config("", Path::new("test.toml")).unwrap();
705 assert!(directives.is_empty());
706 let config = Config::from_directives(directives);
707 assert!(config.match_command("anything", None).is_none());
708 }
709
710 #[test]
711 fn log_full_false_not_emitted() {
712 let toml = "[settings]\nlog-full = false\n";
713 let directives = parse_toml_config(toml, Path::new("test.toml")).unwrap();
714 let config = Config::from_directives(directives);
715 assert!(!config.log_full);
716 }
717
718 const STRUCTURED_DENY_FORCE: &str = "\
721[[rules]]\naction = \"deny\"\ncommand = \"git\"\nsubcommand = \"push\"\n\
722flags = [\"--force\", \"-f\"]\nmessage = \"No force push\"\n";
723
724 #[test]
725 fn parse_structured_command_with_flags() {
726 let directives = parse_toml_config(STRUCTURED_DENY_FORCE, Path::new("t")).unwrap();
727 let config = Config::from_directives(directives);
728 assert_eq!(
729 config
730 .match_command("git push --force origin main", None)
731 .unwrap()
732 .decision,
733 Decision::Deny
734 );
735 assert!(config.match_command("git push origin main", None).is_none());
736 }
737
738 #[test]
739 fn parse_structured_subcommands_and_no_pattern() {
740 let toml = "[[rules]]\naction = \"allow\"\ncommand = \"git\"\n\
741 subcommands = [\"status\", \"log\", \"diff\"]\n";
742 let config = Config::from_directives(parse_toml_config(toml, Path::new("t")).unwrap());
743 assert!(config.match_command("git status", None).is_some());
744 assert!(config.match_command("git log --oneline", None).is_some());
745 assert!(config.match_command("git push", None).is_none());
746
747 let toml2 = "[[rules]]\naction = \"ask\"\ncommand = \"docker\"\nsubcommand = \"run\"\n";
749 let config2 = Config::from_directives(parse_toml_config(toml2, Path::new("t")).unwrap());
750 assert!(config2.match_command("docker run ubuntu", None).is_some());
751 assert!(config2.match_command("docker ps", None).is_none());
752 }
753
754 #[test]
755 fn structured_rule_round_trips() {
756 let directives = parse_toml_config(STRUCTURED_DENY_FORCE, Path::new("t")).unwrap();
757 let serialized = rules_to_toml(&directives);
758 assert!(serialized.contains("command = \"git\""));
759 assert!(serialized.contains("subcommand = \"push\""));
760 assert!(serialized.contains("flags = "));
761 assert!(!serialized.contains("pattern = ")); }
763
764 #[test]
765 fn rule_without_pattern_or_structured_fails() {
766 let toml = "[[rules]]\naction = \"deny\"\nmessage = \"missing\"\n";
767 assert!(parse_toml_config(toml, Path::new("t")).is_err());
768 }
769}