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