1use 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
17pub 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
26pub 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#[derive(Debug, Clone)]
42pub struct DiffSummary {
43 pub files_changed: usize,
44 pub lines_added: usize,
45 pub lines_removed: usize,
46 pub generated_lines_added: usize,
47 pub generated_lines_removed: usize,
48 pub modules_touched: HashSet<String>,
49 pub sensitive_files: Vec<String>,
50 pub has_unsafe: bool,
51 pub has_conflicts: bool,
52 pub rename_count: usize,
54 pub has_migrations: bool,
56 pub has_config_changes: bool,
58}
59
60impl DiffSummary {
61 pub fn total_lines(&self) -> usize {
62 self.lines_added + self.lines_removed
63 }
64
65 pub fn generated_data_lines(&self) -> usize {
66 self.generated_lines_added + self.generated_lines_removed
67 }
68
69 pub fn review_lines(&self) -> usize {
70 self.total_lines()
71 .saturating_sub(self.generated_data_lines())
72 }
73}
74
75#[derive(Debug, Clone, PartialEq)]
77pub enum AutoMergeDecision {
78 AutoMerge {
79 confidence: f64,
80 },
81 ManualReview {
82 confidence: f64,
83 reasons: Vec<String>,
84 },
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum AutoMergeDecisionKind {
90 Accepted,
91 ManualReview,
92}
93
94impl AutoMergeDecisionKind {
95 pub fn action_type(self) -> &'static str {
96 match self {
97 Self::Accepted => "accepted",
98 Self::ManualReview => "manual_review",
99 }
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
104pub struct AutoMergeDecisionRecord {
105 pub decision: AutoMergeDecisionKind,
106 pub confidence: f64,
107 pub reasons: Vec<String>,
108 pub files_changed: usize,
109 pub lines_changed: usize,
110 pub modules_touched: usize,
111 pub has_migrations: bool,
112 pub has_config_changes: bool,
113 pub has_unsafe: bool,
114 pub has_conflicts: bool,
115 pub rename_count: usize,
116 pub tests_passed: bool,
117 pub override_forced: Option<bool>,
118 pub diff_available: bool,
119}
120
121impl AutoMergeDecisionRecord {
122 fn from_summary(
123 summary: Option<&DiffSummary>,
124 confidence: f64,
125 decision: AutoMergeDecisionKind,
126 reasons: Vec<String>,
127 tests_passed: bool,
128 override_forced: Option<bool>,
129 ) -> Self {
130 Self {
131 decision,
132 confidence,
133 reasons,
134 files_changed: summary.map_or(0, |value| value.files_changed),
135 lines_changed: summary.map_or(0, DiffSummary::total_lines),
136 modules_touched: summary.map_or(0, |value| value.modules_touched.len()),
137 has_migrations: summary.is_some_and(|value| value.has_migrations),
138 has_config_changes: summary.is_some_and(|value| value.has_config_changes),
139 has_unsafe: summary.is_some_and(|value| value.has_unsafe),
140 has_conflicts: summary.is_some_and(|value| value.has_conflicts),
141 rename_count: summary.map_or(0, |value| value.rename_count),
142 tests_passed,
143 override_forced,
144 diff_available: summary.is_some(),
145 }
146 }
147}
148
149pub fn analyze_diff(repo: &Path, base: &str, branch: &str) -> Result<DiffSummary> {
151 let stat_output = Command::new("git")
153 .args(["diff", "--numstat", &format!("{}...{}", base, branch)])
154 .current_dir(repo)
155 .output()
156 .context("failed to run git diff --numstat")?;
157
158 let stat_str = String::from_utf8_lossy(&stat_output.stdout);
159
160 let mut files_changed = 0usize;
161 let mut lines_added = 0usize;
162 let mut lines_removed = 0usize;
163 let mut generated_lines_added = 0usize;
164 let mut generated_lines_removed = 0usize;
165 let mut modules_touched = HashSet::new();
166 let mut changed_paths = Vec::new();
167
168 for line in stat_str.lines() {
169 let parts: Vec<&str> = line.split('\t').collect();
170 if parts.len() < 3 {
171 continue;
172 }
173 files_changed += 1;
174 let added = parts[0].parse::<usize>().ok();
175 let removed = parts[1].parse::<usize>().ok();
176 if let Some(added) = added {
177 lines_added += added;
178 }
179 if let Some(removed) = removed {
180 lines_removed += removed;
181 }
182 let path = parts[2];
183 changed_paths.push(path.to_string());
184 if is_generated_data_file(path) {
185 if let Some(added) = added {
186 generated_lines_added += added;
187 }
188 if let Some(removed) = removed {
189 generated_lines_removed += removed;
190 }
191 }
192
193 if let Some(rest) = path.strip_prefix("src/") {
195 if let Some(module) = rest.split('/').next() {
196 modules_touched.insert(module.to_string());
197 }
198 }
199 }
200
201 let diff_output = Command::new("git")
203 .args(["diff", &format!("{}...{}", base, branch)])
204 .current_dir(repo)
205 .output()
206 .context("failed to run git diff")?;
207
208 let diff_str = String::from_utf8_lossy(&diff_output.stdout);
209 let has_unsafe = diff_str.lines().any(|line| {
210 line.starts_with('+') && (line.contains("unsafe {") || line.contains("unsafe fn"))
211 });
212
213 let rename_output = Command::new("git")
215 .args([
216 "diff",
217 "--diff-filter=R",
218 "--name-only",
219 &format!("{}...{}", base, branch),
220 ])
221 .current_dir(repo)
222 .output()
223 .context("failed to run git diff --diff-filter=R")?;
224 let rename_count = String::from_utf8_lossy(&rename_output.stdout)
225 .lines()
226 .filter(|l| !l.is_empty())
227 .count();
228
229 let has_migrations = changed_paths.iter().any(|p| is_migration_file(p));
231 let has_config_changes = changed_paths.iter().any(|p| is_config_file(p));
232
233 let has_conflicts = check_has_conflicts(repo, base, branch);
235
236 Ok(DiffSummary {
237 files_changed,
238 lines_added,
239 lines_removed,
240 generated_lines_added,
241 generated_lines_removed,
242 modules_touched,
243 sensitive_files: changed_paths, has_unsafe,
245 has_conflicts,
246 rename_count,
247 has_migrations,
248 has_config_changes,
249 })
250}
251
252fn check_has_conflicts(repo: &Path, base: &str, branch: &str) -> bool {
254 let merge_base = Command::new("git")
255 .args(["merge-base", base, branch])
256 .current_dir(repo)
257 .output();
258 let merge_base_sha = match merge_base {
259 Ok(output) if output.status.success() => {
260 String::from_utf8_lossy(&output.stdout).trim().to_string()
261 }
262 _ => return true, };
264
265 let result = Command::new("git")
266 .args(["merge-tree", &merge_base_sha, base, branch])
267 .current_dir(repo)
268 .output();
269 match result {
270 Ok(output) => {
271 let stdout = String::from_utf8_lossy(&output.stdout);
272 stdout.contains("<<<<<<") || stdout.contains("changed in both")
273 }
274 Err(_) => true, }
276}
277
278fn is_migration_file(path: &str) -> bool {
280 let lower = path.to_lowercase();
281 lower.contains("migration")
282 || lower.contains("migrate")
283 || lower.contains("/db/")
284 || lower.contains("schema")
285 || lower.ends_with(".sql")
286}
287
288fn is_generated_data_file(path: &str) -> bool {
289 let lower = path.to_lowercase();
290 (lower.contains("generated/") || lower.contains("reference/") || lower.contains("fixtures/"))
291 && !lower.starts_with("src/")
292}
293
294fn is_config_file(path: &str) -> bool {
299 let lower = path.to_lowercase();
300 let has_config_ext = lower.ends_with(".yaml")
301 || lower.ends_with(".yml")
302 || lower.ends_with(".toml")
303 || lower.ends_with(".json")
304 || lower.ends_with(".env")
305 || lower.ends_with(".env.example");
306 if !has_config_ext {
307 return false;
308 }
309 let is_generated = lower.contains("generated/")
311 || lower.contains("reference/")
312 || lower.contains("fixtures/")
313 || lower.contains("tests/")
314 || lower.ends_with(".lock")
315 || lower.ends_with("lock.json");
316 has_config_ext && !is_generated
317}
318
319pub fn compute_merge_confidence(summary: &DiffSummary, policy: &AutoMergePolicy) -> f64 {
321 let mut confidence = 1.0f64;
322
323 if summary.files_changed > 3 {
325 confidence -= 0.1 * (summary.files_changed - 3) as f64;
326 }
327
328 if summary.modules_touched.len() > 1 {
330 confidence -= 0.2 * (summary.modules_touched.len() - 1) as f64;
331 }
332
333 let touches_sensitive = summary
335 .sensitive_files
336 .iter()
337 .any(|f| policy.sensitive_paths.iter().any(|s| f.contains(s)));
338 if touches_sensitive {
339 confidence -= 0.3;
340 }
341
342 let total_lines = summary.total_lines();
344 if total_lines > 100 {
345 let excess = total_lines - 100;
346 confidence -= 0.1 * (excess / 50) as f64;
347 }
348
349 if summary.has_unsafe {
351 confidence -= 0.4;
352 }
353
354 if summary.has_conflicts {
356 confidence -= 0.5;
357 }
358
359 if summary.has_migrations {
361 confidence -= 0.3;
362 }
363
364 if summary.has_config_changes {
366 confidence -= 0.15;
367 }
368
369 if summary.rename_count > 0 && summary.files_changed > 0 {
371 let rename_ratio = summary.rename_count as f64 / summary.files_changed as f64;
372 confidence += 0.1 * rename_ratio;
373 }
374
375 confidence.max(0.0)
377}
378
379pub fn score_auto_merge_candidate(summary: &DiffSummary, policy: &AutoMergePolicy) -> f64 {
380 compute_merge_confidence(summary, policy)
381}
382
383pub fn evaluate_auto_merge_candidate(
384 summary: &DiffSummary,
385 policy: &AutoMergePolicy,
386 tests_passed: bool,
387) -> AutoMergeDecisionRecord {
388 if !policy.enabled {
389 return AutoMergeDecisionRecord::from_summary(
390 Some(summary),
391 score_auto_merge_candidate(summary, policy),
392 AutoMergeDecisionKind::ManualReview,
393 vec!["auto-merge disabled by policy".to_string()],
394 tests_passed,
395 None,
396 );
397 }
398
399 let confidence = score_auto_merge_candidate(summary, policy);
400 let mut reasons = Vec::new();
401
402 if policy.require_tests_pass && !tests_passed {
403 reasons.push("tests did not pass".to_string());
404 }
405
406 if summary.has_conflicts {
407 reasons.push("conflicts with main".to_string());
408 }
409
410 if confidence < policy.confidence_threshold {
411 reasons.push(format!(
412 "confidence {:.2} below threshold {:.2}",
413 confidence, policy.confidence_threshold
414 ));
415 }
416
417 if summary.files_changed > policy.max_files_changed {
418 reasons.push(format!(
419 "{} files changed (max {})",
420 summary.files_changed, policy.max_files_changed
421 ));
422 }
423
424 let review_lines = summary.review_lines();
425 if review_lines > policy.max_diff_lines {
426 reasons.push(format!(
427 "{} diff lines (max {})",
428 review_lines, policy.max_diff_lines
429 ));
430 }
431
432 if summary.modules_touched.len() > policy.max_modules_touched {
433 reasons.push(format!(
434 "{} modules touched (max {})",
435 summary.modules_touched.len(),
436 policy.max_modules_touched
437 ));
438 }
439
440 let touches_sensitive = summary
441 .sensitive_files
442 .iter()
443 .any(|f| policy.sensitive_paths.iter().any(|s| f.contains(s)));
444 if touches_sensitive {
445 reasons.push("touches sensitive paths".to_string());
446 }
447
448 if summary.has_unsafe {
449 reasons.push("contains unsafe blocks".to_string());
450 }
451
452 if summary.has_migrations {
453 reasons.push("contains migration/schema changes".to_string());
454 }
455
456 if reasons.is_empty() {
463 AutoMergeDecisionRecord::from_summary(
464 Some(summary),
465 confidence,
466 AutoMergeDecisionKind::Accepted,
467 vec![format!(
468 "confidence {:.2} meets threshold {:.2}; diff stays within file/module/line policy limits",
469 confidence, policy.confidence_threshold
470 )],
471 tests_passed,
472 None,
473 )
474 } else {
475 AutoMergeDecisionRecord::from_summary(
476 Some(summary),
477 confidence,
478 AutoMergeDecisionKind::ManualReview,
479 reasons,
480 tests_passed,
481 None,
482 )
483 }
484}
485
486pub fn forced_auto_merge_decision(
487 summary: Option<&DiffSummary>,
488 policy: &AutoMergePolicy,
489 tests_passed: bool,
490) -> AutoMergeDecisionRecord {
491 AutoMergeDecisionRecord::from_summary(
492 summary,
493 summary.map_or(0.0, |value| score_auto_merge_candidate(value, policy)),
494 AutoMergeDecisionKind::Accepted,
495 vec!["auto-merge forced by per-task override".to_string()],
496 tests_passed,
497 Some(true),
498 )
499}
500
501pub fn forced_manual_review_decision(
502 summary: Option<&DiffSummary>,
503 policy: &AutoMergePolicy,
504 tests_passed: bool,
505) -> AutoMergeDecisionRecord {
506 AutoMergeDecisionRecord::from_summary(
507 summary,
508 summary.map_or(0.0, |value| score_auto_merge_candidate(value, policy)),
509 AutoMergeDecisionKind::ManualReview,
510 vec!["auto-merge disabled by per-task override".to_string()],
511 tests_passed,
512 Some(false),
513 )
514}
515
516pub fn explain_auto_merge_decision(record: &AutoMergeDecisionRecord) -> String {
517 let decision = match record.decision {
518 AutoMergeDecisionKind::Accepted => "accepted for auto-merge",
519 AutoMergeDecisionKind::ManualReview => "routed to manual review",
520 };
521 let override_text = match record.override_forced {
522 Some(true) => " (forced by override)",
523 Some(false) => " (disabled by override)",
524 None => "",
525 };
526 let diff_shape = if record.diff_available {
527 format!(
528 "{} files, {} lines, {} modules",
529 record.files_changed, record.lines_changed, record.modules_touched
530 )
531 } else {
532 "diff summary unavailable".to_string()
533 };
534 format!(
535 "{decision}{override_text}: confidence {:.2}; {diff_shape}; reasons: {}",
536 record.confidence,
537 record.reasons.join("; ")
538 )
539}
540
541pub fn should_auto_merge(
547 summary: &DiffSummary,
548 policy: &AutoMergePolicy,
549 tests_passed: bool,
550) -> AutoMergeDecision {
551 let record = evaluate_auto_merge_candidate(summary, policy, tests_passed);
552 match record.decision {
553 AutoMergeDecisionKind::Accepted => AutoMergeDecision::AutoMerge {
554 confidence: record.confidence,
555 },
556 AutoMergeDecisionKind::ManualReview => AutoMergeDecision::ManualReview {
557 confidence: record.confidence,
558 reasons: record.reasons,
559 },
560 }
561}
562
563#[cfg(test)]
564mod tests {
565 use super::*;
566
567 fn default_policy() -> AutoMergePolicy {
568 AutoMergePolicy::default()
569 }
570
571 fn enabled_policy() -> AutoMergePolicy {
572 AutoMergePolicy {
573 enabled: true,
574 ..AutoMergePolicy::default()
575 }
576 }
577
578 fn make_summary(
579 files: usize,
580 added: usize,
581 removed: usize,
582 modules: Vec<&str>,
583 sensitive: Vec<&str>,
584 has_unsafe: bool,
585 ) -> DiffSummary {
586 DiffSummary {
587 files_changed: files,
588 lines_added: added,
589 lines_removed: removed,
590 generated_lines_added: 0,
591 generated_lines_removed: 0,
592 modules_touched: modules.into_iter().map(String::from).collect(),
593 sensitive_files: sensitive.into_iter().map(String::from).collect(),
594 has_unsafe,
595 has_conflicts: false,
596 rename_count: 0,
597 has_migrations: false,
598 has_config_changes: false,
599 }
600 }
601
602 #[test]
603 fn small_clean_diff_auto_merges() {
604 let summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
605 let policy = enabled_policy();
606 let decision = should_auto_merge(&summary, &policy, true);
607 match decision {
608 AutoMergeDecision::AutoMerge { confidence } => {
609 assert!(
610 confidence >= 0.8,
611 "confidence should be >= 0.8, got {}",
612 confidence
613 );
614 }
615 other => panic!("expected AutoMerge, got {:?}", other),
616 }
617 }
618
619 #[test]
620 fn large_diff_routes_to_review() {
621 let summary = make_summary(3, 1500, 600, vec!["team"], vec![], false);
623 let policy = enabled_policy();
624 let decision = should_auto_merge(&summary, &policy, true);
625 match decision {
626 AutoMergeDecision::ManualReview { reasons, .. } => {
627 assert!(
628 reasons.iter().any(|r| r.contains("diff lines")),
629 "should mention diff lines: {:?}",
630 reasons
631 );
632 }
633 other => panic!("expected ManualReview, got {:?}", other),
634 }
635 }
636
637 #[test]
638 fn sensitive_file_routes_to_review() {
639 let summary = make_summary(2, 20, 10, vec!["team"], vec!["Cargo.toml"], false);
640 let policy = enabled_policy();
641 let decision = should_auto_merge(&summary, &policy, true);
642 match decision {
643 AutoMergeDecision::ManualReview { reasons, .. } => {
644 assert!(
645 reasons.iter().any(|r| r.contains("sensitive")),
646 "should mention sensitive paths: {:?}",
647 reasons
648 );
649 }
650 other => panic!("expected ManualReview, got {:?}", other),
651 }
652 }
653
654 #[test]
655 fn multi_module_reduces_confidence() {
656 let summary = make_summary(
657 4,
658 40,
659 10,
660 vec!["team", "cli", "tmux", "agent"],
661 vec![],
662 false,
663 );
664 let policy = enabled_policy();
665 let confidence = compute_merge_confidence(&summary, &policy);
666 assert!(
669 confidence < 0.5,
670 "multi-module diff should have reduced confidence: {}",
671 confidence,
672 );
673 }
674
675 #[test]
676 fn confidence_floor_at_zero() {
677 let summary = make_summary(
678 20,
679 2000,
680 1000,
681 vec!["team", "cli", "tmux", "agent", "config"],
682 vec!["Cargo.toml", ".env"],
683 true,
684 );
685 let policy = enabled_policy();
686 let confidence = compute_merge_confidence(&summary, &policy);
687 assert_eq!(confidence, 0.0, "confidence should be floored at 0.0");
688 }
689
690 #[test]
691 fn disabled_policy_always_manual() {
692 let summary = make_summary(1, 5, 2, vec!["team"], vec![], false);
693 let mut policy = default_policy();
694 policy.enabled = false;
695 let decision = should_auto_merge(&summary, &policy, true);
696 match decision {
697 AutoMergeDecision::ManualReview { reasons, .. } => {
698 assert!(
699 reasons.iter().any(|r| r.contains("disabled")),
700 "should mention disabled: {:?}",
701 reasons
702 );
703 }
704 other => panic!("expected ManualReview, got {:?}", other),
705 }
706 }
707
708 #[test]
709 fn config_deserializes_with_defaults() {
710 let yaml = "{}";
711 let policy: AutoMergePolicy = serde_yaml::from_str(yaml).unwrap();
712 assert!(policy.enabled);
713 assert_eq!(policy.max_diff_lines, 2000);
714 assert_eq!(policy.max_files_changed, 30);
715 assert_eq!(policy.max_modules_touched, 10);
716 assert_eq!(policy.confidence_threshold, 0.0);
717 assert!(policy.require_tests_pass);
718 assert!(policy.post_merge_verify);
719 assert!(policy.sensitive_paths.contains(&"Cargo.toml".to_string()));
720 }
721
722 #[test]
723 fn unsafe_blocks_reduce_confidence() {
724 let summary = make_summary(2, 30, 20, vec!["team"], vec![], true);
725 let policy = enabled_policy();
726 let confidence = compute_merge_confidence(&summary, &policy);
727 assert!(
729 (confidence - 0.6).abs() < 0.001,
730 "confidence should be 0.6, got {}",
731 confidence
732 );
733 }
734
735 #[test]
736 fn tests_not_passed_routes_to_review() {
737 let summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
738 let policy = enabled_policy();
739 let decision = should_auto_merge(&summary, &policy, false);
740 match decision {
741 AutoMergeDecision::ManualReview { reasons, .. } => {
742 assert!(
743 reasons.iter().any(|r| r.contains("tests did not pass")),
744 "should mention tests: {:?}",
745 reasons
746 );
747 }
748 other => panic!("expected ManualReview, got {:?}", other),
749 }
750 }
751
752 #[test]
753 fn tests_not_required_allows_merge_without_passing() {
754 let summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
755 let mut policy = enabled_policy();
756 policy.require_tests_pass = false;
757 let decision = should_auto_merge(&summary, &policy, false);
758 match decision {
759 AutoMergeDecision::AutoMerge { .. } => {}
760 other => panic!(
761 "expected AutoMerge when tests not required, got {:?}",
762 other
763 ),
764 }
765 }
766
767 #[test]
768 fn conflicts_reduce_confidence_and_route_to_review() {
769 let mut summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
770 summary.has_conflicts = true;
771 let policy = enabled_policy();
772 let confidence = compute_merge_confidence(&summary, &policy);
773 assert!(
775 (confidence - 0.5).abs() < 0.001,
776 "confidence should be 0.5, got {}",
777 confidence
778 );
779 let decision = should_auto_merge(&summary, &policy, true);
780 match decision {
781 AutoMergeDecision::ManualReview { reasons, .. } => {
782 assert!(
783 reasons.iter().any(|r| r.contains("conflicts")),
784 "should mention conflicts: {:?}",
785 reasons
786 );
787 }
788 other => panic!("expected ManualReview, got {:?}", other),
789 }
790 }
791
792 #[test]
793 fn migrations_reduce_confidence() {
794 let mut summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
795 summary.has_migrations = true;
796 let policy = enabled_policy();
797 let confidence = compute_merge_confidence(&summary, &policy);
798 assert!(
800 (confidence - 0.7).abs() < 0.001,
801 "confidence should be 0.7, got {}",
802 confidence
803 );
804 }
805
806 #[test]
807 fn migrations_route_to_review() {
808 let mut summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
809 summary.has_migrations = true;
810 let policy = enabled_policy();
811 let decision = should_auto_merge(&summary, &policy, true);
812 match decision {
813 AutoMergeDecision::ManualReview { reasons, .. } => {
814 assert!(
815 reasons.iter().any(|r| r.contains("migration")),
816 "should mention migration: {:?}",
817 reasons
818 );
819 }
820 other => panic!("expected ManualReview, got {:?}", other),
821 }
822 }
823
824 #[test]
825 fn config_changes_reduce_confidence() {
826 let mut summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
827 summary.has_config_changes = true;
828 let policy = enabled_policy();
829 let confidence = compute_merge_confidence(&summary, &policy);
830 assert!(
832 (confidence - 0.85).abs() < 0.001,
833 "confidence should be 0.85, got {}",
834 confidence
835 );
836 }
837
838 #[test]
839 fn config_changes_auto_merge_when_confidence_above_threshold() {
840 let mut summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
844 summary.has_config_changes = true;
845 let policy = enabled_policy();
846 let decision = should_auto_merge(&summary, &policy, true);
847 match decision {
848 AutoMergeDecision::AutoMerge { .. } => {} other => panic!("config-only change should auto-merge, got {:?}", other),
850 }
851 }
852
853 #[test]
854 fn renames_boost_confidence() {
855 let mut summary = make_summary(4, 10, 10, vec!["team"], vec![], false);
856 summary.rename_count = 3;
858 let policy = enabled_policy();
859 let confidence_with_renames = compute_merge_confidence(&summary, &policy);
860
861 let summary_no_renames = make_summary(4, 10, 10, vec!["team"], vec![], false);
862 let confidence_without = compute_merge_confidence(&summary_no_renames, &policy);
863
864 assert!(
865 confidence_with_renames > confidence_without,
866 "renames should boost confidence: with={}, without={}",
867 confidence_with_renames,
868 confidence_without
869 );
870 }
871
872 #[test]
873 fn all_renames_gives_full_boost() {
874 let mut summary = make_summary(4, 0, 0, vec!["team"], vec![], false);
875 summary.rename_count = 4;
876 let policy = enabled_policy();
877 let confidence = compute_merge_confidence(&summary, &policy);
878 assert!(
880 (confidence - 1.0).abs() < 0.001,
881 "all-rename diff should have full confidence: {}",
882 confidence
883 );
884 }
885
886 #[test]
887 fn heterogeneous_but_bounded_diff_still_auto_merges() {
888 let summary = make_summary(3, 45, 15, vec!["team", "metrics"], vec![], false);
889 let policy = enabled_policy();
890 let record = evaluate_auto_merge_candidate(&summary, &policy, true);
891 assert_eq!(record.decision, AutoMergeDecisionKind::Accepted);
892 assert!(
893 record.reasons[0].contains("meets threshold"),
894 "should contain acceptance reason: {:?}",
895 record.reasons
896 );
897 }
898
899 #[test]
900 fn forced_override_decision_is_explicit() {
901 let summary = make_summary(5, 80, 20, vec!["team", "metrics", "daemon"], vec![], false);
902 let policy = enabled_policy();
903 let record = forced_auto_merge_decision(Some(&summary), &policy, true);
904 assert_eq!(record.decision, AutoMergeDecisionKind::Accepted);
905 assert_eq!(record.override_forced, Some(true));
906 assert_eq!(
907 explain_auto_merge_decision(&record),
908 "accepted for auto-merge (forced by override): confidence 0.40; 5 files, 100 lines, 3 modules; reasons: auto-merge forced by per-task override"
909 );
910 }
911
912 #[test]
913 fn migration_file_detection() {
914 assert!(is_migration_file("db/migrate/001_add_users.sql"));
915 assert!(is_migration_file("src/migrations/v2.rs"));
916 assert!(is_migration_file("schema.sql"));
917 assert!(!is_migration_file("src/team/mod.rs"));
918 }
919
920 #[test]
921 fn config_file_detection() {
922 assert!(is_config_file("team.yaml"));
923 assert!(is_config_file("Cargo.toml"));
924 assert!(is_config_file("package.json"));
925 assert!(is_config_file(".env"));
926 assert!(!is_config_file("src/team/config.rs"));
927 }
928
929 #[test]
930 fn generated_data_diff_does_not_trip_line_count_gate() {
931 let mut summary = make_summary(1, 39035, 0, vec![], vec!["generated/catalog.json"], false);
932 summary.generated_lines_added = 39035;
933 let policy = enabled_policy();
934 let decision = should_auto_merge(&summary, &policy, true);
935 match decision {
936 AutoMergeDecision::AutoMerge { .. } => {}
937 other => panic!(
938 "generated data extraction should auto-merge, got {:?}",
939 other
940 ),
941 }
942 }
943
944 #[test]
945 fn source_diff_still_trips_line_count_gate() {
946 let summary = make_summary(1, 2500, 0, vec!["team"], vec!["src/team/catalog.rs"], false);
947 let policy = enabled_policy();
948 let decision = should_auto_merge(&summary, &policy, true);
949 match decision {
950 AutoMergeDecision::ManualReview { reasons, .. } => {
951 assert!(
952 reasons.iter().any(|r| r.contains("2500 diff lines")),
953 "should mention gated source diff lines: {:?}",
954 reasons
955 );
956 }
957 other => panic!(
958 "large source diff should route to manual review, got {:?}",
959 other
960 ),
961 }
962 }
963
964 #[test]
965 fn combined_risk_factors_accumulate() {
966 let mut summary = make_summary(
967 6,
968 200,
969 100,
970 vec!["team", "cli", "tmux"],
971 vec!["Cargo.toml"],
972 true,
973 );
974 summary.has_migrations = true;
975 summary.has_config_changes = true;
976 summary.has_conflicts = true;
977 let policy = enabled_policy();
978 let confidence = compute_merge_confidence(&summary, &policy);
979 assert_eq!(confidence, 0.0, "extreme risk diff should floor at 0.0");
980 }
981
982 #[test]
983 fn override_persistence_roundtrip() {
984 let tmp = tempfile::tempdir().unwrap();
985 let root = tmp.path();
986 std::fs::create_dir_all(root.join(".batty")).unwrap();
987
988 assert!(load_overrides(root).is_empty());
990
991 save_override(root, 42, true).unwrap();
993 let overrides = load_overrides(root);
994 assert_eq!(overrides.get(&42), Some(&true));
995
996 save_override(root, 99, false).unwrap();
998 let overrides = load_overrides(root);
999 assert_eq!(overrides.get(&42), Some(&true));
1000 assert_eq!(overrides.get(&99), Some(&false));
1001
1002 save_override(root, 42, false).unwrap();
1004 let overrides = load_overrides(root);
1005 assert_eq!(overrides.get(&42), Some(&false));
1006 }
1007
1008 #[test]
1011 fn load_overrides_malformed_json_returns_empty() {
1012 let tmp = tempfile::tempdir().unwrap();
1013 let root = tmp.path();
1014 let path = root.join(OVERRIDES_FILE);
1015 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1016 std::fs::write(&path, "not valid json {{{}").unwrap();
1017 assert!(load_overrides(root).is_empty());
1018 }
1019
1020 #[test]
1021 fn load_overrides_wrong_json_type_returns_empty() {
1022 let tmp = tempfile::tempdir().unwrap();
1023 let root = tmp.path();
1024 let path = root.join(OVERRIDES_FILE);
1025 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1026 std::fs::write(&path, "[1, 2, 3]").unwrap();
1028 assert!(load_overrides(root).is_empty());
1029 }
1030
1031 #[test]
1032 fn save_override_creates_batty_dir() {
1033 let tmp = tempfile::tempdir().unwrap();
1034 let root = tmp.path();
1035 save_override(root, 1, true).unwrap();
1037 assert!(root.join(OVERRIDES_FILE).exists());
1038 }
1039
1040 #[test]
1041 fn save_override_to_readonly_dir_returns_error() {
1042 #[cfg(unix)]
1043 {
1044 use std::os::unix::fs::PermissionsExt;
1045 let tmp = tempfile::tempdir().unwrap();
1046 let root = tmp.path();
1047 let batty_dir = root.join(".batty");
1048 std::fs::create_dir(&batty_dir).unwrap();
1049 std::fs::set_permissions(&batty_dir, std::fs::Permissions::from_mode(0o444)).unwrap();
1050
1051 let result = save_override(root, 1, true);
1052 assert!(result.is_err());
1053
1054 std::fs::set_permissions(&batty_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
1056 }
1057 }
1058
1059 #[test]
1060 fn analyze_diff_on_non_git_dir_returns_empty_summary() {
1061 let tmp = tempfile::tempdir().unwrap();
1062 let result = analyze_diff(tmp.path(), "main", "feature");
1065 if let Ok(summary) = result {
1066 assert_eq!(summary.files_changed, 0);
1067 assert_eq!(summary.total_lines(), 0);
1068 }
1069 }
1071}