Skip to main content

rippy_cli/
toml_config.rs

1//! TOML-based config parser for `.rippy.toml` files.
2//!
3//! Parses structured TOML config into `Vec<ConfigDirective>` that feeds into the same
4//! `Config::from_directives()` path as the legacy line-based parser.
5
6use 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// ---------------------------------------------------------------------------
17// Deserialization structs
18// ---------------------------------------------------------------------------
19
20/// Top-level structure of a `.rippy.toml` file.
21#[derive(Debug, Deserialize)]
22pub struct TomlConfig {
23    /// Optional metadata section — used by packages for display purposes,
24    /// ignored during directive generation.
25    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/// Metadata section for packages and config files.
36///
37/// This is purely informational — it is not converted to config directives.
38/// For custom packages, `extends` names a built-in package whose rules are
39/// inherited before the custom package's own rules are layered on top.
40#[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/// Configuration for `cd` directory navigation.
50#[derive(Debug, Deserialize)]
51pub struct TomlCd {
52    /// Additional directories that `cd` is allowed to navigate to.
53    #[serde(default, rename = "allowed-dirs")]
54    pub allowed_dirs: Vec<String>,
55}
56
57/// Git workflow style configuration.
58#[derive(Debug, Deserialize)]
59pub struct TomlGit {
60    /// Default git workflow style for the project.
61    pub style: Option<String>,
62    /// Branch-specific style overrides.
63    #[serde(default)]
64    pub branches: Vec<TomlGitBranch>,
65}
66
67/// A branch-specific git style override.
68#[derive(Debug, Deserialize)]
69pub struct TomlGitBranch {
70    /// Branch glob pattern (e.g., "agent/*", "main").
71    pub pattern: String,
72    /// Style name for branches matching this pattern.
73    pub style: String,
74}
75
76/// Global settings section.
77#[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    /// Whether to auto-trust all project configs without checking the trust DB.
87    #[serde(rename = "trust-project-configs")]
88    pub trust_project_configs: Option<bool>,
89    /// Safety package to activate (e.g., "review", "develop", "autopilot").
90    pub package: Option<String>,
91}
92
93/// A single rule entry from the `[[rules]]` array.
94///
95/// `deny_unknown_fields` is set here (and only here among the `Toml*` structs)
96/// so typos or stale fields in user rules surface as a clear error instead of
97/// being silently ignored — see #117 for the `risk` field regression.
98#[derive(Debug, Deserialize)]
99#[serde(deny_unknown_fields)]
100pub struct TomlRule {
101    pub action: String,
102    /// Glob pattern (optional if structured fields are present).
103    pub pattern: Option<String>,
104    pub message: Option<String>,
105    /// Condition clause — parsed into `Condition` list for conditional rules (#46).
106    pub when: Option<toml::Value>,
107    // Structured matching fields (all optional, combined with AND).
108    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/// An alias entry from the `[[aliases]]` array.
117#[derive(Debug, Deserialize)]
118pub struct TomlAlias {
119    pub source: String,
120    pub target: String,
121}
122
123// ---------------------------------------------------------------------------
124// TOML → Vec<ConfigDirective> conversion
125// ---------------------------------------------------------------------------
126
127/// Parse a TOML config string into a list of directives.
128///
129/// # Errors
130///
131/// Returns `RippyError::Config` if the TOML is malformed or contains
132/// invalid rule definitions.
133pub 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
147/// Convert parsed TOML structs into the internal directive list.
148fn 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    // Expand git style rules BEFORE user rules so users can override.
162    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
180/// Convert settings into `ConfigDirective::Set` entries.
181fn 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
226/// Convert a single TOML rule into a `ConfigDirective::Rule`.
227fn 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    // Pattern is optional when structured fields are present.
238    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    // After rules require a message.
253    if target == RuleTarget::After && rule.message.is_none() {
254        return Err("'after' rules require a message field".to_string());
255    }
256
257    // Parse conditions from the `when` clause.
258    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    // Copy structured fields.
264    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
273/// Map an action string (e.g. "deny-redirect") to `(RuleTarget, Decision)`.
274fn 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// ---------------------------------------------------------------------------
305// Vec<ConfigDirective> → TOML serialization (for `rippy migrate`)
306// ---------------------------------------------------------------------------
307
308/// Serialize a list of directives into TOML format.
309#[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    // Only emit pattern if it's not the wildcard placeholder for structured-only rules.
350    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// ---------------------------------------------------------------------------
386// Tests
387// ---------------------------------------------------------------------------
388
389#[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        // Should parse without error and have 1 rule with 1 condition
523        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    // ── Structured matching TOML tests ─────────────────────────────
724
725    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        // No-pattern structured rule (docker)
753        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 = ")); // structured-only
767    }
768
769    #[test]
770    fn rule_with_risk_field_errors() {
771        // Regression: the `risk` field was accepted silently (#117). It is now
772        // rejected as an unknown field so users don't write no-op configs.
773        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        // `deny_unknown_fields` also catches typos in known field names, not
792        // just orphaned fields like `risk`. Pins the broader contract.
793        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}