Skip to main content

rippy_cli/config/
mod.rs

1use std::path::PathBuf;
2
3mod loader;
4mod matching;
5mod parser;
6mod sources;
7mod string_loader;
8mod types;
9
10pub use loader::{home_dir, load_file};
11pub use parser::{parse_action_word, parse_rule};
12pub use sources::{ConfigSourceInfo, enumerate_config_sources, find_project_config};
13pub use string_loader::ConfigFormat;
14pub use types::{ConfigDirective, Rule, RuleTarget};
15
16use loader::{
17    apply_setting, build_weakening_suffix, detect_broad_allow, detect_dangerous_setting,
18    has_trust_setting, load_first_existing, load_project_config_if_trusted,
19};
20use matching::{format_rule_reason, matches_structured};
21
22use std::path::Path;
23
24use crate::condition::{MatchContext, evaluate_all};
25use crate::error::RippyError;
26use crate::pattern::Pattern;
27use crate::verdict::{Decision, Verdict};
28
29// ---------------------------------------------------------------------------
30// Config
31// ---------------------------------------------------------------------------
32
33/// Loaded and merged configuration with rules partitioned by type.
34#[derive(Debug, Clone, Default)]
35pub struct Config {
36    rules: Vec<Rule>,
37    after_rules: Vec<(Pattern, String)>,
38    pub default_action: Option<Decision>,
39    pub log_file: Option<std::path::PathBuf>,
40    pub log_full: bool,
41    pub tracking_db: Option<std::path::PathBuf>,
42    pub self_protect: bool,
43    /// Whether to auto-trust all project configs without checking the trust DB.
44    pub trust_project_configs: bool,
45    aliases: Vec<(String, String)>,
46    /// Extra directories that `cd` is allowed to navigate to (beyond the project root).
47    pub cd_allowed_dirs: Vec<std::path::PathBuf>,
48    /// Index range in `rules` containing project-config rules.
49    /// `None` when no project config was loaded. Rules outside this range
50    /// are baseline (stdlib + global) or env override.
51    project_rules_range: Option<std::ops::Range<usize>>,
52    /// Pre-formatted suffix appended to verdict reasons when project allow rules fire.
53    /// Empty string when the project config doesn't weaken protections.
54    project_weakening_suffix: String,
55    /// The active safety package (if any).
56    pub active_package: Option<crate::packages::Package>,
57}
58
59impl Config {
60    /// Load config from the three-tier system: global, project, env override.
61    ///
62    /// # Errors
63    ///
64    /// Returns `RippyError::Config` if a config file exists but contains invalid syntax.
65    pub fn load(cwd: &Path, env_config: Option<&Path>) -> Result<Self, RippyError> {
66        Self::load_with_home(cwd, env_config, home_dir())
67    }
68
69    /// Load config with an explicit home directory instead of reading `$HOME`.
70    ///
71    /// Pass `None` to skip global config loading (useful for tests).
72    ///
73    /// # Errors
74    ///
75    /// Returns `RippyError::Config` if a config file exists but contains invalid syntax.
76    pub fn load_with_home(
77        cwd: &Path,
78        env_config: Option<&Path>,
79        home: Option<PathBuf>,
80    ) -> Result<Self, RippyError> {
81        // Stdlib first (lowest priority — user config overrides via last-match-wins).
82        let mut directives = crate::stdlib::stdlib_directives()?;
83
84        // Pre-scan config files for the package setting. Project config
85        // overrides global (last-match-wins). The package layer loads between
86        // stdlib and user config so user rules can override package rules.
87        let package = resolve_package(home.as_ref(), cwd);
88        if let Some(pkg) = &package {
89            directives.extend(crate::packages::package_directives(pkg)?);
90        }
91
92        if let Some(home) = home {
93            load_first_existing(
94                &[
95                    home.join(".rippy/config.toml"),
96                    home.join(".rippy/config"),
97                    home.join(".dippy/config"),
98                ],
99                &mut directives,
100            )?;
101        }
102
103        directives.push(ConfigDirective::ProjectBoundary);
104
105        if let Some(project_config) = find_project_config(cwd) {
106            let trust_all = has_trust_setting(&directives);
107            load_project_config_if_trusted(&project_config, trust_all, &mut directives)?;
108        }
109
110        directives.push(ConfigDirective::ProjectBoundary);
111
112        if let Some(env_path) = env_config {
113            load_file(env_path, &mut directives)?;
114        }
115
116        let mut config = Self::from_directives(directives);
117        config.active_package = package;
118        Ok(config)
119    }
120
121    #[must_use]
122    pub fn empty() -> Self {
123        Self::default()
124    }
125
126    /// Return the pre-formatted weakening suffix for verdict annotation.
127    #[must_use]
128    pub fn weakening_suffix(&self) -> &str {
129        &self.project_weakening_suffix
130    }
131
132    /// Match a command string against command rules (last-match-wins).
133    #[must_use]
134    pub fn match_command(&self, command: &str, ctx: Option<&MatchContext>) -> Option<Verdict> {
135        self.match_rules(RuleTarget::Command, command, "matched rule", ctx)
136    }
137
138    /// Match a redirect target path against redirect rules.
139    #[must_use]
140    pub fn match_redirect(&self, path: &str, ctx: Option<&MatchContext>) -> Option<Verdict> {
141        self.match_rules(RuleTarget::Redirect, path, "redirect rule", ctx)
142    }
143
144    /// Match an MCP tool name against MCP rules.
145    #[must_use]
146    pub fn match_mcp(&self, tool_name: &str) -> Option<Verdict> {
147        self.match_rules(RuleTarget::Mcp, tool_name, "MCP rule", None)
148    }
149
150    /// Match a file path against file-read rules.
151    #[must_use]
152    pub fn match_file_read(&self, path: &str, ctx: Option<&MatchContext>) -> Option<Verdict> {
153        self.match_rules(RuleTarget::FileRead, path, "file-read rule", ctx)
154    }
155
156    /// Match a file path against file-write rules.
157    #[must_use]
158    pub fn match_file_write(&self, path: &str, ctx: Option<&MatchContext>) -> Option<Verdict> {
159        self.match_rules(RuleTarget::FileWrite, path, "file-write rule", ctx)
160    }
161
162    /// Match a file path against file-edit rules.
163    #[must_use]
164    pub fn match_file_edit(&self, path: &str, ctx: Option<&MatchContext>) -> Option<Verdict> {
165        self.match_rules(RuleTarget::FileEdit, path, "file-edit rule", ctx)
166    }
167
168    /// Match a command for `after` rules (post-execution feedback).
169    #[must_use]
170    pub fn match_after(&self, command: &str) -> Option<String> {
171        let mut result = None;
172        for (pattern, message) in &self.after_rules {
173            if pattern.matches(command) {
174                result = Some(message.clone());
175            }
176        }
177        result
178    }
179
180    /// Resolve aliases for a command name. Returns the target if aliased.
181    #[must_use]
182    pub fn resolve_alias<'a>(&'a self, command: &'a str) -> &'a str {
183        for (source, target) in &self.aliases {
184            if command == source
185                || command
186                    .strip_prefix(source.as_str())
187                    .is_some_and(|rest| rest.starts_with('/'))
188            {
189                return target;
190            }
191        }
192        command
193    }
194
195    /// Shared matching logic for all rule targets (last-match-wins).
196    ///
197    /// Supports both glob-pattern and structured matching. For structured rules,
198    /// the input is parsed into command name + args on demand.
199    fn match_rules(
200        &self,
201        target: RuleTarget,
202        input: &str,
203        label: &str,
204        ctx: Option<&MatchContext>,
205    ) -> Option<Verdict> {
206        let mut result = None;
207        let mut baseline_decision: Option<Decision> = None;
208        let project_range = self.project_rules_range.as_ref();
209
210        for (i, rule) in self.rules.iter().enumerate() {
211            if rule.target != target {
212                continue;
213            }
214            if !rule.pattern.matches(input) {
215                continue;
216            }
217            if rule.has_structured_fields() && !matches_structured(rule, input) {
218                continue;
219            }
220            if !rule.conditions.is_empty() {
221                match ctx {
222                    Some(c) if evaluate_all(&rule.conditions, c) => {}
223                    _ => continue,
224                }
225            }
226
227            let is_project_rule = project_range.is_some_and(|r| r.contains(&i));
228            if !is_project_rule {
229                baseline_decision = Some(rule.decision);
230            }
231
232            let mut reason = if is_project_rule
233                && rule.decision == Decision::Allow
234                && baseline_decision.is_some_and(|d| d != Decision::Allow)
235            {
236                let overridden = baseline_decision.map_or("ask", Decision::as_str);
237                format!(
238                    "matched project rule (overrides {overridden}: {})",
239                    rule.pattern.raw()
240                )
241            } else {
242                rule.message
243                    .as_deref()
244                    .map_or_else(|| format_rule_reason(rule, label), String::from)
245            };
246
247            if is_project_rule && rule.decision == Decision::Allow {
248                reason.push_str(&self.project_weakening_suffix);
249            }
250
251            result = Some(Verdict {
252                decision: rule.decision,
253                reason,
254                resolved_command: None,
255            });
256        }
257        result
258    }
259
260    /// Build a `Config` from a list of directives.
261    pub fn from_directives(directives: Vec<ConfigDirective>) -> Self {
262        let mut config = Self {
263            self_protect: true,
264            ..Self::default()
265        };
266        let mut in_project_section = false;
267        let mut project_start: Option<usize> = None;
268        let mut weakening_notes: Vec<String> = Vec::new();
269
270        for directive in directives {
271            match directive {
272                ConfigDirective::Rule(r) => {
273                    if r.target == RuleTarget::After {
274                        if let Some(msg) = &r.message {
275                            config.after_rules.push((r.pattern, msg.clone()));
276                        }
277                    } else {
278                        if in_project_section {
279                            detect_broad_allow(&r, &mut weakening_notes);
280                        }
281                        config.rules.push(r);
282                    }
283                }
284                ConfigDirective::Set { key, value } => {
285                    if in_project_section {
286                        detect_dangerous_setting(&key, &value, &mut weakening_notes);
287                    }
288                    apply_setting(&mut config, &key, &value);
289                }
290                ConfigDirective::Alias { source, target } => {
291                    config.aliases.push((source, target));
292                }
293                ConfigDirective::ProjectBoundary => {
294                    if in_project_section {
295                        if let Some(start) = project_start {
296                            config.project_rules_range = Some(start..config.rules.len());
297                        }
298                        in_project_section = false;
299                    } else {
300                        project_start = Some(config.rules.len());
301                        in_project_section = true;
302                    }
303                }
304                ConfigDirective::CdAllow(path) => {
305                    config
306                        .cd_allowed_dirs
307                        .push(crate::handlers::normalize_path(&path));
308                }
309            }
310        }
311
312        if in_project_section && project_start.is_some() {
313            config.project_rules_range = project_start.map(|start| start..config.rules.len());
314        }
315
316        config.project_weakening_suffix = build_weakening_suffix(&weakening_notes);
317        config
318    }
319}
320
321/// Pre-scan global and project config files for the `package` setting.
322///
323/// Project config overrides global (last-match-wins). Returns `None` if
324/// no config file specifies a package.
325fn resolve_package(home: Option<&PathBuf>, cwd: &Path) -> Option<crate::packages::Package> {
326    let mut package_name: Option<String> = None;
327
328    // Check global config candidates.
329    if let Some(home) = home {
330        for path in &[
331            home.join(".rippy/config.toml"),
332            home.join(".rippy/config"),
333            home.join(".dippy/config"),
334        ] {
335            if path.is_file() {
336                package_name = loader::extract_package_setting(path);
337                break; // Only the first existing global config matters
338            }
339        }
340    }
341
342    // Check project config (overrides global).
343    if let Some(project_config) = find_project_config(cwd)
344        && let Some(name) = loader::extract_package_setting(&project_config)
345    {
346        package_name = Some(name);
347    }
348
349    let name = package_name?;
350    match crate::packages::Package::resolve(&name, home.map(PathBuf::as_path)) {
351        Ok(pkg) => Some(pkg),
352        Err(e) => {
353            eprintln!("[rippy] {e}");
354            None
355        }
356    }
357}
358
359#[cfg(test)]
360#[allow(clippy::unwrap_used, clippy::panic)]
361mod tests {
362    use super::*;
363    use crate::condition::Condition;
364
365    #[test]
366    fn last_match_wins() {
367        let config = Config::from_directives(vec![
368            ConfigDirective::Rule(
369                Rule::new(RuleTarget::Command, Decision::Deny, "rm").with_message("blocked"),
370            ),
371            ConfigDirective::Rule(
372                Rule::new(RuleTarget::Command, Decision::Allow, "rm --help")
373                    .with_message("help is fine"),
374            ),
375        ]);
376        let v = config.match_command("rm --help", None).unwrap();
377        assert_eq!(v.decision, Decision::Allow);
378        assert_eq!(v.reason, "help is fine");
379    }
380
381    #[test]
382    fn alias_resolution() {
383        let config = Config {
384            aliases: vec![("~/custom-git".into(), "git".into())],
385            ..Config::default()
386        };
387        assert_eq!(config.resolve_alias("~/custom-git"), "git");
388        assert_eq!(config.resolve_alias("npm"), "npm");
389    }
390
391    #[test]
392    fn match_redirect_last_wins() {
393        let config = Config::from_directives(vec![
394            ConfigDirective::Rule(
395                Rule::new(RuleTarget::Redirect, Decision::Deny, "/etc/*")
396                    .with_message("no writes to /etc"),
397            ),
398            ConfigDirective::Rule(
399                Rule::new(RuleTarget::Redirect, Decision::Allow, "/etc/hosts")
400                    .with_message("hosts ok"),
401            ),
402        ]);
403        let v = config.match_redirect("/etc/hosts", None).unwrap();
404        assert_eq!(v.decision, Decision::Allow);
405    }
406
407    #[test]
408    fn settings_extracted() {
409        let config = Config::from_directives(vec![
410            ConfigDirective::Set {
411                key: "default".into(),
412                value: "deny".into(),
413            },
414            ConfigDirective::Set {
415                key: "log".into(),
416                value: "~/.rippy/audit.log".into(),
417            },
418            ConfigDirective::Set {
419                key: "log-full".into(),
420                value: String::new(),
421            },
422        ]);
423        assert_eq!(config.default_action, Some(Decision::Deny));
424        assert!(config.log_file.is_some());
425        assert!(config.log_full);
426    }
427
428    #[test]
429    fn match_mcp_rule() {
430        let config = Config::from_directives(vec![ConfigDirective::Rule(Rule::new(
431            RuleTarget::Mcp,
432            Decision::Deny,
433            "dangerous*",
434        ))]);
435        let v = config.match_mcp("dangerous_tool").unwrap();
436        assert_eq!(v.decision, Decision::Deny);
437        assert!(config.match_mcp("safe_tool").is_none());
438    }
439
440    #[test]
441    fn match_after_rule() {
442        let config = Config::from_directives(vec![ConfigDirective::Rule(
443            Rule::new(RuleTarget::After, Decision::Allow, "git commit").with_message("committed!"),
444        )]);
445        assert_eq!(
446            config.match_after("git commit -m foo"),
447            Some("committed!".into())
448        );
449        assert!(config.match_after("ls").is_none());
450    }
451
452    #[test]
453    fn allow_uv_run_python_c() {
454        let config = Config::from_directives(vec![
455            ConfigDirective::Rule(
456                Rule::new(RuleTarget::Command, Decision::Deny, "python")
457                    .with_message("Use uv run python"),
458            ),
459            ConfigDirective::Rule(Rule::new(
460                RuleTarget::Command,
461                Decision::Allow,
462                "uv run python -c",
463            )),
464        ]);
465        let v = config.match_command("python foo.py", None).unwrap();
466        assert_eq!(v.decision, Decision::Deny);
467        let v = config
468            .match_command("uv run python -c 'print(1)'", None)
469            .unwrap();
470        assert_eq!(v.decision, Decision::Allow);
471    }
472
473    #[test]
474    fn match_file_read_rules() {
475        let config = Config::from_directives(vec![
476            ConfigDirective::Rule(
477                Rule::new(RuleTarget::FileRead, Decision::Deny, "**/.env*").with_message("no env"),
478            ),
479            ConfigDirective::Rule(Rule::new(RuleTarget::FileRead, Decision::Allow, "/tmp/**")),
480        ]);
481        let v = config.match_file_read(".env.local", None).unwrap();
482        assert_eq!(v.decision, Decision::Deny);
483        assert_eq!(v.reason, "no env");
484
485        let v = config.match_file_read("/tmp/safe.txt", None).unwrap();
486        assert_eq!(v.decision, Decision::Allow);
487
488        assert!(config.match_file_read("main.rs", None).is_none());
489    }
490
491    #[test]
492    fn match_file_write_rules() {
493        let config = Config::from_directives(vec![ConfigDirective::Rule(
494            Rule::new(RuleTarget::FileWrite, Decision::Deny, "**/.rippy*")
495                .with_message("config protected"),
496        )]);
497        let v = config.match_file_write(".rippy.toml", None).unwrap();
498        assert_eq!(v.decision, Decision::Deny);
499        assert!(config.match_file_write("other.txt", None).is_none());
500    }
501
502    #[test]
503    fn match_file_edit_rules() {
504        let config = Config::from_directives(vec![ConfigDirective::Rule(
505            Rule::new(RuleTarget::FileEdit, Decision::Ask, "**/node_modules/**")
506                .with_message("vendor"),
507        )]);
508        let v = config
509            .match_file_edit("node_modules/pkg/index.js", None)
510            .unwrap();
511        assert_eq!(v.decision, Decision::Ask);
512        assert!(config.match_file_edit("src/main.rs", None).is_none());
513    }
514
515    #[test]
516    fn file_rules_last_match_wins() {
517        let config = Config::from_directives(vec![
518            ConfigDirective::Rule(Rule::new(RuleTarget::FileRead, Decision::Allow, "**")),
519            ConfigDirective::Rule(
520                Rule::new(RuleTarget::FileRead, Decision::Deny, "**/.env*").with_message("blocked"),
521            ),
522        ]);
523        let v = config.match_file_read(".env", None).unwrap();
524        assert_eq!(v.decision, Decision::Deny);
525        let v = config.match_file_read("main.rs", None).unwrap();
526        assert_eq!(v.decision, Decision::Allow);
527    }
528
529    #[test]
530    fn conditional_rule_skipped_when_condition_fails() {
531        let config = Config::from_directives(vec![ConfigDirective::Rule(
532            Rule::new(RuleTarget::Command, Decision::Deny, "echo *")
533                .with_message("blocked on main")
534                .with_conditions(vec![Condition::BranchEq("main".into())]),
535        )]);
536        let ctx = MatchContext {
537            branch: Some("develop"),
538            cwd: std::path::Path::new("/tmp"),
539        };
540        assert!(config.match_command("echo hello", Some(&ctx)).is_none());
541    }
542
543    #[test]
544    fn conditional_rule_applies_when_condition_passes() {
545        let config = Config::from_directives(vec![ConfigDirective::Rule(
546            Rule::new(RuleTarget::Command, Decision::Deny, "echo *")
547                .with_message("blocked on main")
548                .with_conditions(vec![Condition::BranchEq("main".into())]),
549        )]);
550        let ctx = MatchContext {
551            branch: Some("main"),
552            cwd: std::path::Path::new("/tmp"),
553        };
554        let v = config.match_command("echo hello", Some(&ctx)).unwrap();
555        assert_eq!(v.decision, Decision::Deny);
556        assert_eq!(v.reason, "blocked on main");
557    }
558
559    #[test]
560    fn conditional_rule_skipped_without_context() {
561        let config = Config::from_directives(vec![ConfigDirective::Rule(
562            Rule::new(RuleTarget::Command, Decision::Deny, "echo *")
563                .with_conditions(vec![Condition::BranchEq("main".into())]),
564        )]);
565        assert!(config.match_command("echo hello", None).is_none());
566    }
567
568    #[test]
569    fn structured_rule_in_config() {
570        let mut rule = Rule::new(RuleTarget::Command, Decision::Deny, "*");
571        rule.pattern = crate::pattern::Pattern::any();
572        rule.command = Some("git".into());
573        rule.subcommand = Some("push".into());
574        let config = Config::from_directives(vec![ConfigDirective::Rule(rule)]);
575        let v = config.match_command("git push origin main", None);
576        assert!(v.is_some());
577        assert_eq!(v.unwrap().decision, Decision::Deny);
578        assert!(config.match_command("git status", None).is_none());
579    }
580
581    #[test]
582    fn structured_rule_with_when_condition() {
583        let mut rule = Rule::new(RuleTarget::Command, Decision::Deny, "*");
584        rule.pattern = crate::pattern::Pattern::any();
585        rule.command = Some("git".into());
586        rule.subcommand = Some("push".into());
587        let rule = rule.with_conditions(vec![Condition::BranchEq("main".into())]);
588        let config = Config::from_directives(vec![ConfigDirective::Rule(rule)]);
589        let ctx_main = MatchContext {
590            branch: Some("main"),
591            cwd: std::path::Path::new("/tmp"),
592        };
593        let ctx_feat = MatchContext {
594            branch: Some("feature"),
595            cwd: std::path::Path::new("/tmp"),
596        };
597        assert!(
598            config
599                .match_command("git push origin", Some(&ctx_main))
600                .is_some()
601        );
602        assert!(
603            config
604                .match_command("git push origin", Some(&ctx_feat))
605                .is_none()
606        );
607    }
608
609    #[test]
610    fn project_rule_override_annotated() {
611        let directives = vec![
612            ConfigDirective::Rule(Rule::new(RuleTarget::Command, Decision::Deny, "rm -rf *")),
613            ConfigDirective::ProjectBoundary,
614            ConfigDirective::Rule(Rule::new(RuleTarget::Command, Decision::Allow, "rm -rf *")),
615        ];
616        let config = Config::from_directives(directives);
617        let v = config.match_command("rm -rf /tmp", None).unwrap();
618        assert_eq!(v.decision, Decision::Allow);
619        assert!(
620            v.reason.contains("overrides deny"),
621            "reason should mention override, got: {}",
622            v.reason
623        );
624    }
625
626    #[test]
627    fn project_rule_no_override_not_annotated() {
628        let directives = vec![
629            ConfigDirective::ProjectBoundary,
630            ConfigDirective::Rule(Rule::new(RuleTarget::Command, Decision::Allow, "echo *")),
631        ];
632        let config = Config::from_directives(directives);
633        let v = config.match_command("echo hello", None).unwrap();
634        assert_eq!(v.decision, Decision::Allow);
635        assert!(
636            !v.reason.contains("overrides"),
637            "no baseline deny → should not mention override, got: {}",
638            v.reason
639        );
640    }
641
642    #[test]
643    fn baseline_rule_not_annotated() {
644        let directives = vec![
645            ConfigDirective::Rule(Rule::new(RuleTarget::Command, Decision::Deny, "rm *")),
646            ConfigDirective::ProjectBoundary,
647        ];
648        let config = Config::from_directives(directives);
649        let v = config.match_command("rm -rf /", None).unwrap();
650        assert_eq!(v.decision, Decision::Deny);
651        assert!(!v.reason.contains("overrides"));
652    }
653
654    #[test]
655    fn project_ask_overriding_deny_not_annotated() {
656        let directives = vec![
657            ConfigDirective::Rule(Rule::new(RuleTarget::Command, Decision::Deny, "rm *")),
658            ConfigDirective::ProjectBoundary,
659            ConfigDirective::Rule(Rule::new(RuleTarget::Command, Decision::Ask, "rm *")),
660        ];
661        let config = Config::from_directives(directives);
662        let v = config.match_command("rm -rf /", None).unwrap();
663        assert_eq!(v.decision, Decision::Ask);
664        assert!(!v.reason.contains("overrides"));
665    }
666
667    #[test]
668    fn project_allow_overriding_ask_annotated() {
669        let directives = vec![
670            ConfigDirective::Rule(Rule::new(
671                RuleTarget::Command,
672                Decision::Ask,
673                "docker run *",
674            )),
675            ConfigDirective::ProjectBoundary,
676            ConfigDirective::Rule(Rule::new(
677                RuleTarget::Command,
678                Decision::Allow,
679                "docker run *",
680            )),
681        ];
682        let config = Config::from_directives(directives);
683        let v = config.match_command("docker run nginx", None).unwrap();
684        assert_eq!(v.decision, Decision::Allow);
685        assert!(v.reason.contains("overrides ask"));
686    }
687
688    #[test]
689    fn project_rules_range_set_correctly() {
690        let directives = vec![
691            ConfigDirective::Rule(Rule::new(RuleTarget::Command, Decision::Deny, "a")),
692            ConfigDirective::ProjectBoundary,
693            ConfigDirective::Rule(Rule::new(RuleTarget::Command, Decision::Allow, "b")),
694            ConfigDirective::ProjectBoundary,
695            ConfigDirective::Rule(Rule::new(RuleTarget::Command, Decision::Allow, "c")),
696        ];
697        let config = Config::from_directives(directives);
698        assert_eq!(config.project_rules_range, Some(1..2));
699    }
700
701    #[test]
702    fn env_override_allow_not_annotated_as_project() {
703        let directives = vec![
704            ConfigDirective::Rule(Rule::new(RuleTarget::Command, Decision::Deny, "rm *")),
705            ConfigDirective::ProjectBoundary,
706            ConfigDirective::ProjectBoundary,
707            ConfigDirective::Rule(Rule::new(RuleTarget::Command, Decision::Allow, "rm *")),
708        ];
709        let config = Config::from_directives(directives);
710        let v = config.match_command("rm -rf /", None).unwrap();
711        assert_eq!(v.decision, Decision::Allow);
712        assert!(!v.reason.contains("overrides"));
713    }
714
715    #[test]
716    fn project_default_allow_detected() {
717        let directives = vec![
718            ConfigDirective::ProjectBoundary,
719            ConfigDirective::Set {
720                key: "default".to_string(),
721                value: "allow".to_string(),
722            },
723            ConfigDirective::ProjectBoundary,
724        ];
725        let config = Config::from_directives(directives);
726        assert!(
727            config
728                .weakening_suffix()
729                .contains("default action to allow")
730        );
731    }
732
733    #[test]
734    fn project_self_protect_off_detected() {
735        let directives = vec![
736            ConfigDirective::ProjectBoundary,
737            ConfigDirective::Set {
738                key: "self-protect".to_string(),
739                value: "off".to_string(),
740            },
741            ConfigDirective::ProjectBoundary,
742        ];
743        let config = Config::from_directives(directives);
744        assert!(config.weakening_suffix().contains("self-protection"));
745    }
746
747    #[test]
748    fn project_broad_allow_detected() {
749        let directives = vec![
750            ConfigDirective::ProjectBoundary,
751            ConfigDirective::Rule(Rule::new(RuleTarget::Command, Decision::Allow, "*")),
752            ConfigDirective::ProjectBoundary,
753        ];
754        let config = Config::from_directives(directives);
755        assert!(config.weakening_suffix().contains("allows all commands"));
756    }
757
758    #[test]
759    fn project_deny_only_no_weakening_notes() {
760        let directives = vec![
761            ConfigDirective::ProjectBoundary,
762            ConfigDirective::Rule(Rule::new(RuleTarget::Command, Decision::Deny, "rm *")),
763            ConfigDirective::Set {
764                key: "default".to_string(),
765                value: "ask".to_string(),
766            },
767            ConfigDirective::ProjectBoundary,
768        ];
769        let config = Config::from_directives(directives);
770        assert!(config.weakening_suffix().is_empty());
771    }
772
773    #[test]
774    fn weakening_notes_appended_to_project_allow_verdict() {
775        let directives = vec![
776            ConfigDirective::ProjectBoundary,
777            ConfigDirective::Set {
778                key: "default".to_string(),
779                value: "allow".to_string(),
780            },
781            ConfigDirective::Rule(Rule::new(RuleTarget::Command, Decision::Allow, "echo *")),
782            ConfigDirective::ProjectBoundary,
783        ];
784        let config = Config::from_directives(directives);
785        let v = config.match_command("echo hello", None).unwrap();
786        assert_eq!(v.decision, Decision::Allow);
787        assert!(v.reason.contains("NOTE: project config"));
788        assert!(v.reason.contains("default action to allow"));
789    }
790
791    #[test]
792    fn package_setting_loads_develop_rules() {
793        let dir = tempfile::tempdir().unwrap();
794        let config_path = dir.path().join("config.toml");
795        std::fs::write(&config_path, "[settings]\npackage = \"develop\"\n").unwrap();
796
797        let mut directives = Vec::new();
798        loader::load_file(&config_path, &mut directives).unwrap();
799
800        // The config should contain a Set directive for package
801        let has_package = directives
802            .iter()
803            .any(|d| matches!(d, ConfigDirective::Set { key, value } if key == "package" && value == "develop"));
804        assert!(has_package, "should emit package setting directive");
805    }
806
807    #[test]
808    fn package_loads_via_config_pipeline() {
809        let dir = tempfile::tempdir().unwrap();
810        let home = dir.path().join("home");
811        std::fs::create_dir_all(home.join(".rippy")).unwrap();
812        std::fs::write(
813            home.join(".rippy/config.toml"),
814            "[settings]\npackage = \"develop\"\n",
815        )
816        .unwrap();
817
818        let config = Config::load_with_home(dir.path(), None, Some(home)).unwrap();
819        assert_eq!(
820            config.active_package,
821            Some(crate::packages::Package::Develop)
822        );
823        // Develop package allows cargo test
824        let v = config.match_command("cargo test", None);
825        assert!(v.is_some(), "develop package should match cargo test");
826        assert_eq!(v.unwrap().decision, Decision::Allow);
827    }
828
829    #[test]
830    fn project_package_overrides_global() {
831        let dir = tempfile::tempdir().unwrap();
832        let home = dir.path().join("home");
833        std::fs::create_dir_all(home.join(".rippy")).unwrap();
834        std::fs::write(
835            home.join(".rippy/config.toml"),
836            "[settings]\npackage = \"develop\"\n",
837        )
838        .unwrap();
839
840        let project = dir.path().join("project");
841        std::fs::create_dir_all(&project).unwrap();
842        std::fs::write(
843            project.join(".rippy.toml"),
844            "[settings]\npackage = \"review\"\n",
845        )
846        .unwrap();
847
848        let config = Config::load_with_home(&project, None, Some(home)).unwrap();
849        assert_eq!(
850            config.active_package,
851            Some(crate::packages::Package::Review)
852        );
853    }
854
855    #[test]
856    fn no_package_setting_backward_compatible() {
857        let dir = tempfile::tempdir().unwrap();
858        let config = Config::load_with_home(dir.path(), None, None).unwrap();
859        assert_eq!(config.active_package, None);
860    }
861
862    #[test]
863    fn user_rules_override_package_rules() {
864        let dir = tempfile::tempdir().unwrap();
865        let home = dir.path().join("home");
866        std::fs::create_dir_all(home.join(".rippy")).unwrap();
867        // develop package allows rm, but user config overrides to deny
868        std::fs::write(
869            home.join(".rippy/config.toml"),
870            "[settings]\npackage = \"develop\"\n\n\
871             [[rules]]\naction = \"deny\"\ncommand = \"rm\"\nmessage = \"no rm\"\n",
872        )
873        .unwrap();
874
875        let config = Config::load_with_home(dir.path(), None, Some(home)).unwrap();
876        let v = config.match_command("rm foo", None);
877        assert!(v.is_some());
878        assert_eq!(v.unwrap().decision, Decision::Deny);
879    }
880
881    #[test]
882    fn line_based_config_package_setting() {
883        let dir = tempfile::tempdir().unwrap();
884        let home = dir.path().join("home");
885        std::fs::create_dir_all(home.join(".rippy")).unwrap();
886        // Line-based format (no .toml extension)
887        std::fs::write(home.join(".rippy/config"), "set package develop\n").unwrap();
888
889        let config = Config::load_with_home(dir.path(), None, Some(home)).unwrap();
890        assert_eq!(
891            config.active_package,
892            Some(crate::packages::Package::Develop)
893        );
894    }
895
896    #[test]
897    fn invalid_package_name_produces_none() {
898        let dir = tempfile::tempdir().unwrap();
899        let home = dir.path().join("home");
900        std::fs::create_dir_all(home.join(".rippy")).unwrap();
901        std::fs::write(
902            home.join(".rippy/config.toml"),
903            "[settings]\npackage = \"yolo\"\n",
904        )
905        .unwrap();
906
907        let config = Config::load_with_home(dir.path(), None, Some(home)).unwrap();
908        // Invalid package name is gracefully ignored (with stderr warning)
909        assert_eq!(config.active_package, None);
910    }
911
912    #[test]
913    fn custom_package_loads_via_config_pipeline() {
914        let dir = tempfile::tempdir().unwrap();
915        let home = dir.path().join("home");
916        std::fs::create_dir_all(home.join(".rippy/packages")).unwrap();
917
918        // Custom package extending develop adds a deny rule for `npm publish`.
919        std::fs::write(
920            home.join(".rippy/packages/team.toml"),
921            r#"
922[meta]
923name = "team"
924extends = "develop"
925
926[[rules]]
927action = "deny"
928pattern = "npm publish"
929message = "team policy"
930"#,
931        )
932        .unwrap();
933
934        // Global config activates the custom package.
935        std::fs::create_dir_all(home.join(".rippy")).unwrap();
936        std::fs::write(
937            home.join(".rippy/config.toml"),
938            "[settings]\npackage = \"team\"\n",
939        )
940        .unwrap();
941
942        let config = Config::load_with_home(dir.path(), None, Some(home)).unwrap();
943
944        // Active package is the custom one.
945        match &config.active_package {
946            Some(crate::packages::Package::Custom(c)) => assert_eq!(c.name, "team"),
947            other => panic!("expected Custom(team), got {other:?}"),
948        }
949
950        // Inherited from develop:
951        let v = config.match_command("cargo test", None);
952        assert!(v.is_some());
953        assert_eq!(v.unwrap().decision, Decision::Allow);
954
955        // Added by custom team package:
956        let v = config.match_command("npm publish", None);
957        assert!(v.is_some());
958        assert_eq!(v.unwrap().decision, Decision::Deny);
959    }
960}