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#[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 pub trust_project_configs: bool,
45 aliases: Vec<(String, String)>,
46 pub cd_allowed_dirs: Vec<std::path::PathBuf>,
48 project_rules_range: Option<std::ops::Range<usize>>,
52 project_weakening_suffix: String,
55 pub active_package: Option<crate::packages::Package>,
57}
58
59impl Config {
60 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 pub fn load_with_home(
77 cwd: &Path,
78 env_config: Option<&Path>,
79 home: Option<PathBuf>,
80 ) -> Result<Self, RippyError> {
81 let mut directives = crate::stdlib::stdlib_directives()?;
83
84 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 #[must_use]
128 pub fn weakening_suffix(&self) -> &str {
129 &self.project_weakening_suffix
130 }
131
132 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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
321fn resolve_package(home: Option<&PathBuf>, cwd: &Path) -> Option<crate::packages::Package> {
326 let mut package_name: Option<String> = None;
327
328 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; }
339 }
340 }
341
342 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 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 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 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 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 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 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 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 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 let v = config.match_command("cargo test", None);
952 assert!(v.is_some());
953 assert_eq!(v.unwrap().decision, Decision::Allow);
954
955 let v = config.match_command("npm publish", None);
957 assert!(v.is_some());
958 assert_eq!(v.unwrap().decision, Decision::Deny);
959 }
960}