Skip to main content

edda_core/
approval.rs

1//! Approval policy engine — determines what actions require human approval.
2//!
3//! Separate from the RBAC policy in `policy.rs`. This module answers
4//! "does action Y in context Z need human approval?" rather than
5//! "can actor X do action Y?".
6
7use 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// ── Core types ──
16
17/// Top-level approval policy configuration.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ApprovalPolicy {
20    /// Human-readable name for this policy.
21    pub name: String,
22    /// Per-step overrides (e.g. "plan_approval" -> Always).
23    #[serde(default)]
24    pub step_policies: HashMap<String, StepPolicy>,
25    /// Ordered condition rules; first match wins.
26    #[serde(default)]
27    pub rules: Vec<ApprovalRule>,
28    /// Fallback action when no rule matches.
29    #[serde(default)]
30    pub default_action: ApprovalAction,
31}
32
33/// How a specific pipeline step is handled.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum StepPolicy {
37    /// Always require human approval for this step.
38    Always,
39    /// Automatically approve this step.
40    Auto,
41    /// Evaluate condition rules to decide.
42    Conditional,
43}
44
45/// A named condition rule with an action.
46#[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/// What action to take when a rule matches.
58#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum ApprovalAction {
61    /// Pause and wait for human approval.
62    #[default]
63    RequireApproval,
64    /// Automatically approve.
65    AutoApprove,
66    /// Automatically approve but send a notification.
67    AutoApproveWithNotify,
68}
69
70/// Flat condition struct — all specified fields must be true (AND semantics).
71/// Unspecified fields (None) are wildcards.
72#[derive(Debug, Clone, Default, Serialize, Deserialize)]
73pub struct ApprovalCondition {
74    /// Risk level must be >= this threshold.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub risk_gte: Option<RiskLevel>,
77    /// Risk level must equal this value.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub risk_eq: Option<RiskLevel>,
80    /// Number of changed files must be > N.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub files_changed_gt: Option<u32>,
83    /// Number of changed files must be <= N.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub files_changed_lte: Option<u32>,
86    /// Glob pattern — at least one changed file path must match.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub path_matches: Option<String>,
89    /// Whether all tests must have passed.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub tests_all_passed: Option<bool>,
92    /// Whether off-limits files are touched.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub touches_off_limits: Option<bool>,
95    /// Current agent phase must match.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub phase: Option<String>,
98    /// Time range "HH:MM-HH:MM" — matches when current time is OUTSIDE this range.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub time_outside: Option<String>,
101    /// Consecutive failure count must be >= N.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub consecutive_failures_gte: Option<u32>,
104}
105
106/// Result of evaluating approval policy.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct ApprovalDecision {
109    /// The action to take.
110    pub action: ApprovalAction,
111    /// Which rule matched (None if step-level or default).
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub matched_rule: Option<String>,
114    /// Human-readable explanation.
115    pub reason: String,
116    /// Whether the user can override this decision.
117    pub overridable: bool,
118}
119
120/// Runtime context for evaluating approval conditions.
121pub 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
129// ── Built-in defaults ──
130
131impl ApprovalPolicy {
132    /// Returns the built-in default policy (zero-config).
133    ///
134    /// - `plan_approval` and `pr_merge` always require approval
135    /// - High risk -> require approval
136    /// - 20+ files changed -> require approval
137    /// - Off-limits touched -> require approval
138    /// - Low risk + all tests pass + <= 5 files -> auto approve
139    /// - Default: require approval (fail-safe)
140    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
199// ── Condition evaluation ──
200
201impl ApprovalCondition {
202    /// Check if this condition matches the given evaluation context.
203    /// All specified fields must be true (AND semantics).
204    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                // Invalid glob pattern — treat as non-matching
245                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                    // "outside" means NOT within [start, end]
273                    let within = if start <= end {
274                        current_minutes >= start && current_minutes < end
275                    } else {
276                        // Wraps midnight (e.g. "22:00-06:00")
277                        current_minutes >= start || current_minutes < end
278                    };
279                    if within {
280                        return false; // Inside the range, so "time_outside" is false
281                    }
282                } else {
283                    // Malformed range string — fail-closed: require approval
284                    return false;
285                }
286            } else {
287                // No current_time provided — fail-closed: uncertain => require approval
288                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
302/// Parse "HH:MM-HH:MM" into (start_minutes, end_minutes).
303fn 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
326// ── Policy evaluation ──
327
328impl ApprovalPolicy {
329    /// Evaluate whether a pipeline step needs approval.
330    pub fn evaluate(&self, step: &str, ctx: &EvalContext<'_>) -> ApprovalDecision {
331        // 1. Check step-level policy
332        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                    // Fall through to rule evaluation
352                }
353            }
354        }
355
356        // 2. Iterate rules (first match wins)
357        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        // 3. No match — fail-safe
378        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
387// ── Config loading ──
388
389/// Load approval policy from `.edda/approval-policy.yaml`.
390/// Falls back to `default_policy()` if the file does not exist.
391pub 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
401/// Generate a template approval-policy.yaml file.
402pub 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    // ── Default policy tests ──
477
478    #[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        // Medium risk, 10 files (not > 20), tests pass, no off-limits
557        // Doesn't match high-risk, large-change, off-limits, or safe-auto (risk != low)
558        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    // ── Condition matching tests ──
569
570    #[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        // Multiple conditions: risk_eq: Low AND files_changed_lte: 5
705        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        // Both true
713        let bundle_ok = sample_bundle(RiskLevel::Low, 3, 0);
714        assert!(cond.matches(&make_ctx(&bundle_ok, &phase, false, 0)));
715
716        // Risk wrong
717        let bundle_risk = sample_bundle(RiskLevel::Medium, 3, 0);
718        assert!(!cond.matches(&make_ctx(&bundle_risk, &phase, false, 0)));
719
720        // Files too many
721        let bundle_files = sample_bundle(RiskLevel::Low, 10, 0);
722        assert!(!cond.matches(&make_ctx(&bundle_files, &phase, false, 0)));
723    }
724
725    // ── Step policy tests ──
726
727    #[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        // Low risk, 2 files, tests pass -> safe-auto
768        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(), // matches everything
780                    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    // ── Serialization tests ──
846
847    #[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    // ── Config loading tests ──
887
888    #[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    // ── Time parsing tests ──
927
928    #[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    // ── Fail-closed time_outside tests ──
949
950    #[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        // current_time is None via make_ctx
959        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}