Skip to main content

batty_cli/team/
auto_merge.rs

1//! Auto-merge policy engine with confidence scoring.
2//!
3//! Evaluates completed task diffs and decides whether to auto-merge
4//! or route to manual review based on configurable thresholds.
5
6use std::collections::{HashMap, HashSet};
7use std::path::Path;
8use std::process::Command;
9
10use anyhow::{Context, Result};
11use serde::{Deserialize, Serialize};
12
13use super::config::AutoMergePolicy;
14
15const OVERRIDES_FILE: &str = ".batty/auto_merge_overrides.json";
16
17/// Load per-task auto-merge overrides from disk.
18pub fn load_overrides(project_root: &Path) -> HashMap<u32, bool> {
19    let path = project_root.join(OVERRIDES_FILE);
20    let Ok(content) = std::fs::read_to_string(&path) else {
21        return HashMap::new();
22    };
23    serde_json::from_str(&content).unwrap_or_default()
24}
25
26/// Save a per-task auto-merge override to disk.
27pub fn save_override(project_root: &Path, task_id: u32, enabled: bool) -> Result<()> {
28    let path = project_root.join(OVERRIDES_FILE);
29    let mut overrides = load_overrides(project_root);
30    overrides.insert(task_id, enabled);
31    let content = serde_json::to_string_pretty(&overrides)
32        .context("failed to serialize auto-merge overrides")?;
33    if let Some(parent) = path.parent() {
34        std::fs::create_dir_all(parent).ok();
35    }
36    std::fs::write(&path, content).context("failed to write auto-merge overrides file")?;
37    Ok(())
38}
39
40/// Summary of a git diff between two refs.
41#[derive(Debug, Clone)]
42pub struct DiffSummary {
43    pub files_changed: usize,
44    pub lines_added: usize,
45    pub lines_removed: usize,
46    pub modules_touched: HashSet<String>,
47    pub sensitive_files: Vec<String>,
48    pub has_unsafe: bool,
49    pub has_conflicts: bool,
50    /// Number of renamed files (pure renames are lower risk).
51    pub rename_count: usize,
52    /// Whether the diff touches migration-like files (schema changes, etc.).
53    pub has_migrations: bool,
54    /// Whether the diff touches config files (YAML, TOML, JSON config).
55    pub has_config_changes: bool,
56}
57
58impl DiffSummary {
59    pub fn total_lines(&self) -> usize {
60        self.lines_added + self.lines_removed
61    }
62}
63
64/// Decision returned by the policy engine.
65#[derive(Debug, Clone, PartialEq)]
66pub enum AutoMergeDecision {
67    AutoMerge {
68        confidence: f64,
69    },
70    ManualReview {
71        confidence: f64,
72        reasons: Vec<String>,
73    },
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum AutoMergeDecisionKind {
79    Accepted,
80    ManualReview,
81}
82
83impl AutoMergeDecisionKind {
84    pub fn action_type(self) -> &'static str {
85        match self {
86            Self::Accepted => "accepted",
87            Self::ManualReview => "manual_review",
88        }
89    }
90}
91
92#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
93pub struct AutoMergeDecisionRecord {
94    pub decision: AutoMergeDecisionKind,
95    pub confidence: f64,
96    pub reasons: Vec<String>,
97    pub files_changed: usize,
98    pub lines_changed: usize,
99    pub modules_touched: usize,
100    pub has_migrations: bool,
101    pub has_config_changes: bool,
102    pub has_unsafe: bool,
103    pub has_conflicts: bool,
104    pub rename_count: usize,
105    pub tests_passed: bool,
106    pub override_forced: Option<bool>,
107    pub diff_available: bool,
108}
109
110impl AutoMergeDecisionRecord {
111    fn from_summary(
112        summary: Option<&DiffSummary>,
113        confidence: f64,
114        decision: AutoMergeDecisionKind,
115        reasons: Vec<String>,
116        tests_passed: bool,
117        override_forced: Option<bool>,
118    ) -> Self {
119        Self {
120            decision,
121            confidence,
122            reasons,
123            files_changed: summary.map_or(0, |value| value.files_changed),
124            lines_changed: summary.map_or(0, DiffSummary::total_lines),
125            modules_touched: summary.map_or(0, |value| value.modules_touched.len()),
126            has_migrations: summary.is_some_and(|value| value.has_migrations),
127            has_config_changes: summary.is_some_and(|value| value.has_config_changes),
128            has_unsafe: summary.is_some_and(|value| value.has_unsafe),
129            has_conflicts: summary.is_some_and(|value| value.has_conflicts),
130            rename_count: summary.map_or(0, |value| value.rename_count),
131            tests_passed,
132            override_forced,
133            diff_available: summary.is_some(),
134        }
135    }
136}
137
138/// Analyze the diff between `base` and `branch` in the given repo.
139pub fn analyze_diff(repo: &Path, base: &str, branch: &str) -> Result<DiffSummary> {
140    // Get --stat for file count and per-file changes
141    let stat_output = Command::new("git")
142        .args(["diff", "--numstat", &format!("{}...{}", base, branch)])
143        .current_dir(repo)
144        .output()
145        .context("failed to run git diff --numstat")?;
146
147    let stat_str = String::from_utf8_lossy(&stat_output.stdout);
148
149    let mut files_changed = 0usize;
150    let mut lines_added = 0usize;
151    let mut lines_removed = 0usize;
152    let mut modules_touched = HashSet::new();
153    let mut changed_paths = Vec::new();
154
155    for line in stat_str.lines() {
156        let parts: Vec<&str> = line.split('\t').collect();
157        if parts.len() < 3 {
158            continue;
159        }
160        files_changed += 1;
161        if let Ok(added) = parts[0].parse::<usize>() {
162            lines_added += added;
163        }
164        if let Ok(removed) = parts[1].parse::<usize>() {
165            lines_removed += removed;
166        }
167        let path = parts[2];
168        changed_paths.push(path.to_string());
169
170        // Extract top-level module (first component under src/)
171        if let Some(rest) = path.strip_prefix("src/") {
172            if let Some(module) = rest.split('/').next() {
173                modules_touched.insert(module.to_string());
174            }
175        }
176    }
177
178    // Get full diff to check for unsafe blocks
179    let diff_output = Command::new("git")
180        .args(["diff", &format!("{}...{}", base, branch)])
181        .current_dir(repo)
182        .output()
183        .context("failed to run git diff")?;
184
185    let diff_str = String::from_utf8_lossy(&diff_output.stdout);
186    let has_unsafe = diff_str.lines().any(|line| {
187        line.starts_with('+') && (line.contains("unsafe {") || line.contains("unsafe fn"))
188    });
189
190    // Count renames (pure renames are lower risk than logic changes)
191    let rename_output = Command::new("git")
192        .args([
193            "diff",
194            "--diff-filter=R",
195            "--name-only",
196            &format!("{}...{}", base, branch),
197        ])
198        .current_dir(repo)
199        .output()
200        .context("failed to run git diff --diff-filter=R")?;
201    let rename_count = String::from_utf8_lossy(&rename_output.stdout)
202        .lines()
203        .filter(|l| !l.is_empty())
204        .count();
205
206    // Detect migration and config file changes
207    let has_migrations = changed_paths.iter().any(|p| is_migration_file(p));
208    let has_config_changes = changed_paths.iter().any(|p| is_config_file(p));
209
210    // Check if branch can merge cleanly into base
211    let has_conflicts = check_has_conflicts(repo, base, branch);
212
213    Ok(DiffSummary {
214        files_changed,
215        lines_added,
216        lines_removed,
217        modules_touched,
218        sensitive_files: changed_paths, // filtered by caller via policy
219        has_unsafe,
220        has_conflicts,
221        rename_count,
222        has_migrations,
223        has_config_changes,
224    })
225}
226
227/// Check whether merging `branch` into `base` would produce conflicts.
228fn check_has_conflicts(repo: &Path, base: &str, branch: &str) -> bool {
229    let merge_base = Command::new("git")
230        .args(["merge-base", base, branch])
231        .current_dir(repo)
232        .output();
233    let merge_base_sha = match merge_base {
234        Ok(output) if output.status.success() => {
235            String::from_utf8_lossy(&output.stdout).trim().to_string()
236        }
237        _ => return true, // Can't find merge base — treat as conflicting
238    };
239
240    let result = Command::new("git")
241        .args(["merge-tree", &merge_base_sha, base, branch])
242        .current_dir(repo)
243        .output();
244    match result {
245        Ok(output) => {
246            let stdout = String::from_utf8_lossy(&output.stdout);
247            stdout.contains("<<<<<<") || stdout.contains("changed in both")
248        }
249        Err(_) => true, // merge-tree failed — assume conflicts
250    }
251}
252
253/// Returns true if the path looks like a migration file.
254fn is_migration_file(path: &str) -> bool {
255    let lower = path.to_lowercase();
256    lower.contains("migration")
257        || lower.contains("migrate")
258        || lower.contains("/db/")
259        || lower.contains("schema")
260        || lower.ends_with(".sql")
261}
262
263/// Returns true if the path looks like a config file (not source code).
264fn is_config_file(path: &str) -> bool {
265    let lower = path.to_lowercase();
266    lower.ends_with(".yaml")
267        || lower.ends_with(".yml")
268        || lower.ends_with(".toml")
269        || lower.ends_with(".json")
270        || lower.ends_with(".env")
271        || lower.ends_with(".env.example")
272}
273
274/// Compute merge confidence score (0.0-1.0) from a diff summary and policy.
275pub fn compute_merge_confidence(summary: &DiffSummary, policy: &AutoMergePolicy) -> f64 {
276    let mut confidence = 1.0f64;
277
278    // Subtract 0.1 per file over 3
279    if summary.files_changed > 3 {
280        confidence -= 0.1 * (summary.files_changed - 3) as f64;
281    }
282
283    // Subtract 0.2 per module touched over 1
284    if summary.modules_touched.len() > 1 {
285        confidence -= 0.2 * (summary.modules_touched.len() - 1) as f64;
286    }
287
288    // Subtract 0.3 if any sensitive path touched
289    let touches_sensitive = summary
290        .sensitive_files
291        .iter()
292        .any(|f| policy.sensitive_paths.iter().any(|s| f.contains(s)));
293    if touches_sensitive {
294        confidence -= 0.3;
295    }
296
297    // Subtract 0.1 per 50 lines over 100
298    let total_lines = summary.total_lines();
299    if total_lines > 100 {
300        let excess = total_lines - 100;
301        confidence -= 0.1 * (excess / 50) as f64;
302    }
303
304    // Subtract 0.4 if unsafe blocks or FFI
305    if summary.has_unsafe {
306        confidence -= 0.4;
307    }
308
309    // Subtract 0.5 if conflicts detected with main
310    if summary.has_conflicts {
311        confidence -= 0.5;
312    }
313
314    // Subtract 0.3 for migration/schema changes (high risk)
315    if summary.has_migrations {
316        confidence -= 0.3;
317    }
318
319    // Subtract 0.15 for config file changes
320    if summary.has_config_changes {
321        confidence -= 0.15;
322    }
323
324    // Boost confidence when most changes are renames (low-risk)
325    if summary.rename_count > 0 && summary.files_changed > 0 {
326        let rename_ratio = summary.rename_count as f64 / summary.files_changed as f64;
327        confidence += 0.1 * rename_ratio;
328    }
329
330    // Floor at 0.0
331    confidence.max(0.0)
332}
333
334pub fn score_auto_merge_candidate(summary: &DiffSummary, policy: &AutoMergePolicy) -> f64 {
335    compute_merge_confidence(summary, policy)
336}
337
338pub fn evaluate_auto_merge_candidate(
339    summary: &DiffSummary,
340    policy: &AutoMergePolicy,
341    tests_passed: bool,
342) -> AutoMergeDecisionRecord {
343    if !policy.enabled {
344        return AutoMergeDecisionRecord::from_summary(
345            Some(summary),
346            score_auto_merge_candidate(summary, policy),
347            AutoMergeDecisionKind::ManualReview,
348            vec!["auto-merge disabled by policy".to_string()],
349            tests_passed,
350            None,
351        );
352    }
353
354    let confidence = score_auto_merge_candidate(summary, policy);
355    let mut reasons = Vec::new();
356
357    if policy.require_tests_pass && !tests_passed {
358        reasons.push("tests did not pass".to_string());
359    }
360
361    if summary.has_conflicts {
362        reasons.push("conflicts with main".to_string());
363    }
364
365    if confidence < policy.confidence_threshold {
366        reasons.push(format!(
367            "confidence {:.2} below threshold {:.2}",
368            confidence, policy.confidence_threshold
369        ));
370    }
371
372    if summary.files_changed > policy.max_files_changed {
373        reasons.push(format!(
374            "{} files changed (max {})",
375            summary.files_changed, policy.max_files_changed
376        ));
377    }
378
379    let total_lines = summary.total_lines();
380    if total_lines > policy.max_diff_lines {
381        reasons.push(format!(
382            "{} diff lines (max {})",
383            total_lines, policy.max_diff_lines
384        ));
385    }
386
387    if summary.modules_touched.len() > policy.max_modules_touched {
388        reasons.push(format!(
389            "{} modules touched (max {})",
390            summary.modules_touched.len(),
391            policy.max_modules_touched
392        ));
393    }
394
395    let touches_sensitive = summary
396        .sensitive_files
397        .iter()
398        .any(|f| policy.sensitive_paths.iter().any(|s| f.contains(s)));
399    if touches_sensitive {
400        reasons.push("touches sensitive paths".to_string());
401    }
402
403    if summary.has_unsafe {
404        reasons.push("contains unsafe blocks".to_string());
405    }
406
407    if summary.has_migrations {
408        reasons.push("contains migration/schema changes".to_string());
409    }
410
411    if summary.has_config_changes {
412        reasons.push("contains config file changes".to_string());
413    }
414
415    if reasons.is_empty() {
416        AutoMergeDecisionRecord::from_summary(
417            Some(summary),
418            confidence,
419            AutoMergeDecisionKind::Accepted,
420            vec![format!(
421                "confidence {:.2} meets threshold {:.2}; diff stays within file/module/line policy limits",
422                confidence, policy.confidence_threshold
423            )],
424            tests_passed,
425            None,
426        )
427    } else {
428        AutoMergeDecisionRecord::from_summary(
429            Some(summary),
430            confidence,
431            AutoMergeDecisionKind::ManualReview,
432            reasons,
433            tests_passed,
434            None,
435        )
436    }
437}
438
439pub fn forced_auto_merge_decision(
440    summary: Option<&DiffSummary>,
441    policy: &AutoMergePolicy,
442    tests_passed: bool,
443) -> AutoMergeDecisionRecord {
444    AutoMergeDecisionRecord::from_summary(
445        summary,
446        summary.map_or(0.0, |value| score_auto_merge_candidate(value, policy)),
447        AutoMergeDecisionKind::Accepted,
448        vec!["auto-merge forced by per-task override".to_string()],
449        tests_passed,
450        Some(true),
451    )
452}
453
454pub fn forced_manual_review_decision(
455    summary: Option<&DiffSummary>,
456    policy: &AutoMergePolicy,
457    tests_passed: bool,
458) -> AutoMergeDecisionRecord {
459    AutoMergeDecisionRecord::from_summary(
460        summary,
461        summary.map_or(0.0, |value| score_auto_merge_candidate(value, policy)),
462        AutoMergeDecisionKind::ManualReview,
463        vec!["auto-merge disabled by per-task override".to_string()],
464        tests_passed,
465        Some(false),
466    )
467}
468
469pub fn explain_auto_merge_decision(record: &AutoMergeDecisionRecord) -> String {
470    let decision = match record.decision {
471        AutoMergeDecisionKind::Accepted => "accepted for auto-merge",
472        AutoMergeDecisionKind::ManualReview => "routed to manual review",
473    };
474    let override_text = match record.override_forced {
475        Some(true) => " (forced by override)",
476        Some(false) => " (disabled by override)",
477        None => "",
478    };
479    let diff_shape = if record.diff_available {
480        format!(
481            "{} files, {} lines, {} modules",
482            record.files_changed, record.lines_changed, record.modules_touched
483        )
484    } else {
485        "diff summary unavailable".to_string()
486    };
487    format!(
488        "{decision}{override_text}: confidence {:.2}; {diff_shape}; reasons: {}",
489        record.confidence,
490        record.reasons.join("; ")
491    )
492}
493
494/// Decide whether to auto-merge or route to manual review.
495///
496/// `tests_passed` indicates whether the task's test suite passed. When
497/// `policy.require_tests_pass` is true and tests haven't passed, the
498/// decision is always manual review regardless of other criteria.
499pub fn should_auto_merge(
500    summary: &DiffSummary,
501    policy: &AutoMergePolicy,
502    tests_passed: bool,
503) -> AutoMergeDecision {
504    let record = evaluate_auto_merge_candidate(summary, policy, tests_passed);
505    match record.decision {
506        AutoMergeDecisionKind::Accepted => AutoMergeDecision::AutoMerge {
507            confidence: record.confidence,
508        },
509        AutoMergeDecisionKind::ManualReview => AutoMergeDecision::ManualReview {
510            confidence: record.confidence,
511            reasons: record.reasons,
512        },
513    }
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    fn default_policy() -> AutoMergePolicy {
521        AutoMergePolicy::default()
522    }
523
524    fn enabled_policy() -> AutoMergePolicy {
525        AutoMergePolicy {
526            enabled: true,
527            ..AutoMergePolicy::default()
528        }
529    }
530
531    fn make_summary(
532        files: usize,
533        added: usize,
534        removed: usize,
535        modules: Vec<&str>,
536        sensitive: Vec<&str>,
537        has_unsafe: bool,
538    ) -> DiffSummary {
539        DiffSummary {
540            files_changed: files,
541            lines_added: added,
542            lines_removed: removed,
543            modules_touched: modules.into_iter().map(String::from).collect(),
544            sensitive_files: sensitive.into_iter().map(String::from).collect(),
545            has_unsafe,
546            has_conflicts: false,
547            rename_count: 0,
548            has_migrations: false,
549            has_config_changes: false,
550        }
551    }
552
553    #[test]
554    fn small_clean_diff_auto_merges() {
555        let summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
556        let policy = enabled_policy();
557        let decision = should_auto_merge(&summary, &policy, true);
558        match decision {
559            AutoMergeDecision::AutoMerge { confidence } => {
560                assert!(
561                    confidence >= 0.8,
562                    "confidence should be >= 0.8, got {}",
563                    confidence
564                );
565            }
566            other => panic!("expected AutoMerge, got {:?}", other),
567        }
568    }
569
570    #[test]
571    fn large_diff_routes_to_review() {
572        // With relaxed thresholds (max_diff_lines=2000), need a truly large diff
573        let summary = make_summary(3, 1500, 600, vec!["team"], vec![], false);
574        let policy = enabled_policy();
575        let decision = should_auto_merge(&summary, &policy, true);
576        match decision {
577            AutoMergeDecision::ManualReview { reasons, .. } => {
578                assert!(
579                    reasons.iter().any(|r| r.contains("diff lines")),
580                    "should mention diff lines: {:?}",
581                    reasons
582                );
583            }
584            other => panic!("expected ManualReview, got {:?}", other),
585        }
586    }
587
588    #[test]
589    fn sensitive_file_routes_to_review() {
590        let summary = make_summary(2, 20, 10, vec!["team"], vec!["Cargo.toml"], false);
591        let policy = enabled_policy();
592        let decision = should_auto_merge(&summary, &policy, true);
593        match decision {
594            AutoMergeDecision::ManualReview { reasons, .. } => {
595                assert!(
596                    reasons.iter().any(|r| r.contains("sensitive")),
597                    "should mention sensitive paths: {:?}",
598                    reasons
599                );
600            }
601            other => panic!("expected ManualReview, got {:?}", other),
602        }
603    }
604
605    #[test]
606    fn multi_module_reduces_confidence() {
607        let summary = make_summary(
608            4,
609            40,
610            10,
611            vec!["team", "cli", "tmux", "agent"],
612            vec![],
613            false,
614        );
615        let policy = enabled_policy();
616        let confidence = compute_merge_confidence(&summary, &policy);
617        // 1.0 - 0.1*(4-3) - 0.2*(4-1) = 1.0 - 0.1 - 0.6 = 0.3
618        // Confidence is reduced but still above the relaxed threshold (0.0)
619        assert!(
620            confidence < 0.5,
621            "multi-module diff should have reduced confidence: {}",
622            confidence,
623        );
624    }
625
626    #[test]
627    fn confidence_floor_at_zero() {
628        let summary = make_summary(
629            20,
630            2000,
631            1000,
632            vec!["team", "cli", "tmux", "agent", "config"],
633            vec!["Cargo.toml", ".env"],
634            true,
635        );
636        let policy = enabled_policy();
637        let confidence = compute_merge_confidence(&summary, &policy);
638        assert_eq!(confidence, 0.0, "confidence should be floored at 0.0");
639    }
640
641    #[test]
642    fn disabled_policy_always_manual() {
643        let summary = make_summary(1, 5, 2, vec!["team"], vec![], false);
644        let mut policy = default_policy();
645        policy.enabled = false;
646        let decision = should_auto_merge(&summary, &policy, true);
647        match decision {
648            AutoMergeDecision::ManualReview { reasons, .. } => {
649                assert!(
650                    reasons.iter().any(|r| r.contains("disabled")),
651                    "should mention disabled: {:?}",
652                    reasons
653                );
654            }
655            other => panic!("expected ManualReview, got {:?}", other),
656        }
657    }
658
659    #[test]
660    fn config_deserializes_with_defaults() {
661        let yaml = "{}";
662        let policy: AutoMergePolicy = serde_yaml::from_str(yaml).unwrap();
663        assert!(policy.enabled);
664        assert_eq!(policy.max_diff_lines, 2000);
665        assert_eq!(policy.max_files_changed, 30);
666        assert_eq!(policy.max_modules_touched, 10);
667        assert_eq!(policy.confidence_threshold, 0.0);
668        assert!(policy.require_tests_pass);
669        assert!(policy.post_merge_verify);
670        assert!(policy.sensitive_paths.contains(&"Cargo.toml".to_string()));
671    }
672
673    #[test]
674    fn unsafe_blocks_reduce_confidence() {
675        let summary = make_summary(2, 30, 20, vec!["team"], vec![], true);
676        let policy = enabled_policy();
677        let confidence = compute_merge_confidence(&summary, &policy);
678        // 1.0 - 0.4 = 0.6
679        assert!(
680            (confidence - 0.6).abs() < 0.001,
681            "confidence should be 0.6, got {}",
682            confidence
683        );
684    }
685
686    #[test]
687    fn tests_not_passed_routes_to_review() {
688        let summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
689        let policy = enabled_policy();
690        let decision = should_auto_merge(&summary, &policy, false);
691        match decision {
692            AutoMergeDecision::ManualReview { reasons, .. } => {
693                assert!(
694                    reasons.iter().any(|r| r.contains("tests did not pass")),
695                    "should mention tests: {:?}",
696                    reasons
697                );
698            }
699            other => panic!("expected ManualReview, got {:?}", other),
700        }
701    }
702
703    #[test]
704    fn tests_not_required_allows_merge_without_passing() {
705        let summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
706        let mut policy = enabled_policy();
707        policy.require_tests_pass = false;
708        let decision = should_auto_merge(&summary, &policy, false);
709        match decision {
710            AutoMergeDecision::AutoMerge { .. } => {}
711            other => panic!(
712                "expected AutoMerge when tests not required, got {:?}",
713                other
714            ),
715        }
716    }
717
718    #[test]
719    fn conflicts_reduce_confidence_and_route_to_review() {
720        let mut summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
721        summary.has_conflicts = true;
722        let policy = enabled_policy();
723        let confidence = compute_merge_confidence(&summary, &policy);
724        // 1.0 - 0.5 = 0.5
725        assert!(
726            (confidence - 0.5).abs() < 0.001,
727            "confidence should be 0.5, got {}",
728            confidence
729        );
730        let decision = should_auto_merge(&summary, &policy, true);
731        match decision {
732            AutoMergeDecision::ManualReview { reasons, .. } => {
733                assert!(
734                    reasons.iter().any(|r| r.contains("conflicts")),
735                    "should mention conflicts: {:?}",
736                    reasons
737                );
738            }
739            other => panic!("expected ManualReview, got {:?}", other),
740        }
741    }
742
743    #[test]
744    fn migrations_reduce_confidence() {
745        let mut summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
746        summary.has_migrations = true;
747        let policy = enabled_policy();
748        let confidence = compute_merge_confidence(&summary, &policy);
749        // 1.0 - 0.3 = 0.7
750        assert!(
751            (confidence - 0.7).abs() < 0.001,
752            "confidence should be 0.7, got {}",
753            confidence
754        );
755    }
756
757    #[test]
758    fn migrations_route_to_review() {
759        let mut summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
760        summary.has_migrations = true;
761        let policy = enabled_policy();
762        let decision = should_auto_merge(&summary, &policy, true);
763        match decision {
764            AutoMergeDecision::ManualReview { reasons, .. } => {
765                assert!(
766                    reasons.iter().any(|r| r.contains("migration")),
767                    "should mention migration: {:?}",
768                    reasons
769                );
770            }
771            other => panic!("expected ManualReview, got {:?}", other),
772        }
773    }
774
775    #[test]
776    fn config_changes_reduce_confidence() {
777        let mut summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
778        summary.has_config_changes = true;
779        let policy = enabled_policy();
780        let confidence = compute_merge_confidence(&summary, &policy);
781        // 1.0 - 0.15 = 0.85
782        assert!(
783            (confidence - 0.85).abs() < 0.001,
784            "confidence should be 0.85, got {}",
785            confidence
786        );
787    }
788
789    #[test]
790    fn config_changes_route_to_review() {
791        let mut summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
792        summary.has_config_changes = true;
793        let policy = enabled_policy();
794        let decision = should_auto_merge(&summary, &policy, true);
795        match decision {
796            AutoMergeDecision::ManualReview { reasons, .. } => {
797                assert!(
798                    reasons.iter().any(|r| r.contains("config")),
799                    "should mention config: {:?}",
800                    reasons
801                );
802            }
803            other => panic!("expected ManualReview, got {:?}", other),
804        }
805    }
806
807    #[test]
808    fn renames_boost_confidence() {
809        let mut summary = make_summary(4, 10, 10, vec!["team"], vec![], false);
810        // 4 files changed, 3 of them are renames
811        summary.rename_count = 3;
812        let policy = enabled_policy();
813        let confidence_with_renames = compute_merge_confidence(&summary, &policy);
814
815        let summary_no_renames = make_summary(4, 10, 10, vec!["team"], vec![], false);
816        let confidence_without = compute_merge_confidence(&summary_no_renames, &policy);
817
818        assert!(
819            confidence_with_renames > confidence_without,
820            "renames should boost confidence: with={}, without={}",
821            confidence_with_renames,
822            confidence_without
823        );
824    }
825
826    #[test]
827    fn all_renames_gives_full_boost() {
828        let mut summary = make_summary(4, 0, 0, vec!["team"], vec![], false);
829        summary.rename_count = 4;
830        let policy = enabled_policy();
831        let confidence = compute_merge_confidence(&summary, &policy);
832        // 1.0 - 0.1*(4-3) + 0.1*(4/4) = 1.0 - 0.1 + 0.1 = 1.0
833        assert!(
834            (confidence - 1.0).abs() < 0.001,
835            "all-rename diff should have full confidence: {}",
836            confidence
837        );
838    }
839
840    #[test]
841    fn heterogeneous_but_bounded_diff_still_auto_merges() {
842        let summary = make_summary(3, 45, 15, vec!["team", "metrics"], vec![], false);
843        let policy = enabled_policy();
844        let record = evaluate_auto_merge_candidate(&summary, &policy, true);
845        assert_eq!(record.decision, AutoMergeDecisionKind::Accepted);
846        assert!(
847            record.reasons[0].contains("meets threshold"),
848            "should contain acceptance reason: {:?}",
849            record.reasons
850        );
851    }
852
853    #[test]
854    fn forced_override_decision_is_explicit() {
855        let summary = make_summary(5, 80, 20, vec!["team", "metrics", "daemon"], vec![], false);
856        let policy = enabled_policy();
857        let record = forced_auto_merge_decision(Some(&summary), &policy, true);
858        assert_eq!(record.decision, AutoMergeDecisionKind::Accepted);
859        assert_eq!(record.override_forced, Some(true));
860        assert_eq!(
861            explain_auto_merge_decision(&record),
862            "accepted for auto-merge (forced by override): confidence 0.40; 5 files, 100 lines, 3 modules; reasons: auto-merge forced by per-task override"
863        );
864    }
865
866    #[test]
867    fn migration_file_detection() {
868        assert!(is_migration_file("db/migrate/001_add_users.sql"));
869        assert!(is_migration_file("src/migrations/v2.rs"));
870        assert!(is_migration_file("schema.sql"));
871        assert!(!is_migration_file("src/team/mod.rs"));
872    }
873
874    #[test]
875    fn config_file_detection() {
876        assert!(is_config_file("team.yaml"));
877        assert!(is_config_file("Cargo.toml"));
878        assert!(is_config_file("package.json"));
879        assert!(is_config_file(".env"));
880        assert!(!is_config_file("src/team/config.rs"));
881    }
882
883    #[test]
884    fn combined_risk_factors_accumulate() {
885        let mut summary = make_summary(
886            6,
887            200,
888            100,
889            vec!["team", "cli", "tmux"],
890            vec!["Cargo.toml"],
891            true,
892        );
893        summary.has_migrations = true;
894        summary.has_config_changes = true;
895        summary.has_conflicts = true;
896        let policy = enabled_policy();
897        let confidence = compute_merge_confidence(&summary, &policy);
898        assert_eq!(confidence, 0.0, "extreme risk diff should floor at 0.0");
899    }
900
901    #[test]
902    fn override_persistence_roundtrip() {
903        let tmp = tempfile::tempdir().unwrap();
904        let root = tmp.path();
905        std::fs::create_dir_all(root.join(".batty")).unwrap();
906
907        // No overrides file — returns empty
908        assert!(load_overrides(root).is_empty());
909
910        // Save override
911        save_override(root, 42, true).unwrap();
912        let overrides = load_overrides(root);
913        assert_eq!(overrides.get(&42), Some(&true));
914
915        // Save another override, first one persists
916        save_override(root, 99, false).unwrap();
917        let overrides = load_overrides(root);
918        assert_eq!(overrides.get(&42), Some(&true));
919        assert_eq!(overrides.get(&99), Some(&false));
920
921        // Overwrite existing
922        save_override(root, 42, false).unwrap();
923        let overrides = load_overrides(root);
924        assert_eq!(overrides.get(&42), Some(&false));
925    }
926
927    // --- Error path and recovery tests (Task #265) ---
928
929    #[test]
930    fn load_overrides_malformed_json_returns_empty() {
931        let tmp = tempfile::tempdir().unwrap();
932        let root = tmp.path();
933        let path = root.join(OVERRIDES_FILE);
934        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
935        std::fs::write(&path, "not valid json {{{}").unwrap();
936        assert!(load_overrides(root).is_empty());
937    }
938
939    #[test]
940    fn load_overrides_wrong_json_type_returns_empty() {
941        let tmp = tempfile::tempdir().unwrap();
942        let root = tmp.path();
943        let path = root.join(OVERRIDES_FILE);
944        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
945        // Valid JSON but wrong type (array instead of object)
946        std::fs::write(&path, "[1, 2, 3]").unwrap();
947        assert!(load_overrides(root).is_empty());
948    }
949
950    #[test]
951    fn save_override_creates_batty_dir() {
952        let tmp = tempfile::tempdir().unwrap();
953        let root = tmp.path();
954        // .batty dir doesn't exist yet
955        save_override(root, 1, true).unwrap();
956        assert!(root.join(OVERRIDES_FILE).exists());
957    }
958
959    #[test]
960    fn save_override_to_readonly_dir_returns_error() {
961        #[cfg(unix)]
962        {
963            use std::os::unix::fs::PermissionsExt;
964            let tmp = tempfile::tempdir().unwrap();
965            let root = tmp.path();
966            let batty_dir = root.join(".batty");
967            std::fs::create_dir(&batty_dir).unwrap();
968            std::fs::set_permissions(&batty_dir, std::fs::Permissions::from_mode(0o444)).unwrap();
969
970            let result = save_override(root, 1, true);
971            assert!(result.is_err());
972
973            // Restore for cleanup
974            std::fs::set_permissions(&batty_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
975        }
976    }
977
978    #[test]
979    fn analyze_diff_on_non_git_dir_returns_empty_summary() {
980        let tmp = tempfile::tempdir().unwrap();
981        // analyze_diff doesn't fail — git commands produce empty output on non-git dirs
982        // This verifies graceful degradation rather than hard failure
983        let result = analyze_diff(tmp.path(), "main", "feature");
984        if let Ok(summary) = result {
985            assert_eq!(summary.files_changed, 0);
986            assert_eq!(summary.total_lines(), 0);
987        }
988        // If it errors, that's also acceptable graceful behavior
989    }
990}