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