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#[derive(Debug, Deserialize)]
95pub struct TomlRule {
96    pub action: String,
97    /// Glob pattern (optional if structured fields are present).
98    pub pattern: Option<String>,
99    pub message: Option<String>,
100    /// Risk annotation — stored for future use by `rippy suggest` (#48).
101    pub risk: Option<String>,
102    /// Condition clause — parsed into `Condition` list for conditional rules (#46).
103    pub when: Option<toml::Value>,
104    // Structured matching fields (all optional, combined with AND).
105    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/// An alias entry from the `[[aliases]]` array.
114#[derive(Debug, Deserialize)]
115pub struct TomlAlias {
116    pub source: String,
117    pub target: String,
118}
119
120// ---------------------------------------------------------------------------
121// TOML → Vec<ConfigDirective> conversion
122// ---------------------------------------------------------------------------
123
124/// Parse a TOML config string into a list of directives.
125///
126/// # Errors
127///
128/// Returns `RippyError::Config` if the TOML is malformed or contains
129/// invalid rule definitions.
130pub 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
144/// Convert parsed TOML structs into the internal directive list.
145fn 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    // Expand git style rules BEFORE user rules so users can override.
159    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
177/// Convert settings into `ConfigDirective::Set` entries.
178fn 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
223/// Convert a single TOML rule into a `ConfigDirective::Rule`.
224fn 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    // Pattern is optional when structured fields are present.
235    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    // After rules require a message.
250    if target == RuleTarget::After && rule.message.is_none() {
251        return Err("'after' rules require a message field".to_string());
252    }
253
254    // Parse conditions from the `when` clause.
255    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    // Copy structured fields.
261    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
270/// Map an action string (e.g. "deny-redirect") to `(RuleTarget, Decision)`.
271fn 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// ---------------------------------------------------------------------------
302// Vec<ConfigDirective> → TOML serialization (for `rippy migrate`)
303// ---------------------------------------------------------------------------
304
305/// Serialize a list of directives into TOML format.
306#[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    // Only emit pattern if it's not the wildcard placeholder for structured-only rules.
347    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// ---------------------------------------------------------------------------
383// Tests
384// ---------------------------------------------------------------------------
385
386#[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        // Should parse without error and have 1 rule with 1 condition
521        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    // ── Structured matching TOML tests ─────────────────────────────
722
723    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        // No-pattern structured rule (docker)
751        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 = ")); // structured-only
765    }
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}