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 modules_touched: HashSet<String>,
47 pub sensitive_files: Vec<String>,
48 pub has_unsafe: bool,
49 pub has_conflicts: bool,
50 pub rename_count: usize,
52 pub has_migrations: bool,
54 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#[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
138pub fn analyze_diff(repo: &Path, base: &str, branch: &str) -> Result<DiffSummary> {
140 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 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 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 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 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 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, has_unsafe,
220 has_conflicts,
221 rename_count,
222 has_migrations,
223 has_config_changes,
224 })
225}
226
227fn 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, };
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, }
251}
252
253fn 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
263fn 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
274pub fn compute_merge_confidence(summary: &DiffSummary, policy: &AutoMergePolicy) -> f64 {
276 let mut confidence = 1.0f64;
277
278 if summary.files_changed > 3 {
280 confidence -= 0.1 * (summary.files_changed - 3) as f64;
281 }
282
283 if summary.modules_touched.len() > 1 {
285 confidence -= 0.2 * (summary.modules_touched.len() - 1) as f64;
286 }
287
288 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 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 if summary.has_unsafe {
306 confidence -= 0.4;
307 }
308
309 if summary.has_conflicts {
311 confidence -= 0.5;
312 }
313
314 if summary.has_migrations {
316 confidence -= 0.3;
317 }
318
319 if summary.has_config_changes {
321 confidence -= 0.15;
322 }
323
324 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 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
494pub 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 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 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 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 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 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 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 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 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 assert!(load_overrides(root).is_empty());
909
910 save_override(root, 42, true).unwrap();
912 let overrides = load_overrides(root);
913 assert_eq!(overrides.get(&42), Some(&true));
914
915 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 save_override(root, 42, false).unwrap();
923 let overrides = load_overrides(root);
924 assert_eq!(overrides.get(&42), Some(&false));
925 }
926
927 #[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 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 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 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 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 }
990}