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