1use std::collections::HashMap;
8use std::path::Path;
9
10use serde::{Deserialize, Serialize};
11
12use crate::agent_phase::AgentPhaseState;
13use crate::bundle::{ReviewBundle, RiskLevel};
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ApprovalPolicy {
20 pub name: String,
22 #[serde(default)]
24 pub step_policies: HashMap<String, StepPolicy>,
25 #[serde(default)]
27 pub rules: Vec<ApprovalRule>,
28 #[serde(default)]
30 pub default_action: ApprovalAction,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum StepPolicy {
37 Always,
39 Auto,
41 Conditional,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct ApprovalRule {
48 pub name: String,
49 pub condition: ApprovalCondition,
50 pub action: ApprovalAction,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub reason: Option<String>,
53 #[serde(default)]
54 pub notify: bool,
55}
56
57#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum ApprovalAction {
61 #[default]
63 RequireApproval,
64 AutoApprove,
66 AutoApproveWithNotify,
68}
69
70#[derive(Debug, Clone, Default, Serialize, Deserialize)]
73pub struct ApprovalCondition {
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub risk_gte: Option<RiskLevel>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub risk_eq: Option<RiskLevel>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub files_changed_gt: Option<u32>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub files_changed_lte: Option<u32>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub path_matches: Option<String>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub tests_all_passed: Option<bool>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub touches_off_limits: Option<bool>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub phase: Option<String>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub time_outside: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub consecutive_failures_gte: Option<u32>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct ApprovalDecision {
109 pub action: ApprovalAction,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub matched_rule: Option<String>,
114 pub reason: String,
116 pub overridable: bool,
118}
119
120pub struct EvalContext<'a> {
122 pub bundle: &'a ReviewBundle,
123 pub phase: &'a AgentPhaseState,
124 pub off_limits_touched: bool,
125 pub consecutive_failures: u32,
126 pub current_time: Option<time::OffsetDateTime>,
127}
128
129impl ApprovalPolicy {
132 pub fn default_policy() -> Self {
141 let mut step_policies = HashMap::new();
142 step_policies.insert("plan_approval".to_string(), StepPolicy::Always);
143 step_policies.insert("pr_merge".to_string(), StepPolicy::Always);
144
145 let rules = vec![
146 ApprovalRule {
147 name: "high-risk".to_string(),
148 condition: ApprovalCondition {
149 risk_gte: Some(RiskLevel::High),
150 ..Default::default()
151 },
152 action: ApprovalAction::RequireApproval,
153 reason: Some("high or critical risk level".to_string()),
154 notify: false,
155 },
156 ApprovalRule {
157 name: "large-change".to_string(),
158 condition: ApprovalCondition {
159 files_changed_gt: Some(20),
160 ..Default::default()
161 },
162 action: ApprovalAction::RequireApproval,
163 reason: Some("more than 20 files changed".to_string()),
164 notify: false,
165 },
166 ApprovalRule {
167 name: "off-limits".to_string(),
168 condition: ApprovalCondition {
169 touches_off_limits: Some(true),
170 ..Default::default()
171 },
172 action: ApprovalAction::RequireApproval,
173 reason: Some("off-limits files touched".to_string()),
174 notify: false,
175 },
176 ApprovalRule {
177 name: "safe-auto".to_string(),
178 condition: ApprovalCondition {
179 risk_eq: Some(RiskLevel::Low),
180 tests_all_passed: Some(true),
181 files_changed_lte: Some(5),
182 ..Default::default()
183 },
184 action: ApprovalAction::AutoApprove,
185 reason: Some("low risk, all tests pass, small change".to_string()),
186 notify: false,
187 },
188 ];
189
190 Self {
191 name: "default".to_string(),
192 step_policies,
193 rules,
194 default_action: ApprovalAction::RequireApproval,
195 }
196 }
197}
198
199impl ApprovalCondition {
202 pub fn matches(&self, ctx: &EvalContext<'_>) -> bool {
205 if let Some(threshold) = &self.risk_gte {
206 if ctx.bundle.risk_assessment.level < *threshold {
207 return false;
208 }
209 }
210
211 if let Some(expected) = &self.risk_eq {
212 if ctx.bundle.risk_assessment.level != *expected {
213 return false;
214 }
215 }
216
217 let file_count = ctx.bundle.change_summary.files.len() as u32;
218
219 if let Some(threshold) = self.files_changed_gt {
220 if file_count <= threshold {
221 return false;
222 }
223 }
224
225 if let Some(threshold) = self.files_changed_lte {
226 if file_count > threshold {
227 return false;
228 }
229 }
230
231 if let Some(pattern) = &self.path_matches {
232 if let Ok(glob) = globset::Glob::new(pattern) {
233 let matcher = glob.compile_matcher();
234 let any_match = ctx
235 .bundle
236 .change_summary
237 .files
238 .iter()
239 .any(|f| matcher.is_match(&f.path));
240 if !any_match {
241 return false;
242 }
243 } else {
244 return false;
246 }
247 }
248
249 if let Some(expected) = self.tests_all_passed {
250 let all_passed = ctx.bundle.test_results.failed == 0;
251 if all_passed != expected {
252 return false;
253 }
254 }
255
256 if let Some(expected) = self.touches_off_limits {
257 if ctx.off_limits_touched != expected {
258 return false;
259 }
260 }
261
262 if let Some(expected_phase) = &self.phase {
263 if ctx.phase.phase.to_string() != *expected_phase {
264 return false;
265 }
266 }
267
268 if let Some(range_str) = &self.time_outside {
269 if let Some(now) = &ctx.current_time {
270 if let Some((start, end)) = parse_time_range(range_str) {
271 let current_minutes = now.hour() as u32 * 60 + now.minute() as u32;
272 let within = if start <= end {
274 current_minutes >= start && current_minutes < end
275 } else {
276 current_minutes >= start || current_minutes < end
278 };
279 if within {
280 return false; }
282 } else {
283 return false;
285 }
286 } else {
287 return false;
289 }
290 }
291
292 if let Some(threshold) = self.consecutive_failures_gte {
293 if ctx.consecutive_failures < threshold {
294 return false;
295 }
296 }
297
298 true
299 }
300}
301
302fn parse_time_range(s: &str) -> Option<(u32, u32)> {
304 let parts: Vec<&str> = s.split('-').collect();
305 if parts.len() != 2 {
306 return None;
307 }
308 let start = parse_hhmm(parts[0])?;
309 let end = parse_hhmm(parts[1])?;
310 Some((start, end))
311}
312
313fn parse_hhmm(s: &str) -> Option<u32> {
314 let parts: Vec<&str> = s.trim().split(':').collect();
315 if parts.len() != 2 {
316 return None;
317 }
318 let h: u32 = parts[0].parse().ok()?;
319 let m: u32 = parts[1].parse().ok()?;
320 if h >= 24 || m >= 60 {
321 return None;
322 }
323 Some(h * 60 + m)
324}
325
326impl ApprovalPolicy {
329 pub fn evaluate(&self, step: &str, ctx: &EvalContext<'_>) -> ApprovalDecision {
331 if let Some(step_policy) = self.step_policies.get(step) {
333 match step_policy {
334 StepPolicy::Always => {
335 return ApprovalDecision {
336 action: ApprovalAction::RequireApproval,
337 matched_rule: None,
338 reason: format!("step '{}' always requires approval", step),
339 overridable: false,
340 };
341 }
342 StepPolicy::Auto => {
343 return ApprovalDecision {
344 action: ApprovalAction::AutoApprove,
345 matched_rule: None,
346 reason: format!("step '{}' is auto-approved", step),
347 overridable: true,
348 };
349 }
350 StepPolicy::Conditional => {
351 }
353 }
354 }
355
356 for rule in &self.rules {
358 if rule.condition.matches(ctx) {
359 let reason = rule
360 .reason
361 .clone()
362 .unwrap_or_else(|| format!("matched rule '{}'", rule.name));
363 let action = if rule.notify && rule.action == ApprovalAction::AutoApprove {
364 ApprovalAction::AutoApproveWithNotify
365 } else {
366 rule.action.clone()
367 };
368 return ApprovalDecision {
369 action,
370 matched_rule: Some(rule.name.clone()),
371 reason,
372 overridable: true,
373 };
374 }
375 }
376
377 ApprovalDecision {
379 action: self.default_action.clone(),
380 matched_rule: None,
381 reason: "no rule matched; fail-safe".to_string(),
382 overridable: true,
383 }
384 }
385}
386
387pub fn load_approval_policy(edda_dir: &Path) -> anyhow::Result<ApprovalPolicy> {
392 let path = edda_dir.join("approval-policy.yaml");
393 if !path.exists() {
394 return Ok(ApprovalPolicy::default_policy());
395 }
396 let content = std::fs::read(&path)?;
397 let policy: ApprovalPolicy = serde_yaml::from_slice(&content)?;
398 Ok(policy)
399}
400
401pub fn generate_template() -> String {
403 let policy = ApprovalPolicy::default_policy();
404 serde_yaml::to_string(&policy).unwrap_or_default()
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410 use crate::agent_phase::AgentPhase;
411 use crate::bundle::*;
412
413 fn sample_bundle(risk: RiskLevel, file_count: usize, failures: u32) -> ReviewBundle {
414 let files: Vec<FileChange> = (0..file_count)
415 .map(|i| FileChange {
416 path: format!("src/file_{}.rs", i),
417 added: 10,
418 deleted: 2,
419 })
420 .collect();
421
422 ReviewBundle {
423 bundle_id: "bun_test".to_string(),
424 change_summary: ChangeSummary {
425 files,
426 total_added: 10 * file_count as u32,
427 total_deleted: 2 * file_count as u32,
428 diff_ref: "HEAD~1".to_string(),
429 },
430 test_results: TestResults {
431 passed: 50,
432 failed: failures,
433 ignored: 0,
434 total: 50 + failures,
435 failures: vec![],
436 command: "cargo test".to_string(),
437 },
438 risk_assessment: RiskAssessment {
439 level: risk,
440 factors: vec![],
441 },
442 suggested_action: SuggestedAction::Approve,
443 suggested_reason: "test".to_string(),
444 }
445 }
446
447 fn sample_phase() -> AgentPhaseState {
448 AgentPhaseState {
449 phase: AgentPhase::Implement,
450 session_id: "sess-test".to_string(),
451 label: None,
452 issue: Some(120),
453 pr: None,
454 branch: None,
455 confidence: 0.9,
456 detected_at: "2026-03-12T10:00:00Z".to_string(),
457 signals: vec![],
458 }
459 }
460
461 fn make_ctx<'a>(
462 bundle: &'a ReviewBundle,
463 phase: &'a AgentPhaseState,
464 off_limits: bool,
465 failures: u32,
466 ) -> EvalContext<'a> {
467 EvalContext {
468 bundle,
469 phase,
470 off_limits_touched: off_limits,
471 consecutive_failures: failures,
472 current_time: None,
473 }
474 }
475
476 #[test]
479 fn default_policy_step_always() {
480 let policy = ApprovalPolicy::default_policy();
481 let bundle = sample_bundle(RiskLevel::Low, 1, 0);
482 let phase = sample_phase();
483 let ctx = make_ctx(&bundle, &phase, false, 0);
484
485 let decision = policy.evaluate("plan_approval", &ctx);
486 assert_eq!(decision.action, ApprovalAction::RequireApproval);
487 assert!(decision.reason.contains("always requires approval"));
488
489 let decision = policy.evaluate("pr_merge", &ctx);
490 assert_eq!(decision.action, ApprovalAction::RequireApproval);
491 }
492
493 #[test]
494 fn default_policy_high_risk() {
495 let policy = ApprovalPolicy::default_policy();
496 let bundle = sample_bundle(RiskLevel::High, 3, 0);
497 let phase = sample_phase();
498 let ctx = make_ctx(&bundle, &phase, false, 0);
499
500 let decision = policy.evaluate("implement", &ctx);
501 assert_eq!(decision.action, ApprovalAction::RequireApproval);
502 assert_eq!(decision.matched_rule.as_deref(), Some("high-risk"));
503 }
504
505 #[test]
506 fn default_policy_critical_risk_matches_high_gte() {
507 let policy = ApprovalPolicy::default_policy();
508 let bundle = sample_bundle(RiskLevel::Critical, 3, 0);
509 let phase = sample_phase();
510 let ctx = make_ctx(&bundle, &phase, false, 0);
511
512 let decision = policy.evaluate("implement", &ctx);
513 assert_eq!(decision.action, ApprovalAction::RequireApproval);
514 assert_eq!(decision.matched_rule.as_deref(), Some("high-risk"));
515 }
516
517 #[test]
518 fn default_policy_large_change() {
519 let policy = ApprovalPolicy::default_policy();
520 let bundle = sample_bundle(RiskLevel::Medium, 25, 0);
521 let phase = sample_phase();
522 let ctx = make_ctx(&bundle, &phase, false, 0);
523
524 let decision = policy.evaluate("implement", &ctx);
525 assert_eq!(decision.action, ApprovalAction::RequireApproval);
526 assert_eq!(decision.matched_rule.as_deref(), Some("large-change"));
527 }
528
529 #[test]
530 fn default_policy_off_limits() {
531 let policy = ApprovalPolicy::default_policy();
532 let bundle = sample_bundle(RiskLevel::Low, 2, 0);
533 let phase = sample_phase();
534 let ctx = make_ctx(&bundle, &phase, true, 0);
535
536 let decision = policy.evaluate("implement", &ctx);
537 assert_eq!(decision.action, ApprovalAction::RequireApproval);
538 assert_eq!(decision.matched_rule.as_deref(), Some("off-limits"));
539 }
540
541 #[test]
542 fn default_policy_safe_auto() {
543 let policy = ApprovalPolicy::default_policy();
544 let bundle = sample_bundle(RiskLevel::Low, 3, 0);
545 let phase = sample_phase();
546 let ctx = make_ctx(&bundle, &phase, false, 0);
547
548 let decision = policy.evaluate("implement", &ctx);
549 assert_eq!(decision.action, ApprovalAction::AutoApprove);
550 assert_eq!(decision.matched_rule.as_deref(), Some("safe-auto"));
551 }
552
553 #[test]
554 fn default_policy_medium_risk_no_match_failsafe() {
555 let policy = ApprovalPolicy::default_policy();
556 let bundle = sample_bundle(RiskLevel::Medium, 10, 0);
559 let phase = sample_phase();
560 let ctx = make_ctx(&bundle, &phase, false, 0);
561
562 let decision = policy.evaluate("implement", &ctx);
563 assert_eq!(decision.action, ApprovalAction::RequireApproval);
564 assert!(decision.matched_rule.is_none());
565 assert!(decision.reason.contains("fail-safe"));
566 }
567
568 #[test]
571 fn condition_empty_matches_everything() {
572 let cond = ApprovalCondition::default();
573 let bundle = sample_bundle(RiskLevel::Medium, 10, 0);
574 let phase = sample_phase();
575 let ctx = make_ctx(&bundle, &phase, false, 0);
576 assert!(cond.matches(&ctx));
577 }
578
579 #[test]
580 fn condition_risk_gte() {
581 let cond = ApprovalCondition {
582 risk_gte: Some(RiskLevel::High),
583 ..Default::default()
584 };
585 let bundle_low = sample_bundle(RiskLevel::Low, 1, 0);
586 let bundle_high = sample_bundle(RiskLevel::High, 1, 0);
587 let phase = sample_phase();
588
589 assert!(!cond.matches(&make_ctx(&bundle_low, &phase, false, 0)));
590 assert!(cond.matches(&make_ctx(&bundle_high, &phase, false, 0)));
591 }
592
593 #[test]
594 fn condition_files_changed_boundaries() {
595 let cond = ApprovalCondition {
596 files_changed_gt: Some(5),
597 ..Default::default()
598 };
599 let bundle_5 = sample_bundle(RiskLevel::Low, 5, 0);
600 let bundle_6 = sample_bundle(RiskLevel::Low, 6, 0);
601 let phase = sample_phase();
602
603 assert!(!cond.matches(&make_ctx(&bundle_5, &phase, false, 0)));
604 assert!(cond.matches(&make_ctx(&bundle_6, &phase, false, 0)));
605 }
606
607 #[test]
608 fn condition_files_changed_lte() {
609 let cond = ApprovalCondition {
610 files_changed_lte: Some(5),
611 ..Default::default()
612 };
613 let bundle_5 = sample_bundle(RiskLevel::Low, 5, 0);
614 let bundle_6 = sample_bundle(RiskLevel::Low, 6, 0);
615 let phase = sample_phase();
616
617 assert!(cond.matches(&make_ctx(&bundle_5, &phase, false, 0)));
618 assert!(!cond.matches(&make_ctx(&bundle_6, &phase, false, 0)));
619 }
620
621 #[test]
622 fn condition_path_matches() {
623 let cond = ApprovalCondition {
624 path_matches: Some("src/auth/**".to_string()),
625 ..Default::default()
626 };
627
628 let mut bundle = sample_bundle(RiskLevel::Low, 1, 0);
629 bundle.change_summary.files = vec![FileChange {
630 path: "src/auth/login.rs".to_string(),
631 added: 5,
632 deleted: 1,
633 }];
634 let phase = sample_phase();
635 assert!(cond.matches(&make_ctx(&bundle, &phase, false, 0)));
636
637 let mut bundle_no_match = sample_bundle(RiskLevel::Low, 1, 0);
638 bundle_no_match.change_summary.files = vec![FileChange {
639 path: "src/db/query.rs".to_string(),
640 added: 5,
641 deleted: 1,
642 }];
643 assert!(!cond.matches(&make_ctx(&bundle_no_match, &phase, false, 0)));
644 }
645
646 #[test]
647 fn condition_tests_all_passed() {
648 let cond = ApprovalCondition {
649 tests_all_passed: Some(true),
650 ..Default::default()
651 };
652 let bundle_pass = sample_bundle(RiskLevel::Low, 1, 0);
653 let bundle_fail = sample_bundle(RiskLevel::Low, 1, 3);
654 let phase = sample_phase();
655
656 assert!(cond.matches(&make_ctx(&bundle_pass, &phase, false, 0)));
657 assert!(!cond.matches(&make_ctx(&bundle_fail, &phase, false, 0)));
658 }
659
660 #[test]
661 fn condition_touches_off_limits() {
662 let cond = ApprovalCondition {
663 touches_off_limits: Some(true),
664 ..Default::default()
665 };
666 let bundle = sample_bundle(RiskLevel::Low, 1, 0);
667 let phase = sample_phase();
668
669 assert!(cond.matches(&make_ctx(&bundle, &phase, true, 0)));
670 assert!(!cond.matches(&make_ctx(&bundle, &phase, false, 0)));
671 }
672
673 #[test]
674 fn condition_phase() {
675 let cond = ApprovalCondition {
676 phase: Some("implement".to_string()),
677 ..Default::default()
678 };
679 let bundle = sample_bundle(RiskLevel::Low, 1, 0);
680 let phase_impl = sample_phase();
681 let mut phase_review = sample_phase();
682 phase_review.phase = AgentPhase::Review;
683
684 assert!(cond.matches(&make_ctx(&bundle, &phase_impl, false, 0)));
685 assert!(!cond.matches(&make_ctx(&bundle, &phase_review, false, 0)));
686 }
687
688 #[test]
689 fn condition_consecutive_failures() {
690 let cond = ApprovalCondition {
691 consecutive_failures_gte: Some(3),
692 ..Default::default()
693 };
694 let bundle = sample_bundle(RiskLevel::Low, 1, 0);
695 let phase = sample_phase();
696
697 assert!(!cond.matches(&make_ctx(&bundle, &phase, false, 2)));
698 assert!(cond.matches(&make_ctx(&bundle, &phase, false, 3)));
699 assert!(cond.matches(&make_ctx(&bundle, &phase, false, 5)));
700 }
701
702 #[test]
703 fn condition_and_semantics() {
704 let cond = ApprovalCondition {
706 risk_eq: Some(RiskLevel::Low),
707 files_changed_lte: Some(5),
708 ..Default::default()
709 };
710 let phase = sample_phase();
711
712 let bundle_ok = sample_bundle(RiskLevel::Low, 3, 0);
714 assert!(cond.matches(&make_ctx(&bundle_ok, &phase, false, 0)));
715
716 let bundle_risk = sample_bundle(RiskLevel::Medium, 3, 0);
718 assert!(!cond.matches(&make_ctx(&bundle_risk, &phase, false, 0)));
719
720 let bundle_files = sample_bundle(RiskLevel::Low, 10, 0);
722 assert!(!cond.matches(&make_ctx(&bundle_files, &phase, false, 0)));
723 }
724
725 #[test]
728 fn step_auto_overrides_rules() {
729 let mut policy = ApprovalPolicy::default_policy();
730 policy
731 .step_policies
732 .insert("test".to_string(), StepPolicy::Auto);
733
734 let bundle = sample_bundle(RiskLevel::Critical, 100, 10);
735 let phase = sample_phase();
736 let ctx = make_ctx(&bundle, &phase, true, 5);
737
738 let decision = policy.evaluate("test", &ctx);
739 assert_eq!(decision.action, ApprovalAction::AutoApprove);
740 assert!(decision.reason.contains("auto-approved"));
741 }
742
743 #[test]
744 fn step_conditional_falls_through_to_rules() {
745 let mut policy = ApprovalPolicy::default_policy();
746 policy
747 .step_policies
748 .insert("deploy".to_string(), StepPolicy::Conditional);
749
750 let bundle = sample_bundle(RiskLevel::High, 5, 0);
751 let phase = sample_phase();
752 let ctx = make_ctx(&bundle, &phase, false, 0);
753
754 let decision = policy.evaluate("deploy", &ctx);
755 assert_eq!(decision.action, ApprovalAction::RequireApproval);
756 assert_eq!(decision.matched_rule.as_deref(), Some("high-risk"));
757 }
758
759 #[test]
760 fn unknown_step_falls_through_to_rules() {
761 let policy = ApprovalPolicy::default_policy();
762 let bundle = sample_bundle(RiskLevel::Low, 2, 0);
763 let phase = sample_phase();
764 let ctx = make_ctx(&bundle, &phase, false, 0);
765
766 let decision = policy.evaluate("custom_step", &ctx);
767 assert_eq!(decision.action, ApprovalAction::AutoApprove);
769 }
770
771 #[test]
772 fn first_match_wins() {
773 let policy = ApprovalPolicy {
774 name: "test".to_string(),
775 step_policies: HashMap::new(),
776 rules: vec![
777 ApprovalRule {
778 name: "catch-all-approve".to_string(),
779 condition: ApprovalCondition::default(), action: ApprovalAction::AutoApprove,
781 reason: Some("catch-all".to_string()),
782 notify: false,
783 },
784 ApprovalRule {
785 name: "never-reached".to_string(),
786 condition: ApprovalCondition::default(),
787 action: ApprovalAction::RequireApproval,
788 reason: None,
789 notify: false,
790 },
791 ],
792 default_action: ApprovalAction::RequireApproval,
793 };
794
795 let bundle = sample_bundle(RiskLevel::Critical, 100, 10);
796 let phase = sample_phase();
797 let ctx = make_ctx(&bundle, &phase, true, 5);
798
799 let decision = policy.evaluate("anything", &ctx);
800 assert_eq!(decision.action, ApprovalAction::AutoApprove);
801 assert_eq!(decision.matched_rule.as_deref(), Some("catch-all-approve"));
802 }
803
804 #[test]
805 fn empty_rules_uses_default_action() {
806 let policy = ApprovalPolicy {
807 name: "minimal".to_string(),
808 step_policies: HashMap::new(),
809 rules: vec![],
810 default_action: ApprovalAction::AutoApproveWithNotify,
811 };
812
813 let bundle = sample_bundle(RiskLevel::Low, 1, 0);
814 let phase = sample_phase();
815 let ctx = make_ctx(&bundle, &phase, false, 0);
816
817 let decision = policy.evaluate("anything", &ctx);
818 assert_eq!(decision.action, ApprovalAction::AutoApproveWithNotify);
819 assert!(decision.matched_rule.is_none());
820 }
821
822 #[test]
823 fn notify_flag_upgrades_auto_approve() {
824 let policy = ApprovalPolicy {
825 name: "test".to_string(),
826 step_policies: HashMap::new(),
827 rules: vec![ApprovalRule {
828 name: "notify-rule".to_string(),
829 condition: ApprovalCondition::default(),
830 action: ApprovalAction::AutoApprove,
831 reason: Some("auto with notify".to_string()),
832 notify: true,
833 }],
834 default_action: ApprovalAction::RequireApproval,
835 };
836
837 let bundle = sample_bundle(RiskLevel::Low, 1, 0);
838 let phase = sample_phase();
839 let ctx = make_ctx(&bundle, &phase, false, 0);
840
841 let decision = policy.evaluate("step", &ctx);
842 assert_eq!(decision.action, ApprovalAction::AutoApproveWithNotify);
843 }
844
845 #[test]
848 fn policy_yaml_roundtrip() {
849 let policy = ApprovalPolicy::default_policy();
850 let yaml = serde_yaml::to_string(&policy).unwrap();
851 let parsed: ApprovalPolicy = serde_yaml::from_str(&yaml).unwrap();
852 assert_eq!(parsed.name, "default");
853 assert_eq!(parsed.rules.len(), 4);
854 assert_eq!(
855 parsed.step_policies.get("plan_approval"),
856 Some(&StepPolicy::Always)
857 );
858 }
859
860 #[test]
861 fn approval_action_json_roundtrip() {
862 for action in [
863 ApprovalAction::RequireApproval,
864 ApprovalAction::AutoApprove,
865 ApprovalAction::AutoApproveWithNotify,
866 ] {
867 let json = serde_json::to_string(&action).unwrap();
868 let back: ApprovalAction = serde_json::from_str(&json).unwrap();
869 assert_eq!(back, action);
870 }
871 }
872
873 #[test]
874 fn step_policy_json_roundtrip() {
875 for sp in [
876 StepPolicy::Always,
877 StepPolicy::Auto,
878 StepPolicy::Conditional,
879 ] {
880 let json = serde_json::to_string(&sp).unwrap();
881 let back: StepPolicy = serde_json::from_str(&json).unwrap();
882 assert_eq!(back, sp);
883 }
884 }
885
886 #[test]
889 fn load_missing_file_returns_default() {
890 let dir = std::path::PathBuf::from("/nonexistent/path");
891 let policy = load_approval_policy(&dir).unwrap();
892 assert_eq!(policy.name, "default");
893 assert_eq!(policy.rules.len(), 4);
894 }
895
896 #[test]
897 fn load_custom_policy_from_yaml() {
898 let dir = tempfile::tempdir().unwrap();
899 let yaml = r#"
900name: custom-project
901step_policies:
902 plan_approval: always
903 pr_merge: always
904 implement: auto
905rules:
906 - name: auth-module
907 condition:
908 path_matches: "src/auth/**"
909 action: require_approval
910 reason: "auth changes need review"
911 notify: false
912default_action: auto_approve
913"#;
914 std::fs::write(dir.path().join("approval-policy.yaml"), yaml).unwrap();
915 let policy = load_approval_policy(dir.path()).unwrap();
916 assert_eq!(policy.name, "custom-project");
917 assert_eq!(policy.rules.len(), 1);
918 assert_eq!(policy.rules[0].name, "auth-module");
919 assert_eq!(policy.default_action, ApprovalAction::AutoApprove);
920 assert_eq!(
921 policy.step_policies.get("implement"),
922 Some(&StepPolicy::Auto)
923 );
924 }
925
926 #[test]
929 fn parse_time_range_valid() {
930 assert_eq!(parse_time_range("09:00-18:00"), Some((540, 1080)));
931 assert_eq!(parse_time_range("22:00-06:00"), Some((1320, 360)));
932 }
933
934 #[test]
935 fn parse_time_range_invalid() {
936 assert_eq!(parse_time_range("invalid"), None);
937 assert_eq!(parse_time_range("25:00-18:00"), None);
938 assert_eq!(parse_time_range("09:00"), None);
939 }
940
941 #[test]
942 fn generate_template_is_valid_yaml() {
943 let template = generate_template();
944 let parsed: ApprovalPolicy = serde_yaml::from_str(&template).unwrap();
945 assert_eq!(parsed.name, "default");
946 }
947
948 #[test]
951 fn time_outside_with_no_current_time_does_not_match() {
952 let cond = ApprovalCondition {
953 time_outside: Some("09:00-18:00".to_string()),
954 ..Default::default()
955 };
956 let bundle = sample_bundle(RiskLevel::Low, 1, 0);
957 let phase = sample_phase();
958 let ctx = make_ctx(&bundle, &phase, false, 0);
960 assert!(!cond.matches(&ctx));
961 }
962
963 #[test]
964 fn time_outside_with_malformed_range_does_not_match() {
965 let cond = ApprovalCondition {
966 time_outside: Some("invalid".to_string()),
967 ..Default::default()
968 };
969 let bundle = sample_bundle(RiskLevel::Low, 1, 0);
970 let phase = sample_phase();
971 let now = time::OffsetDateTime::now_utc();
972 let ctx = EvalContext {
973 bundle: &bundle,
974 phase: &phase,
975 off_limits_touched: false,
976 consecutive_failures: 0,
977 current_time: Some(now),
978 };
979 assert!(!cond.matches(&ctx));
980 }
981}