1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use crate::data::context::{
7 ArchitecturalLayer, ChangeImpact, FileContext, FilePurpose, ProjectSignificance,
8};
9use crate::git::CommitInfo;
10
11pub struct FileAnalyzer;
13
14impl FileAnalyzer {
15 pub fn analyze_file(path: &Path, change_type: &str) -> FileContext {
17 let file_purpose = determine_file_purpose(path);
18 let architectural_layer = determine_architectural_layer(path, &file_purpose);
19 let change_impact = determine_change_impact(change_type, &file_purpose);
20 let project_significance = determine_project_significance(path, &file_purpose);
21
22 FileContext {
23 path: path.to_path_buf(),
24 file_purpose,
25 architectural_layer,
26 change_impact,
27 project_significance,
28 }
29 }
30
31 pub fn analyze_file_set(files: &[(PathBuf, String)]) -> Vec<FileContext> {
33 files
34 .iter()
35 .map(|(path, change_type)| Self::analyze_file(path, change_type))
36 .collect()
37 }
38
39 pub fn analyze_commits(commits: &[CommitInfo]) -> Vec<FileContext> {
45 let mut file_map: HashMap<PathBuf, String> = HashMap::new();
46
47 for commit in commits {
48 for fc in &commit.analysis.file_changes.file_list {
49 file_map.insert(PathBuf::from(&fc.file), fc.status.clone());
50 }
51 }
52
53 let files: Vec<(PathBuf, String)> = file_map.into_iter().collect();
54 Self::analyze_file_set(&files)
55 }
56
57 pub fn primary_architectural_impact(contexts: &[FileContext]) -> ArchitecturalLayer {
59 let mut layer_counts = HashMap::new();
60 for context in contexts {
61 *layer_counts
62 .entry(context.architectural_layer.clone())
63 .or_insert(0) += 1;
64 }
65
66 layer_counts
68 .into_iter()
69 .max_by_key(|(layer, count)| {
70 let priority = match layer {
71 ArchitecturalLayer::Business => 100,
72 ArchitecturalLayer::Data => 90,
73 ArchitecturalLayer::Presentation => 80,
74 ArchitecturalLayer::Infrastructure => 70,
75 ArchitecturalLayer::Cross => 60,
76 };
77 priority + count
78 })
79 .map_or(ArchitecturalLayer::Cross, |(layer, _)| layer)
80 }
81
82 #[must_use]
84 pub fn is_architectural_change(contexts: &[FileContext]) -> bool {
85 let critical_files = contexts
86 .iter()
87 .filter(|c| matches!(c.project_significance, ProjectSignificance::Critical))
88 .count();
89
90 let breaking_changes = contexts
91 .iter()
92 .filter(|c| {
93 matches!(
94 c.change_impact,
95 ChangeImpact::Breaking | ChangeImpact::Critical
96 )
97 })
98 .count();
99
100 critical_files > 0 || breaking_changes > 1 || contexts.len() > 10
101 }
102}
103
104fn determine_file_purpose(path: &Path) -> FilePurpose {
106 let path_str = path.to_string_lossy().to_lowercase();
107 let file_name = path
108 .file_name()
109 .and_then(|name| name.to_str())
110 .unwrap_or("")
111 .to_lowercase();
112
113 if is_config_file(&path_str, &file_name) {
115 return FilePurpose::Config;
116 }
117
118 if is_test_file(&path_str, &file_name) {
120 return FilePurpose::Test;
121 }
122
123 if is_documentation_file(&path_str, &file_name) {
125 return FilePurpose::Documentation;
126 }
127
128 if is_build_file(&path_str, &file_name) {
130 return FilePurpose::Build;
131 }
132
133 if is_tooling_file(&path_str, &file_name) {
135 return FilePurpose::Tooling;
136 }
137
138 if is_interface_file(&path_str, &file_name) {
140 return FilePurpose::Interface;
141 }
142
143 FilePurpose::CoreLogic
145}
146
147fn determine_architectural_layer(path: &Path, file_purpose: &FilePurpose) -> ArchitecturalLayer {
149 let path_str = path.to_string_lossy().to_lowercase();
150
151 match file_purpose {
152 FilePurpose::Config | FilePurpose::Build | FilePurpose::Tooling => {
153 ArchitecturalLayer::Infrastructure
154 }
155 FilePurpose::Test | FilePurpose::Documentation => ArchitecturalLayer::Cross,
156 FilePurpose::Interface => ArchitecturalLayer::Presentation,
157 FilePurpose::CoreLogic => {
158 if path_str.contains("ui") || path_str.contains("web") || path_str.contains("cli") {
160 ArchitecturalLayer::Presentation
161 } else if path_str.contains("data")
162 || path_str.contains("db")
163 || path_str.contains("storage")
164 {
165 ArchitecturalLayer::Data
166 } else if path_str.contains("core")
167 || path_str.contains("business")
168 || path_str.contains("logic")
169 {
170 ArchitecturalLayer::Business
171 } else if path_str.contains("infra")
172 || path_str.contains("system")
173 || path_str.contains("network")
174 {
175 ArchitecturalLayer::Infrastructure
176 } else {
177 ArchitecturalLayer::Business }
179 }
180 }
181}
182
183fn determine_change_impact(change_type: &str, file_purpose: &FilePurpose) -> ChangeImpact {
185 match change_type {
186 "A" | "C" => ChangeImpact::Additive, "D" => {
188 match file_purpose {
190 FilePurpose::Interface | FilePurpose::CoreLogic => ChangeImpact::Breaking,
191 _ => ChangeImpact::Modification,
192 }
193 }
194 "M" => {
195 match file_purpose {
197 FilePurpose::Test | FilePurpose::Documentation => ChangeImpact::Style,
198 FilePurpose::Interface => ChangeImpact::Breaking, _ => ChangeImpact::Modification,
200 }
201 }
202 _ => ChangeImpact::Modification, }
204}
205
206fn determine_project_significance(path: &Path, file_purpose: &FilePurpose) -> ProjectSignificance {
208 let path_str = path.to_string_lossy().to_lowercase();
209 let file_name = path
210 .file_name()
211 .and_then(|name| name.to_str())
212 .unwrap_or("")
213 .to_lowercase();
214
215 if is_critical_file(&path_str, &file_name) {
217 return ProjectSignificance::Critical;
218 }
219
220 match file_purpose {
222 FilePurpose::Interface | FilePurpose::CoreLogic | FilePurpose::Build => {
223 ProjectSignificance::Important
224 }
225 FilePurpose::Config => {
226 if file_name.contains("cargo.toml") || file_name.contains("package.json") {
227 ProjectSignificance::Critical
228 } else {
229 ProjectSignificance::Important
230 }
231 }
232 FilePurpose::Test | FilePurpose::Documentation | FilePurpose::Tooling => {
233 ProjectSignificance::Routine
234 }
235 }
236}
237
238fn is_config_file(path_str: &str, file_name: &str) -> bool {
240 let config_patterns = [
241 ".toml",
242 ".json",
243 ".yaml",
244 ".yml",
245 ".ini",
246 ".cfg",
247 ".conf",
248 ".env",
249 ".properties",
250 "config",
251 "settings",
252 "options",
253 ];
254
255 let config_names = [
256 "cargo.toml",
257 "package.json",
258 "pyproject.toml",
259 "go.mod",
260 "pom.xml",
261 "build.gradle",
262 "makefile",
263 "dockerfile",
264 ".gitignore",
265 ".gitattributes",
266 ];
267
268 config_patterns
269 .iter()
270 .any(|pattern| file_name.contains(pattern))
271 || config_names.contains(&file_name)
272 || path_str.contains("config")
273 || path_str.contains(".github/workflows")
274}
275
276fn is_test_file(path_str: &str, file_name: &str) -> bool {
278 path_str.contains("test")
279 || path_str.contains("spec")
280 || file_name.contains("test")
281 || file_name.contains("spec")
282 || file_name.ends_with("_test.rs")
283 || file_name.ends_with("_test.py")
284 || file_name.ends_with(".test.js")
285 || file_name.ends_with("_test.go")
286}
287
288fn is_documentation_file(path_str: &str, file_name: &str) -> bool {
290 let doc_extensions = [".md", ".rst", ".txt", ".adoc"];
291 let doc_names = ["readme", "changelog", "contributing", "license", "authors"];
292
293 doc_extensions.iter().any(|ext| file_name.ends_with(ext))
294 || doc_names.iter().any(|name| file_name.contains(name))
295 || path_str.contains("doc")
296 || path_str.contains("guide")
297 || path_str.contains("manual")
298}
299
300fn is_build_file(path_str: &str, file_name: &str) -> bool {
302 let build_names = [
303 "makefile",
304 "dockerfile",
305 "build.gradle",
306 "pom.xml",
307 "cmake",
308 "webpack.config",
309 "rollup.config",
310 "vite.config",
311 ];
312
313 build_names.iter().any(|name| file_name.contains(name))
314 || path_str.contains("build")
315 || path_str.contains("scripts")
316 || file_name.ends_with(".sh")
317 || file_name.ends_with(".bat")
318}
319
320fn is_tooling_file(path_str: &str, file_name: &str) -> bool {
322 path_str.contains("tool")
323 || path_str.contains("util")
324 || path_str.contains(".vscode")
325 || path_str.contains(".idea")
326 || file_name.starts_with('.')
327 || file_name.contains("prettier")
328 || file_name.contains("eslint")
329 || file_name.contains("clippy")
330}
331
332fn is_interface_file(path_str: &str, file_name: &str) -> bool {
334 path_str.contains("api")
335 || path_str.contains("interface")
336 || path_str.contains("proto")
337 || file_name.contains("lib.rs")
338 || file_name.contains("mod.rs")
339 || file_name.contains("index")
340 || file_name.ends_with(".proto")
341 || file_name.ends_with(".graphql")
342}
343
344fn is_critical_file(path_str: &str, file_name: &str) -> bool {
346 let critical_names = [
347 "main.rs",
348 "lib.rs",
349 "index.js",
350 "app.js",
351 "main.py",
352 "__init__.py",
353 "main.go",
354 "main.java",
355 "cargo.toml",
356 "package.json",
357 "go.mod",
358 "pom.xml",
359 ];
360
361 critical_names.contains(&file_name)
362 || (path_str.contains("src") && (file_name == "lib.rs" || file_name == "main.rs"))
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368 use std::path::Path;
369
370 #[test]
373 fn purpose_config_toml() {
374 assert!(matches!(
375 determine_file_purpose(Path::new("Cargo.toml")),
376 FilePurpose::Config
377 ));
378 }
379
380 #[test]
381 fn purpose_config_json() {
382 assert!(matches!(
383 determine_file_purpose(Path::new("package.json")),
384 FilePurpose::Config
385 ));
386 }
387
388 #[test]
389 fn purpose_test_file() {
390 assert!(matches!(
391 determine_file_purpose(Path::new("tests/integration_test.rs")),
392 FilePurpose::Test
393 ));
394 }
395
396 #[test]
397 fn purpose_documentation() {
398 assert!(matches!(
399 determine_file_purpose(Path::new("README.md")),
400 FilePurpose::Documentation
401 ));
402 }
403
404 #[test]
405 fn purpose_build_file() {
406 assert!(matches!(
407 determine_file_purpose(Path::new("scripts/build.sh")),
408 FilePurpose::Build
409 ));
410 }
411
412 #[test]
413 fn purpose_interface_file() {
414 assert!(matches!(
415 determine_file_purpose(Path::new("src/api/handler.rs")),
416 FilePurpose::Interface
417 ));
418 }
419
420 #[test]
421 fn purpose_core_logic_default() {
422 assert!(matches!(
423 determine_file_purpose(Path::new("src/claude/prompts.rs")),
424 FilePurpose::CoreLogic
425 ));
426 }
427
428 #[test]
431 fn layer_config_is_infrastructure() {
432 let layer = determine_architectural_layer(Path::new("Cargo.toml"), &FilePurpose::Config);
433 assert_eq!(layer, ArchitecturalLayer::Infrastructure);
434 }
435
436 #[test]
437 fn layer_test_is_cross() {
438 let layer = determine_architectural_layer(Path::new("tests/test.rs"), &FilePurpose::Test);
439 assert_eq!(layer, ArchitecturalLayer::Cross);
440 }
441
442 #[test]
443 fn layer_interface_is_presentation() {
444 let layer =
445 determine_architectural_layer(Path::new("src/api/mod.rs"), &FilePurpose::Interface);
446 assert_eq!(layer, ArchitecturalLayer::Presentation);
447 }
448
449 #[test]
450 fn layer_cli_is_presentation() {
451 let layer =
452 determine_architectural_layer(Path::new("src/cli/git.rs"), &FilePurpose::CoreLogic);
453 assert_eq!(layer, ArchitecturalLayer::Presentation);
454 }
455
456 #[test]
457 fn layer_data_is_data() {
458 let layer =
459 determine_architectural_layer(Path::new("src/data/check.rs"), &FilePurpose::CoreLogic);
460 assert_eq!(layer, ArchitecturalLayer::Data);
461 }
462
463 #[test]
464 fn layer_core_is_business() {
465 let layer =
466 determine_architectural_layer(Path::new("src/core/engine.rs"), &FilePurpose::CoreLogic);
467 assert_eq!(layer, ArchitecturalLayer::Business);
468 }
469
470 #[test]
471 fn layer_unknown_defaults_business() {
472 let layer = determine_architectural_layer(
473 Path::new("src/claude/prompts.rs"),
474 &FilePurpose::CoreLogic,
475 );
476 assert_eq!(layer, ArchitecturalLayer::Business);
477 }
478
479 #[test]
482 fn impact_added_is_additive() {
483 assert!(matches!(
484 determine_change_impact("A", &FilePurpose::CoreLogic),
485 ChangeImpact::Additive
486 ));
487 }
488
489 #[test]
490 fn impact_deleted_interface_is_breaking() {
491 assert!(matches!(
492 determine_change_impact("D", &FilePurpose::Interface),
493 ChangeImpact::Breaking
494 ));
495 }
496
497 #[test]
498 fn impact_deleted_test_is_modification() {
499 assert!(matches!(
500 determine_change_impact("D", &FilePurpose::Test),
501 ChangeImpact::Modification
502 ));
503 }
504
505 #[test]
506 fn impact_modified_test_is_style() {
507 assert!(matches!(
508 determine_change_impact("M", &FilePurpose::Test),
509 ChangeImpact::Style
510 ));
511 }
512
513 #[test]
514 fn impact_modified_core_is_modification() {
515 assert!(matches!(
516 determine_change_impact("M", &FilePurpose::CoreLogic),
517 ChangeImpact::Modification
518 ));
519 }
520
521 #[test]
522 fn impact_unknown_type_is_modification() {
523 assert!(matches!(
524 determine_change_impact("R", &FilePurpose::CoreLogic),
525 ChangeImpact::Modification
526 ));
527 }
528
529 #[test]
532 fn significance_main_rs_is_critical() {
533 assert!(matches!(
534 determine_project_significance(Path::new("src/main.rs"), &FilePurpose::CoreLogic),
535 ProjectSignificance::Critical
536 ));
537 }
538
539 #[test]
540 fn significance_cargo_toml_is_critical() {
541 assert!(matches!(
542 determine_project_significance(Path::new("Cargo.toml"), &FilePurpose::Config),
543 ProjectSignificance::Critical
544 ));
545 }
546
547 #[test]
548 fn significance_core_logic_is_important() {
549 assert!(matches!(
550 determine_project_significance(
551 Path::new("src/claude/prompts.rs"),
552 &FilePurpose::CoreLogic
553 ),
554 ProjectSignificance::Important
555 ));
556 }
557
558 #[test]
559 fn significance_test_is_routine() {
560 assert!(matches!(
561 determine_project_significance(Path::new("tests/test.rs"), &FilePurpose::Test),
562 ProjectSignificance::Routine
563 ));
564 }
565
566 #[test]
569 fn test_file_detected() {
570 assert!(is_test_file("tests/integration.rs", "integration.rs"));
571 assert!(is_test_file("src/foo_test.rs", "foo_test.rs"));
572 assert!(!is_test_file("src/main.rs", "main.rs"));
573 }
574
575 #[test]
576 fn documentation_file_detected() {
577 assert!(is_documentation_file("README.md", "readme.md"));
578 assert!(is_documentation_file("docs/guide.md", "guide.md"));
579 assert!(!is_documentation_file("src/main.rs", "main.rs"));
580 }
581
582 #[test]
583 fn build_file_detected() {
584 assert!(is_build_file("scripts/deploy.sh", "deploy.sh"));
585 assert!(is_build_file("Makefile", "makefile"));
586 assert!(!is_build_file("src/main.rs", "main.rs"));
587 }
588
589 #[test]
590 fn interface_file_detected() {
591 assert!(is_interface_file("src/api/routes.rs", "routes.rs"));
592 assert!(is_interface_file("protos/service.proto", "service.proto"));
593 assert!(!is_interface_file("src/claude/prompts.rs", "prompts.rs"));
594 }
595
596 #[test]
599 fn analyze_file_rust_source() {
600 let ctx = FileAnalyzer::analyze_file(Path::new("src/claude/prompts.rs"), "M");
601 assert!(matches!(ctx.file_purpose, FilePurpose::CoreLogic));
602 assert!(matches!(ctx.change_impact, ChangeImpact::Modification));
603 assert!(matches!(
604 ctx.project_significance,
605 ProjectSignificance::Important
606 ));
607 }
608
609 #[test]
610 fn analyze_file_set_multiple() {
611 let files = vec![
612 (PathBuf::from("src/main.rs"), "M".to_string()),
613 (PathBuf::from("README.md"), "M".to_string()),
614 ];
615 let contexts = FileAnalyzer::analyze_file_set(&files);
616 assert_eq!(contexts.len(), 2);
617 }
618
619 #[test]
620 fn primary_architectural_impact_mixed() {
621 let contexts = vec![
622 FileAnalyzer::analyze_file(Path::new("src/data/check.rs"), "M"),
623 FileAnalyzer::analyze_file(Path::new("src/data/yaml.rs"), "M"),
624 FileAnalyzer::analyze_file(Path::new("README.md"), "M"),
625 ];
626 let layer = FileAnalyzer::primary_architectural_impact(&contexts);
627 assert_eq!(layer, ArchitecturalLayer::Data);
628 }
629
630 #[test]
631 fn primary_architectural_impact_empty() {
632 let layer = FileAnalyzer::primary_architectural_impact(&[]);
633 assert_eq!(layer, ArchitecturalLayer::Cross);
634 }
635
636 #[test]
637 fn is_architectural_change_critical_files() {
638 let contexts = vec![FileAnalyzer::analyze_file(Path::new("src/main.rs"), "D")];
639 assert!(FileAnalyzer::is_architectural_change(&contexts));
640 }
641
642 #[test]
643 fn is_architectural_change_many_files() {
644 let contexts: Vec<_> = (0..11)
645 .map(|i| FileAnalyzer::analyze_file(Path::new(&format!("src/file{i}.rs")), "M"))
646 .collect();
647 assert!(FileAnalyzer::is_architectural_change(&contexts));
648 }
649
650 #[test]
651 fn is_not_architectural_change_small() {
652 let contexts = vec![FileAnalyzer::analyze_file(
653 Path::new("src/claude/prompts.rs"),
654 "M",
655 )];
656 assert!(!FileAnalyzer::is_architectural_change(&contexts));
657 }
658
659 mod analyze_commits_tests {
662 use super::*;
663 use crate::git::commit::{CommitAnalysis, FileChange, FileChanges};
664
665 fn make_commit(files: Vec<(&str, &str)>) -> CommitInfo {
666 CommitInfo {
667 hash: "a".repeat(40),
668 author: "Test <test@test.com>".to_string(),
669 date: chrono::Utc::now().fixed_offset(),
670 original_message: "test commit".to_string(),
671 in_main_branches: Vec::new(),
672 analysis: CommitAnalysis {
673 detected_type: String::new(),
674 detected_scope: String::new(),
675 proposed_message: String::new(),
676 file_changes: FileChanges {
677 total_files: files.len(),
678 files_added: files.iter().filter(|(s, _)| *s == "A").count(),
679 files_deleted: files.iter().filter(|(s, _)| *s == "D").count(),
680 file_list: files
681 .into_iter()
682 .map(|(status, file)| FileChange {
683 status: status.to_string(),
684 file: file.to_string(),
685 })
686 .collect(),
687 },
688 diff_summary: String::new(),
689 diff_file: String::new(),
690 file_diffs: Vec::new(),
691 },
692 }
693 }
694
695 #[test]
696 fn empty_commits() {
697 let result = FileAnalyzer::analyze_commits(&[]);
698 assert!(result.is_empty());
699 }
700
701 #[test]
702 fn single_commit() {
703 let commit = make_commit(vec![("M", "src/main.rs"), ("A", "src/new.rs")]);
704 let result = FileAnalyzer::analyze_commits(&[commit]);
705 assert_eq!(result.len(), 2);
706 }
707
708 #[test]
709 fn deduplicates_across_commits() {
710 let commits = vec![
711 make_commit(vec![("A", "src/feature.rs"), ("M", "src/lib.rs")]),
712 make_commit(vec![("M", "src/feature.rs"), ("M", "src/main.rs")]),
713 ];
714 let result = FileAnalyzer::analyze_commits(&commits);
715 assert_eq!(result.len(), 3);
717 }
718
719 #[test]
720 fn last_status_wins() {
721 let commits = vec![
722 make_commit(vec![("A", "src/feature.rs")]),
723 make_commit(vec![("M", "src/feature.rs")]),
724 ];
725 let result = FileAnalyzer::analyze_commits(&commits);
726 assert_eq!(result.len(), 1);
727 assert!(matches!(
729 result[0].change_impact,
730 ChangeImpact::Modification
731 ));
732 }
733 }
734
735 mod prop {
738 use super::*;
739 use proptest::prelude::*;
740
741 fn arb_file_purpose() -> impl Strategy<Value = FilePurpose> {
742 prop_oneof![
743 Just(FilePurpose::Config),
744 Just(FilePurpose::Test),
745 Just(FilePurpose::Documentation),
746 Just(FilePurpose::Build),
747 Just(FilePurpose::Tooling),
748 Just(FilePurpose::Interface),
749 Just(FilePurpose::CoreLogic),
750 ]
751 }
752
753 proptest! {
754 #[test]
755 fn file_purpose_deterministic(s in "[a-zA-Z0-9_/\\.]{0,100}") {
756 let p = Path::new(&s);
757 let a = format!("{:?}", determine_file_purpose(p));
758 let b = format!("{:?}", determine_file_purpose(p));
759 prop_assert_eq!(a, b);
760 }
761
762 #[test]
763 fn config_extensions_classified(
764 name in "[a-z]{1,10}",
765 ext in prop_oneof![
766 Just(".toml"),
767 Just(".json"),
768 Just(".yaml"),
769 Just(".yml"),
770 Just(".ini"),
771 Just(".cfg"),
772 ],
773 ) {
774 let path_str = format!("{name}{ext}");
775 let purpose = determine_file_purpose(Path::new(&path_str));
776 prop_assert!(matches!(purpose, FilePurpose::Config));
777 }
778
779 #[test]
780 fn test_paths_classified(name in "[a-z_]{1,20}\\.rs") {
781 let path_str = format!("tests/{name}");
782 let purpose = determine_file_purpose(Path::new(&path_str));
783 prop_assert!(matches!(purpose, FilePurpose::Test));
784 }
785
786 #[test]
787 fn change_impact_added_always_additive(purpose in arb_file_purpose()) {
788 let impact = determine_change_impact("A", &purpose);
789 prop_assert!(matches!(impact, ChangeImpact::Additive));
790 }
791
792 #[test]
793 fn architectural_layer_deterministic(
794 s in "[a-zA-Z0-9_/\\.]{0,100}",
795 purpose in arb_file_purpose(),
796 ) {
797 let p = Path::new(&s);
798 let a = format!("{:?}", determine_architectural_layer(p, &purpose));
799 let b = format!("{:?}", determine_architectural_layer(p, &purpose));
800 prop_assert_eq!(a, b);
801 }
802 }
803 }
804}