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