1use std::{
2 collections::{HashMap, HashSet},
3 process::Command,
4};
5
6use crate::config::CommitConfig;
8use crate::{
9 error::{CommitGenError, Result},
10 types::{Mode, ScopeCandidate},
11};
12
13const PLACEHOLDER_DIRS: &[&str] = &[
16 "src", "lib", "bin", "crates", "benches", "examples", "internal", "pkg", "include", "tests", "test", "docs", "packages", "modules",
23];
24
25const SKIP_DIRS: &[&str] =
27 &["test", "tests", "benches", "examples", "target", "build", "node_modules", ".github"];
28
29pub struct ScopeAnalyzer {
30 component_lines: HashMap<String, usize>,
31 total_lines: usize,
32}
33
34impl Default for ScopeAnalyzer {
35 fn default() -> Self {
36 Self::new()
37 }
38}
39
40impl ScopeAnalyzer {
41 pub fn new() -> Self {
42 Self { component_lines: HashMap::new(), total_lines: 0 }
43 }
44
45 pub fn process_numstat_line(&mut self, line: &str, config: &CommitConfig) {
47 let parts: Vec<&str> = line.split('\t').collect();
48 if parts.len() < 3 {
49 return;
50 }
51
52 let (added_str, deleted_str, path_part) = (parts[0], parts[1], parts[2]);
53
54 let added = added_str.parse::<usize>().unwrap_or(0);
56 let deleted = deleted_str.parse::<usize>().unwrap_or(0);
57 let lines_changed = added + deleted;
58
59 if lines_changed == 0 {
60 return;
61 }
62
63 let path = Self::extract_path_from_rename(path_part);
65
66 if config.excluded_files.iter().any(|ex| path.ends_with(ex)) {
68 return;
69 }
70
71 self.total_lines += lines_changed;
72
73 let component_candidates = Self::extract_components_from_path(&path);
75
76 for comp in component_candidates {
77 if comp.split('/').any(|s| s.contains('.')) {
79 continue;
80 }
81
82 *self.component_lines.entry(comp).or_insert(0) += lines_changed;
83 }
84 }
85
86 fn extract_path_from_rename(path_part: &str) -> String {
88 if let Some(brace_start) = path_part.find('{') {
90 if let Some(arrow_pos) = path_part[brace_start..].find(" => ") {
91 let arrow_abs = brace_start + arrow_pos;
92 if let Some(brace_end) = path_part[arrow_abs..].find('}') {
93 let brace_end_abs = arrow_abs + brace_end;
94 let prefix = &path_part[..brace_start];
95 let new_name = path_part[arrow_abs + 4..brace_end_abs].trim();
96 return format!("{prefix}{new_name}");
97 }
98 }
99 } else if path_part.contains(" => ") {
100 return path_part
102 .split(" => ")
103 .nth(1)
104 .unwrap_or(path_part)
105 .trim()
106 .to_string();
107 }
108
109 path_part.trim().to_string()
110 }
111
112 fn extract_components_from_path(path: &str) -> Vec<String> {
114 let segments: Vec<&str> = path.split('/').collect();
115 let mut component_candidates = Vec::new();
116 let mut meaningful_segments = Vec::new();
117
118 let strip_ext = |s: &str| -> String {
120 if let Some(pos) = s.rfind('.') {
121 s[..pos].to_string()
122 } else {
123 s.to_string()
124 }
125 };
126
127 let is_file = |s: &str| -> bool {
129 s.contains('.') && !s.starts_with('.') && s.rfind('.').is_some_and(|p| p > 0)
130 };
131
132 for (seg_idx, seg) in segments.iter().enumerate() {
134 if PLACEHOLDER_DIRS.contains(seg) {
136 if segments.len() > seg_idx + 1 {
138 continue;
139 }
140 }
141 if is_file(seg) {
143 continue;
144 }
145 if SKIP_DIRS.contains(seg) {
147 continue;
148 }
149
150 let stripped = strip_ext(seg);
151 if !stripped.is_empty() && !stripped.starts_with('.') {
153 meaningful_segments.push(stripped);
154 }
155 }
156
157 if !meaningful_segments.is_empty() {
159 component_candidates.push(meaningful_segments[0].clone());
160
161 if meaningful_segments.len() >= 2 {
162 component_candidates
163 .push(format!("{}/{}", meaningful_segments[0], meaningful_segments[1]));
164 }
165 }
166
167 component_candidates
168 }
169
170 pub fn build_scope_candidates(&self) -> Vec<ScopeCandidate> {
172 let mut candidates: Vec<ScopeCandidate> = self
173 .component_lines
174 .iter()
175 .filter(|(path, _)| {
176 if !path.contains('/') && PLACEHOLDER_DIRS.contains(&path.as_str()) {
178 return false;
179 }
180 if let Some(root) = path.split('/').next()
182 && PLACEHOLDER_DIRS.contains(&root)
183 {
184 return false;
185 }
186 true
187 })
188 .map(|(path, &lines)| {
189 let percentage = (lines as f32 / self.total_lines as f32) * 100.0;
190 let is_two_segment = path.contains('/');
191
192 let confidence = if is_two_segment {
196 if percentage > 60.0 {
197 percentage * 1.2
198 } else {
199 percentage * 0.8
200 }
201 } else {
202 percentage
203 };
204
205 ScopeCandidate { percentage, path: path.clone(), confidence }
206 })
207 .collect();
208
209 candidates.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap());
210 candidates
211 }
212
213 pub fn is_wide_change(candidates: &[ScopeCandidate], config: &CommitConfig) -> bool {
215 let is_wide = if let Some(top) = candidates.first() {
217 top.percentage / 100.0 < config.wide_change_threshold
218 } else {
219 false
220 };
221
222 let distinct_roots: HashSet<&str> = candidates
224 .iter()
225 .map(|c| c.path.split('/').next().unwrap_or(&c.path))
226 .collect();
227
228 is_wide || distinct_roots.len() >= 3
229 }
230
231 pub fn extract_scope(numstat: &str, config: &CommitConfig) -> (Vec<ScopeCandidate>, usize) {
233 let mut analyzer = Self::new();
234
235 for line in numstat.lines() {
236 analyzer.process_numstat_line(line, config);
237 }
238
239 let candidates = analyzer.build_scope_candidates();
240 (candidates, analyzer.total_lines)
241 }
242
243 pub fn analyze_wide_change(numstat: &str) -> Option<String> {
245 let lines: Vec<&str> = numstat.lines().collect();
246 if lines.is_empty() {
247 return None;
248 }
249
250 let paths: Vec<&str> = lines
252 .iter()
253 .filter_map(|line| {
254 let parts: Vec<&str> = line.split('\t').collect();
255 if parts.len() >= 3 {
256 Some(parts[2])
257 } else {
258 None
259 }
260 })
261 .collect();
262
263 if paths.is_empty() {
264 return None;
265 }
266
267 let total = paths.len();
269 let mut md_count = 0;
270 let mut test_count = 0;
271 let mut config_count = 0;
272 let mut has_cargo_toml = false;
273 let mut has_package_json = false;
274
275 let mut error_keywords = 0;
277 let mut type_keywords = 0;
278
279 for path in &paths {
280 if std::path::Path::new(path)
282 .extension()
283 .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
284 {
285 md_count += 1;
286 }
287 if path.contains("/test") || path.contains("_test.") || path.ends_with("_test.rs") {
288 test_count += 1;
289 }
290 if std::path::Path::new(path).extension().is_some_and(|ext| {
291 ext.eq_ignore_ascii_case("toml")
292 || ext.eq_ignore_ascii_case("yaml")
293 || ext.eq_ignore_ascii_case("yml")
294 || ext.eq_ignore_ascii_case("json")
295 }) {
296 config_count += 1;
297 }
298
299 if path.contains("Cargo.toml") {
301 has_cargo_toml = true;
302 }
303 if path.contains("package.json") {
304 has_package_json = true;
305 }
306
307 let lower_path = path.to_lowercase();
309 if lower_path.contains("error")
310 || lower_path.contains("result")
311 || lower_path.contains("err")
312 {
313 error_keywords += 1;
314 }
315 if lower_path.contains("type")
316 || lower_path.contains("struct")
317 || lower_path.contains("enum")
318 {
319 type_keywords += 1;
320 }
321 }
322
323 if has_cargo_toml || has_package_json {
327 return Some("deps".to_string());
328 }
329
330 if md_count * 100 / total > 70 {
332 return Some("docs".to_string());
333 }
334
335 if test_count * 100 / total > 60 {
337 return Some("tests".to_string());
338 }
339
340 if error_keywords * 100 / total > 40 {
342 return Some("error-handling".to_string());
343 }
344
345 if type_keywords * 100 / total > 40 {
347 return Some("type-refactor".to_string());
348 }
349
350 if config_count * 100 / total > 50 {
352 return Some("config".to_string());
353 }
354
355 None
357 }
358}
359
360pub fn extract_scope_candidates(
363 mode: &Mode,
364 target: Option<&str>,
365 dir: &str,
366 config: &CommitConfig,
367) -> Result<(String, bool)> {
368 let output = match mode {
370 Mode::Staged => Command::new("git")
371 .args(["diff", "--cached", "--numstat"])
372 .current_dir(dir)
373 .output()
374 .map_err(|e| {
375 CommitGenError::git(format!("Failed to run git diff --cached --numstat: {e}"))
376 })?,
377 Mode::Commit => {
378 let target = target.ok_or_else(|| {
379 CommitGenError::ValidationError("--target required for commit mode".to_string())
380 })?;
381 Command::new("git")
382 .args(["show", "--numstat", target])
383 .current_dir(dir)
384 .output()
385 .map_err(|e| CommitGenError::git(format!("Failed to run git show --numstat: {e}")))?
386 },
387 Mode::Unstaged => Command::new("git")
388 .args(["diff", "--numstat"])
389 .current_dir(dir)
390 .output()
391 .map_err(|e| CommitGenError::git(format!("Failed to run git diff --numstat: {e}")))?,
392 Mode::Compose => unreachable!("compose mode handled separately"),
393 };
394
395 if !output.status.success() {
396 return Err(CommitGenError::git("git diff --numstat failed".to_string()));
397 }
398
399 let numstat = String::from_utf8_lossy(&output.stdout);
400
401 let (candidates, total_lines) = ScopeAnalyzer::extract_scope(&numstat, config);
402
403 if total_lines == 0 {
404 return Ok(("(none - no measurable changes)".to_string(), false));
405 }
406
407 let is_wide = ScopeAnalyzer::is_wide_change(&candidates, config);
408
409 if is_wide {
410 let scope_str = if config.wide_change_abstract {
412 if let Some(pattern) = ScopeAnalyzer::analyze_wide_change(&numstat) {
413 format!("(cross-cutting: {pattern})")
414 } else {
415 "(none - multi-component change)".to_string()
416 }
417 } else {
418 "(none - multi-component change)".to_string()
419 };
420
421 return Ok((scope_str, true));
422 }
423
424 let mut suggestion_parts = Vec::new();
427 for cand in candidates.iter().take(5) {
428 if cand.percentage >= 10.0 {
430 let confidence_label = if cand.path.contains('/') {
431 if cand.percentage > 60.0 {
432 "high confidence"
433 } else {
434 "moderate confidence"
435 }
436 } else {
437 "high confidence"
438 };
439
440 suggestion_parts
441 .push(format!("{} ({:.0}%, {})", cand.path, cand.percentage, confidence_label));
442 }
443 }
444
445 let scope_str = if suggestion_parts.is_empty() {
446 "(none - unclear component)".to_string()
447 } else {
448 format!("{}\nPrefer 2-segment scopes marked 'high confidence'", suggestion_parts.join(", "))
449 };
450
451 Ok((scope_str, is_wide))
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457
458 fn default_config() -> CommitConfig {
459 CommitConfig {
460 excluded_files: vec![
461 "Cargo.lock".to_string(),
462 "package-lock.json".to_string(),
463 "yarn.lock".to_string(),
464 ],
465 wide_change_threshold: 0.5,
466 ..Default::default()
467 }
468 }
469
470 #[test]
472 fn test_extract_path_from_rename_brace() {
473 assert_eq!(ScopeAnalyzer::extract_path_from_rename("lib/{old => new}/file.rs"), "lib/new");
476 }
477
478 #[test]
479 fn test_extract_path_from_rename_brace_complex() {
480 assert_eq!(
481 ScopeAnalyzer::extract_path_from_rename("src/api/{client.rs => http_client.rs}"),
482 "src/api/http_client.rs"
483 );
484 }
485
486 #[test]
487 fn test_extract_path_from_rename_arrow() {
488 assert_eq!(
489 ScopeAnalyzer::extract_path_from_rename("old/file.rs => new/file.rs"),
490 "new/file.rs"
491 );
492 }
493
494 #[test]
495 fn test_extract_path_from_rename_arrow_with_spaces() {
496 assert_eq!(
497 ScopeAnalyzer::extract_path_from_rename(" old/path.rs => new/path.rs "),
498 "new/path.rs"
499 );
500 }
501
502 #[test]
503 fn test_extract_path_from_rename_no_rename() {
504 assert_eq!(ScopeAnalyzer::extract_path_from_rename("lib/file.rs"), "lib/file.rs");
505 }
506
507 #[test]
508 fn test_extract_path_from_rename_malformed_brace() {
509 assert_eq!(
511 ScopeAnalyzer::extract_path_from_rename("lib/{old => new/file.rs"),
512 "lib/{old => new/file.rs"
513 );
514 }
515
516 #[test]
518 fn test_extract_components_simple() {
519 let comps = ScopeAnalyzer::extract_components_from_path("src/api/client.rs");
521 assert_eq!(comps, vec!["api"]);
522 }
523
524 #[test]
525 fn test_extract_components_with_placeholder() {
526 let comps = ScopeAnalyzer::extract_components_from_path("lib/foo/bar/baz.tsx");
528 assert_eq!(comps, vec!["foo", "foo/bar"]);
529 }
530
531 #[test]
532 fn test_extract_components_skip_tests() {
533 let comps = ScopeAnalyzer::extract_components_from_path("tests/api/client_test.rs");
535 assert_eq!(comps, vec!["api"]);
536 }
537
538 #[test]
539 fn test_extract_components_skip_node_modules() {
540 let comps = ScopeAnalyzer::extract_components_from_path("node_modules/foo/bar.js");
542 assert_eq!(comps, vec!["foo"]);
543 }
544
545 #[test]
546 fn test_extract_components_single_segment() {
547 let comps = ScopeAnalyzer::extract_components_from_path("src/main.rs");
548 assert_eq!(comps, Vec::<String>::new());
550 }
551
552 #[test]
553 fn test_extract_components_dotfile_skipped() {
554 let comps = ScopeAnalyzer::extract_components_from_path("lib/.git/config");
556 assert_eq!(comps, vec!["config"]);
557 }
558
559 #[test]
560 fn test_extract_components_strips_extension() {
561 let comps = ScopeAnalyzer::extract_components_from_path("src/api/client.rs");
562 assert!(comps.contains(&"api".to_string()));
564 }
565
566 #[test]
567 fn test_extract_components_go_internal() {
568 let comps = ScopeAnalyzer::extract_components_from_path("internal/agent/worker.go");
570 assert_eq!(comps, vec!["agent"]);
571 }
572
573 #[test]
574 fn test_extract_components_go_internal_nested() {
575 let comps = ScopeAnalyzer::extract_components_from_path("internal/config/parser/json.go");
577 assert_eq!(comps, vec!["config", "config/parser"]);
578 }
579
580 #[test]
581 fn test_extract_components_go_pkg() {
582 let comps = ScopeAnalyzer::extract_components_from_path("pkg/util/strings.go");
584 assert_eq!(comps, vec!["util"]);
585 }
586
587 #[test]
588 fn test_extract_components_monorepo_packages() {
589 let comps = ScopeAnalyzer::extract_components_from_path("packages/core/index.ts");
591 assert_eq!(comps, vec!["core"]);
592 }
593
594 #[test]
596 fn test_process_numstat_line_normal() {
597 let mut analyzer = ScopeAnalyzer::new();
598 let config = default_config();
599 analyzer.process_numstat_line("10\t5\tlib/foo/bar.rs", &config);
600
601 assert_eq!(analyzer.total_lines, 15);
602 assert_eq!(analyzer.component_lines.get("foo"), Some(&15));
603 }
604
605 #[test]
606 fn test_process_numstat_line_excluded_file() {
607 let mut analyzer = ScopeAnalyzer::new();
608 let config = default_config();
609 analyzer.process_numstat_line("10\t5\tCargo.lock", &config);
610
611 assert_eq!(analyzer.total_lines, 0);
612 assert!(analyzer.component_lines.is_empty());
613 }
614
615 #[test]
616 fn test_process_numstat_line_binary_file() {
617 let mut analyzer = ScopeAnalyzer::new();
618 let config = default_config();
619 analyzer.process_numstat_line("-\t-\timage.png", &config);
620
621 assert_eq!(analyzer.total_lines, 0);
622 }
623
624 #[test]
625 fn test_process_numstat_line_invalid() {
626 let mut analyzer = ScopeAnalyzer::new();
627 let config = default_config();
628 analyzer.process_numstat_line("invalid line", &config);
629
630 assert_eq!(analyzer.total_lines, 0);
631 }
632
633 #[test]
634 fn test_process_numstat_line_rename_brace() {
635 let mut analyzer = ScopeAnalyzer::new();
636 let config = default_config();
637 analyzer.process_numstat_line("20\t10\tlib/{old => new}/file.rs", &config);
639
640 assert_eq!(analyzer.total_lines, 30);
641 assert_eq!(analyzer.component_lines.get("new"), Some(&30));
643 }
644
645 #[test]
646 fn test_process_numstat_line_multiple_files() {
647 let mut analyzer = ScopeAnalyzer::new();
648 let config = default_config();
649 analyzer.process_numstat_line("10\t5\tsrc/api/client.rs", &config);
650 analyzer.process_numstat_line("20\t10\tsrc/api/server.rs", &config);
651
652 assert_eq!(analyzer.total_lines, 45);
653 assert_eq!(analyzer.component_lines.get("api"), Some(&45));
654 }
655
656 #[test]
658 fn test_is_wide_change_focused() {
659 let config = default_config();
660 let candidates = vec![
661 ScopeCandidate { path: "api".to_string(), percentage: 80.0, confidence: 80.0 },
662 ScopeCandidate { path: "db".to_string(), percentage: 20.0, confidence: 20.0 },
663 ];
664
665 assert!(!ScopeAnalyzer::is_wide_change(&candidates, &config));
666 }
667
668 #[test]
669 fn test_is_wide_change_dispersed() {
670 let config = default_config();
671 let candidates = vec![
672 ScopeCandidate { path: "api".to_string(), percentage: 30.0, confidence: 30.0 },
673 ScopeCandidate { path: "db".to_string(), percentage: 30.0, confidence: 30.0 },
674 ScopeCandidate { path: "ui".to_string(), percentage: 40.0, confidence: 40.0 },
675 ];
676
677 assert!(ScopeAnalyzer::is_wide_change(&candidates, &config));
678 }
679
680 #[test]
681 fn test_is_wide_change_three_roots() {
682 let config = default_config();
683 let candidates = vec![
684 ScopeCandidate { path: "api".to_string(), percentage: 60.0, confidence: 60.0 },
685 ScopeCandidate { path: "db".to_string(), percentage: 20.0, confidence: 20.0 },
686 ScopeCandidate { path: "ui".to_string(), percentage: 20.0, confidence: 20.0 },
687 ];
688
689 assert!(ScopeAnalyzer::is_wide_change(&candidates, &config));
690 }
691
692 #[test]
693 fn test_is_wide_change_nested_same_root() {
694 let config = default_config();
695 let candidates = vec![
696 ScopeCandidate {
697 path: "api/client".to_string(),
698 percentage: 60.0,
699 confidence: 72.0,
700 },
701 ScopeCandidate {
702 path: "api/server".to_string(),
703 percentage: 40.0,
704 confidence: 32.0,
705 },
706 ];
707
708 assert!(!ScopeAnalyzer::is_wide_change(&candidates, &config));
709 }
710
711 #[test]
712 fn test_is_wide_change_empty() {
713 let config = default_config();
714 let candidates = vec![];
715
716 assert!(!ScopeAnalyzer::is_wide_change(&candidates, &config));
717 }
718
719 #[test]
721 fn test_extract_scope_single_file() {
722 let config = default_config();
723 let numstat = "10\t5\tsrc/api/client.rs";
724 let (candidates, total_lines) = ScopeAnalyzer::extract_scope(numstat, &config);
725
726 assert_eq!(total_lines, 15);
727 assert_eq!(candidates.len(), 1);
729 assert_eq!(candidates[0].path, "api");
730 assert_eq!(candidates[0].percentage, 100.0);
731 }
732
733 #[test]
734 fn test_extract_scope_placeholder_only() {
735 let config = default_config();
736 let numstat = "10\t5\tsrc/main.rs";
737 let (candidates, total_lines) = ScopeAnalyzer::extract_scope(numstat, &config);
738
739 assert_eq!(total_lines, 15);
740 assert_eq!(candidates.len(), 0);
742 }
743
744 #[test]
745 fn test_extract_scope_multiple_files() {
746 let config = default_config();
747 let numstat = "10\t5\tsrc/api/client.rs\n20\t10\tsrc/db/models.rs";
748 let (candidates, total_lines) = ScopeAnalyzer::extract_scope(numstat, &config);
749
750 assert_eq!(total_lines, 45);
751 assert!(candidates.len() >= 2);
752
753 let api_cand = candidates.iter().find(|c| c.path == "api");
755 let db_cand = candidates.iter().find(|c| c.path == "db");
756
757 assert!(api_cand.is_some());
758 assert!(db_cand.is_some());
759
760 assert!(db_cand.unwrap().percentage > api_cand.unwrap().percentage);
762 }
763
764 #[test]
765 fn test_extract_scope_excluded_files() {
766 let config = default_config();
767 let numstat = "100\t50\tCargo.lock\n10\t5\tsrc/api/client.rs";
768 let (candidates, total_lines) = ScopeAnalyzer::extract_scope(numstat, &config);
769
770 assert_eq!(total_lines, 15);
772 assert_eq!(candidates[0].path, "api");
773 }
774
775 #[test]
776 fn test_extract_scope_no_changes() {
777 let config = default_config();
778 let numstat = "";
779 let (candidates, total_lines) = ScopeAnalyzer::extract_scope(numstat, &config);
780
781 assert_eq!(total_lines, 0);
782 assert!(candidates.is_empty());
783 }
784
785 #[test]
786 fn test_extract_scope_sorted_by_percentage() {
787 let config = default_config();
788 let numstat = "5\t0\tsrc/api/client.rs\n50\t0\tsrc/db/models.rs\n10\t0\tsrc/ui/component.tsx";
789 let (candidates, _) = ScopeAnalyzer::extract_scope(numstat, &config);
790
791 assert!(candidates[0].percentage >= candidates[1].percentage);
793 assert!(candidates[1].percentage >= candidates[2].percentage);
794 }
795
796 #[test]
797 fn test_build_scope_candidates_percentages() {
798 let mut analyzer = ScopeAnalyzer::new();
799 analyzer.component_lines.insert("api".to_string(), 30);
800 analyzer.component_lines.insert("db".to_string(), 70);
801 analyzer.total_lines = 100;
802
803 let candidates = analyzer.build_scope_candidates();
804
805 assert_eq!(candidates.len(), 2);
806 assert_eq!(candidates[0].path, "db");
807 assert!((candidates[0].percentage - 70.0).abs() < 0.001);
808 assert_eq!(candidates[1].path, "api");
809 assert!((candidates[1].percentage - 30.0).abs() < 0.001);
810 }
811
812 #[test]
814 fn test_confidence_70_percent_in_two_segment_prefers_specific() {
815 let mut analyzer = ScopeAnalyzer::new();
816 analyzer.component_lines.insert("api".to_string(), 70);
817 analyzer
818 .component_lines
819 .insert("api/client".to_string(), 70);
820 analyzer.component_lines.insert("other".to_string(), 30);
821 analyzer.total_lines = 100;
822
823 let candidates = analyzer.build_scope_candidates();
824
825 assert_eq!(candidates[0].path, "api/client");
830 assert!((candidates[0].percentage - 70.0).abs() < 0.001);
831 assert!((candidates[0].confidence - 84.0).abs() < 0.001);
832 }
833
834 #[test]
836 fn test_confidence_45_percent_in_two_segment_prefers_single() {
837 let mut analyzer = ScopeAnalyzer::new();
838 analyzer.component_lines.insert("api".to_string(), 45);
839 analyzer
840 .component_lines
841 .insert("api/client".to_string(), 45);
842 analyzer.component_lines.insert("other".to_string(), 55);
843 analyzer.total_lines = 100;
844
845 let candidates = analyzer.build_scope_candidates();
846
847 assert_eq!(candidates[0].path, "other");
852 assert_eq!(candidates[1].path, "api");
853 assert_eq!(candidates[2].path, "api/client");
854 assert!((candidates[2].confidence - 36.0).abs() < 0.001);
855 }
856
857 #[test]
859 fn test_analyze_wide_change_dependency_updates() {
860 let numstat = "10\t5\tCargo.toml\n20\t10\tsrc/lib.rs\n5\t3\tsrc/api.rs";
861 let result = ScopeAnalyzer::analyze_wide_change(numstat);
862 assert_eq!(result, Some("deps".to_string()));
863 }
864
865 #[test]
866 fn test_analyze_wide_change_documentation() {
867 let numstat =
868 "50\t20\tREADME.md\n30\t10\tdocs/guide.md\n20\t5\tdocs/api.md\n5\t2\tsrc/lib.rs";
869 let result = ScopeAnalyzer::analyze_wide_change(numstat);
870 assert_eq!(result, Some("docs".to_string()));
871 }
872
873 #[test]
874 fn test_analyze_wide_change_tests() {
875 let numstat = "10\t5\tsrc/api_test.rs\n15\t8\tsrc/client_test.rs\n20\t10\ttests/\
876 integration_test.rs\n5\t2\tsrc/lib.rs";
877 let result = ScopeAnalyzer::analyze_wide_change(numstat);
878 assert_eq!(result, Some("tests".to_string()));
879 }
880
881 #[test]
882 fn test_analyze_wide_change_error_handling() {
883 let numstat =
884 "10\t5\tsrc/error.rs\n15\t8\tsrc/result.rs\n20\t10\tsrc/error_types.rs\n5\t2\tsrc/lib.rs";
885 let result = ScopeAnalyzer::analyze_wide_change(numstat);
886 assert_eq!(result, Some("error-handling".to_string()));
887 }
888
889 #[test]
890 fn test_analyze_wide_change_type_refactor() {
891 let numstat =
892 "10\t5\tsrc/types.rs\n15\t8\tsrc/structs.rs\n20\t10\tsrc/enums.rs\n5\t2\tsrc/lib.rs";
893 let result = ScopeAnalyzer::analyze_wide_change(numstat);
894 assert_eq!(result, Some("type-refactor".to_string()));
895 }
896
897 #[test]
898 fn test_analyze_wide_change_config() {
899 let numstat =
900 "10\t5\tconfig.toml\n15\t8\tsettings.yaml\n20\t10\tconfig.json\n5\t2\tsrc/lib.rs";
901 let result = ScopeAnalyzer::analyze_wide_change(numstat);
902 assert_eq!(result, Some("config".to_string()));
903 }
904
905 #[test]
906 fn test_analyze_wide_change_no_pattern() {
907 let numstat = "10\t5\tsrc/foo.rs\n15\t8\tsrc/bar.rs\n20\t10\tsrc/baz.rs";
908 let result = ScopeAnalyzer::analyze_wide_change(numstat);
909 assert_eq!(result, None);
910 }
911
912 #[test]
913 fn test_analyze_wide_change_empty() {
914 let numstat = "";
915 let result = ScopeAnalyzer::analyze_wide_change(numstat);
916 assert_eq!(result, None);
917 }
918
919 #[test]
920 fn test_analyze_wide_change_package_json() {
921 let numstat = "10\t5\tpackage.json\n20\t10\tsrc/index.js\n5\t3\tsrc/utils.js";
922 let result = ScopeAnalyzer::analyze_wide_change(numstat);
923 assert_eq!(result, Some("deps".to_string()));
924 }
925}