1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::config::Config;
6use crate::graph::DependencyGraph;
7use crate::metrics_report::{ClassificationCoverage, DependencyDepthMetrics, MetricsReport};
8use crate::types::{
9 ArchLayer, ArchitectureMode, Component, ComponentKind, Severity, Violation, ViolationKind,
10};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ServiceAnalysisResult {
15 pub service_name: String,
16 pub result: AnalysisResult,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct MultiServiceResult {
22 pub services: Vec<ServiceAnalysisResult>,
23 pub aggregate: AnalysisResult,
24 pub shared_modules: Vec<SharedModule>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct SharedModule {
30 pub path: String,
31 pub used_by: Vec<String>,
32}
33
34pub fn aggregate_results(services: &[ServiceAnalysisResult]) -> AnalysisResult {
36 if services.is_empty() {
37 return AnalysisResult {
38 score: ArchitectureScore {
39 overall: 100.0,
40 layer_isolation: 100.0,
41 dependency_direction: 100.0,
42 interface_coverage: 100.0,
43 },
44 violations: vec![],
45 component_count: 0,
46 dependency_count: 0,
47 metrics: None,
48 };
49 }
50
51 let total_components: usize = services.iter().map(|s| s.result.component_count).sum();
52 let total_deps: usize = services.iter().map(|s| s.result.dependency_count).sum();
53
54 let mut overall = 0.0f64;
56 let mut layer_isolation = 0.0f64;
57 let mut dependency_direction = 0.0f64;
58 let mut interface_coverage = 0.0f64;
59
60 if total_components > 0 {
61 for s in services {
62 let weight = s.result.component_count as f64 / total_components as f64;
63 overall += s.result.score.overall * weight;
64 layer_isolation += s.result.score.layer_isolation * weight;
65 dependency_direction += s.result.score.dependency_direction * weight;
66 interface_coverage += s.result.score.interface_coverage * weight;
67 }
68 }
69
70 let all_violations: Vec<_> = services
71 .iter()
72 .flat_map(|s| s.result.violations.clone())
73 .collect();
74
75 AnalysisResult {
76 score: ArchitectureScore {
77 overall,
78 layer_isolation,
79 dependency_direction,
80 interface_coverage,
81 },
82 violations: all_violations,
83 component_count: total_components,
84 dependency_count: total_deps,
85 metrics: None,
86 }
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct ArchitectureScore {
92 pub overall: f64,
93 pub layer_isolation: f64,
94 pub dependency_direction: f64,
95 pub interface_coverage: f64,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct AnalysisResult {
101 pub score: ArchitectureScore,
102 pub violations: Vec<Violation>,
103 pub component_count: usize,
104 pub dependency_count: usize,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub metrics: Option<MetricsReport>,
107}
108
109pub fn calculate_score(graph: &DependencyGraph, config: &Config) -> ArchitectureScore {
111 let layer_isolation = calculate_layer_isolation(graph);
112 let dependency_direction = calculate_dependency_direction(graph);
113 let interface_coverage = calculate_interface_coverage(graph);
114
115 let w = &config.scoring;
116 let overall = layer_isolation * w.layer_isolation_weight
117 + dependency_direction * w.dependency_direction_weight
118 + interface_coverage * w.interface_coverage_weight;
119
120 let overall = overall.clamp(0.0, 100.0);
122
123 ArchitectureScore {
124 overall,
125 layer_isolation,
126 dependency_direction,
127 interface_coverage,
128 }
129}
130
131pub fn detect_violations(graph: &DependencyGraph, config: &Config) -> Vec<Violation> {
133 let mut violations = Vec::new();
134
135 detect_layer_violations(graph, config, &mut violations);
137
138 detect_circular_dependencies(graph, config, &mut violations);
140
141 detect_pattern_violations(graph, config, &mut violations);
143
144 detect_init_violations(graph, config, &mut violations);
146
147 if !config.rules.custom_rules.is_empty() {
149 match crate::custom_rules::compile_rules(&config.rules.custom_rules) {
150 Ok(compiled) => {
151 let custom_violations =
152 crate::custom_rules::evaluate_custom_rules(graph, &compiled);
153 violations.extend(custom_violations);
154 }
155 Err(e) => {
156 eprintln!("Warning: failed to compile custom rules: {e:#}");
157 }
158 }
159 }
160
161 violations
162}
163
164fn detect_layer_violations(
165 graph: &DependencyGraph,
166 config: &Config,
167 violations: &mut Vec<Violation>,
168) {
169 let severity = config
170 .rules
171 .severities
172 .get("layer_boundary")
173 .copied()
174 .unwrap_or(Severity::Error);
175
176 for (src, tgt, edge) in graph.edges_with_nodes() {
177 if src.is_cross_cutting || tgt.is_cross_cutting {
178 continue;
179 }
180
181 if src.architecture_mode == ArchitectureMode::ServiceOriented {
183 continue;
184 }
185
186 let (Some(from_layer), Some(to_layer)) = (src.layer, tgt.layer) else {
187 continue;
188 };
189
190 if from_layer.violates_dependency_on(&to_layer) {
191 let import_detail = edge
192 .import_path
193 .as_deref()
194 .map(|p| format!(" (import: {p})"))
195 .unwrap_or_default();
196
197 violations.push(Violation {
198 kind: ViolationKind::LayerBoundary {
199 from_layer,
200 to_layer,
201 },
202 severity,
203 location: edge.location.clone(),
204 message: format!(
205 "{} layer depends on {} layer{import_detail}",
206 from_layer, to_layer
207 ),
208 suggestion: Some(format!(
209 "The {from_layer} layer should not depend on the {to_layer} layer. \
210 Consider introducing a port interface in the {from_layer} layer \
211 and an adapter in the {to_layer} layer."
212 )),
213 });
214 }
215 }
216}
217
218fn detect_circular_dependencies(
219 graph: &DependencyGraph,
220 config: &Config,
221 violations: &mut Vec<Violation>,
222) {
223 let severity = config
224 .rules
225 .severities
226 .get("circular_dependency")
227 .copied()
228 .unwrap_or(Severity::Error);
229
230 let all_nodes = graph.nodes();
231 for cycle in graph.find_cycles() {
232 let cycle_str = cycle
233 .iter()
234 .map(|c| c.0.as_str())
235 .collect::<Vec<_>>()
236 .join(" -> ");
237 let location = cycle
239 .first()
240 .and_then(|id| all_nodes.iter().find(|n| &n.id == id))
241 .map(|n| n.location.clone())
242 .unwrap_or_default();
243 violations.push(Violation {
244 kind: ViolationKind::CircularDependency {
245 cycle: cycle.clone(),
246 },
247 severity,
248 location,
249 message: format!("Circular dependency detected: {cycle_str}"),
250 suggestion: Some(
251 "Break the cycle by introducing an interface or reorganizing dependencies."
252 .to_string(),
253 ),
254 });
255 }
256}
257
258const INFRA_KEYWORDS: &[&str] = &[
260 "postgres",
261 "mysql",
262 "redis",
263 "mongo",
264 "database",
265 "sql",
266 "db",
267 "dynamodb",
268 "sqlite",
269 "cassandra",
270 "elasticsearch",
271];
272
273fn detect_pattern_violations(
274 graph: &DependencyGraph,
275 config: &Config,
276 violations: &mut Vec<Violation>,
277) {
278 let severity = config
279 .rules
280 .severities
281 .get("missing_port")
282 .copied()
283 .unwrap_or(Severity::Warning);
284
285 let nodes = graph.nodes();
286
287 let port_names: Vec<String> = nodes
289 .iter()
290 .filter(|n| {
291 let name_lower = n.name.to_lowercase();
292 name_lower.contains("port")
293 || name_lower.contains("interface")
294 || name_lower.contains("repository") && n.layer == Some(ArchLayer::Domain)
295 })
296 .map(|n| n.name.clone())
297 .collect();
298
299 for node in &nodes {
301 let name_lower = node.name.to_lowercase();
302 let is_adapter = name_lower.ends_with("handler")
303 || name_lower.ends_with("controller")
304 || (node.layer == Some(ArchLayer::Infrastructure)
305 && (name_lower.contains("adapter") || name_lower.contains("impl")));
306
307 if !is_adapter {
308 continue;
309 }
310
311 let has_port = port_names.iter().any(|port| {
313 let port_lower = port.to_lowercase();
314 let adapter_base = name_lower
316 .trim_end_matches("handler")
317 .trim_end_matches("controller")
318 .trim_end_matches("adapter")
319 .trim_end_matches("impl");
320 let port_base = port_lower
321 .trim_end_matches("port")
322 .trim_end_matches("interface")
323 .trim_end_matches("repository");
324 !adapter_base.is_empty() && !port_base.is_empty() && adapter_base == port_base
325 });
326
327 if !has_port {
328 violations.push(Violation {
329 kind: ViolationKind::MissingPort {
330 adapter_name: node.name.clone(),
331 },
332 severity,
333 location: node.location.clone(),
334 message: format!(
335 "Adapter '{}' has no matching port interface",
336 node.name
337 ),
338 suggestion: Some(
339 "Create a port interface that this adapter implements to maintain proper boundaries."
340 .to_string(),
341 ),
342 });
343 }
344 }
345
346 for (src, _tgt, edge) in graph.edges_with_nodes() {
348 if src.is_cross_cutting {
349 continue;
350 }
351 if src.architecture_mode == ArchitectureMode::ActiveRecord {
353 continue;
354 }
355 if src.layer != Some(ArchLayer::Domain) {
356 continue;
357 }
358
359 if let Some(ref import_path) = edge.import_path {
360 let path_lower = import_path.to_lowercase();
361 if INFRA_KEYWORDS.iter().any(|kw| path_lower.contains(kw)) {
362 violations.push(Violation {
363 kind: ViolationKind::DomainInfrastructureLeak {
364 detail: format!("domain imports infrastructure path: {import_path}"),
365 },
366 severity: Severity::Error,
367 location: edge.location.clone(),
368 message: format!(
369 "Domain layer directly imports infrastructure dependency '{import_path}'"
370 ),
371 suggestion: Some(
372 "Domain should not reference infrastructure directly. \
373 Use a repository interface (port) in the domain layer instead."
374 .to_string(),
375 ),
376 });
377 }
378 }
379 }
380
381 for (src, tgt, edge) in graph.edges_with_nodes() {
383 if src.is_cross_cutting || tgt.is_cross_cutting {
384 continue;
385 }
386 if src.architecture_mode == ArchitectureMode::ActiveRecord {
388 continue;
389 }
390 if src.layer == Some(ArchLayer::Domain) && tgt.layer == Some(ArchLayer::Infrastructure) {
391 let tgt_lower = tgt.name.to_lowercase();
394 if tgt_lower.contains("postgres")
395 || tgt_lower.contains("mysql")
396 || tgt_lower.contains("redis")
397 || tgt_lower.contains("mongo")
398 {
399 violations.push(Violation {
400 kind: ViolationKind::DomainInfrastructureLeak {
401 detail: format!(
402 "domain entity depends on concrete infrastructure: {}",
403 tgt.name
404 ),
405 },
406 severity: Severity::Error,
407 location: edge.location.clone(),
408 message: format!(
409 "Domain component '{}' directly depends on infrastructure component '{}'",
410 src.name, tgt.name
411 ),
412 suggestion: Some(
413 "Introduce a repository interface in the domain layer and have the \
414 infrastructure component implement it."
415 .to_string(),
416 ),
417 });
418 }
419 }
420 }
421}
422
423fn detect_init_violations(
424 graph: &DependencyGraph,
425 config: &Config,
426 violations: &mut Vec<Violation>,
427) {
428 if !config.rules.detect_init_functions {
429 return;
430 }
431
432 let severity = config
433 .rules
434 .severities
435 .get("init_coupling")
436 .copied()
437 .unwrap_or(Severity::Warning);
438
439 for (src, tgt, edge) in graph.edges_with_nodes() {
440 if !src.id.0.contains("<init>") {
442 continue;
443 }
444
445 if src.is_cross_cutting || tgt.is_cross_cutting {
446 continue;
447 }
448
449 let (Some(from_layer), Some(to_layer)) = (src.layer, tgt.layer) else {
450 continue;
451 };
452
453 if from_layer.violates_dependency_on(&to_layer) {
454 let init_file = edge.location.file.to_string_lossy().to_string();
455 let called_package = tgt.id.0.clone();
456
457 violations.push(Violation {
458 kind: ViolationKind::InitFunctionCoupling {
459 init_file: init_file.clone(),
460 called_package: called_package.clone(),
461 from_layer,
462 to_layer,
463 },
464 severity,
465 location: edge.location.clone(),
466 message: format!(
467 "init() function in {from_layer} layer calls into {to_layer} layer ({called_package})"
468 ),
469 suggestion: Some(
470 "Move initialization logic out of init() or use dependency injection to avoid hidden cross-layer coupling."
471 .to_string(),
472 ),
473 });
474 }
475 }
476}
477
478fn calculate_layer_isolation(graph: &DependencyGraph) -> f64 {
482 let edges = graph.edges_with_nodes();
483 if edges.is_empty() {
484 return 100.0;
485 }
486
487 let mut total = 0u64;
488 let mut correct = 0u64;
489
490 for (src, tgt, _) in &edges {
491 if src.is_cross_cutting || tgt.is_cross_cutting {
492 continue;
493 }
494 if src.architecture_mode == ArchitectureMode::ServiceOriented {
496 continue;
497 }
498 match (src.layer, tgt.layer) {
499 (Some(from_layer), Some(to_layer)) => {
500 if from_layer == to_layer {
501 continue;
503 }
504 total += 1;
505 if !from_layer.violates_dependency_on(&to_layer) {
506 correct += 1;
507 }
508 }
509 _ => {
510 total += 1;
512 }
513 }
514 }
515
516 if total == 0 {
517 return 100.0;
518 }
519 (correct as f64 / total as f64) * 100.0
520}
521
522fn calculate_dependency_direction(graph: &DependencyGraph) -> f64 {
526 let edges = graph.edges_with_nodes();
527 if edges.is_empty() {
528 return 100.0;
529 }
530
531 let non_cross_cutting: Vec<_> = edges
532 .iter()
533 .filter(|(src, tgt, _)| {
534 !src.is_cross_cutting
535 && !tgt.is_cross_cutting
536 && src.architecture_mode != ArchitectureMode::ServiceOriented
537 })
538 .collect();
539
540 if non_cross_cutting.is_empty() {
541 return 100.0;
542 }
543
544 let correct = non_cross_cutting
545 .iter()
546 .filter(|(src, tgt, _)| match (src.layer, tgt.layer) {
547 (Some(from), Some(to)) => !from.violates_dependency_on(&to),
548 _ => false, })
550 .count();
551
552 (correct as f64 / non_cross_cutting.len() as f64) * 100.0
553}
554
555fn calculate_interface_coverage(graph: &DependencyGraph) -> f64 {
557 let nodes = graph.nodes();
558 if nodes.is_empty() {
559 return 100.0;
560 }
561
562 let mut ports = 0u64;
564 let mut adapters = 0u64;
565
566 for node in &nodes {
567 let name_lower = node.name.to_lowercase();
569 if name_lower.contains("port")
570 || name_lower.contains("interface")
571 || (node.layer == Some(ArchLayer::Domain) && name_lower.ends_with("er"))
572 {
573 ports += 1;
574 }
575 if node.layer == Some(ArchLayer::Infrastructure) {
576 adapters += 1;
577 }
578 }
579
580 if adapters == 0 {
581 return 100.0;
582 }
583
584 let ratio = (ports as f64 / adapters as f64).min(1.0);
586 ratio * 100.0
587}
588
589pub fn build_result(
591 graph: &DependencyGraph,
592 config: &Config,
593 dep_count: usize,
594 components: &[Component],
595) -> AnalysisResult {
596 let score = calculate_score(graph, config);
597 let violations = detect_violations(graph, config);
598
599 let metrics = compute_metrics(graph, components, &violations);
600
601 AnalysisResult {
602 score,
603 violations,
604 component_count: graph.node_count(),
605 dependency_count: dep_count,
606 metrics: Some(metrics),
607 }
608}
609
610fn compute_metrics(
611 graph: &DependencyGraph,
612 components: &[Component],
613 violations: &[Violation],
614) -> MetricsReport {
615 let mut components_by_kind: HashMap<String, usize> = HashMap::new();
617 for comp in components {
618 let kind_name = match &comp.kind {
619 ComponentKind::Port(_) => "port",
620 ComponentKind::Adapter(_) => "adapter",
621 ComponentKind::Entity(_) => "entity",
622 ComponentKind::ValueObject => "value_object",
623 ComponentKind::UseCase => "use_case",
624 ComponentKind::Repository => "repository",
625 ComponentKind::Service => "service",
626 ComponentKind::DomainEvent(_) => "domain_event",
627 };
628 *components_by_kind.entry(kind_name.to_string()).or_insert(0) += 1;
629 }
630
631 let components_by_layer = graph.nodes_by_layer();
633
634 let mut violations_by_kind: HashMap<String, usize> = HashMap::new();
636 for v in violations {
637 let kind_name = match &v.kind {
638 ViolationKind::LayerBoundary { .. } => "layer_boundary",
639 ViolationKind::CircularDependency { .. } => "circular_dependency",
640 ViolationKind::MissingPort { .. } => "missing_port",
641 ViolationKind::CustomRule { .. } => "custom_rule",
642 ViolationKind::DomainInfrastructureLeak { .. } => "domain_infrastructure_leak",
643 ViolationKind::InitFunctionCoupling { .. } => "init_coupling",
644 };
645 *violations_by_kind.entry(kind_name.to_string()).or_insert(0) += 1;
646 }
647
648 let max_depth = graph.max_dependency_depth();
650 let node_count = graph.node_count();
651 let avg_depth = if node_count > 0 {
652 max_depth as f64 / node_count as f64
653 } else {
654 0.0
655 };
656
657 let layer_coupling = graph.layer_coupling_matrix();
659
660 let classification_coverage = compute_classification_coverage(graph);
662
663 MetricsReport {
664 components_by_kind,
665 components_by_layer,
666 violations_by_kind,
667 dependency_depth: DependencyDepthMetrics {
668 max_depth,
669 avg_depth,
670 },
671 layer_coupling,
672 classification_coverage: Some(classification_coverage),
673 }
674}
675
676fn compute_classification_coverage(graph: &DependencyGraph) -> ClassificationCoverage {
677 let nodes = graph.nodes();
678 let total_components = nodes.len();
679
680 let mut classified = 0usize;
681 let mut cross_cutting = 0usize;
682 let mut unclassified = 0usize;
683 let mut unclassified_dirs: Vec<String> = Vec::new();
684
685 for node in &nodes {
686 if node.is_cross_cutting {
687 cross_cutting += 1;
688 } else if node.layer.is_some() {
689 classified += 1;
690 } else {
691 unclassified += 1;
692 let id = &node.id.0;
694 if let Some(dir) = id.rsplit_once("::").map(|(pkg, _)| pkg.to_string()) {
695 if !unclassified_dirs.contains(&dir) {
696 unclassified_dirs.push(dir);
697 }
698 }
699 }
700 }
701
702 unclassified_dirs.sort();
704 unclassified_dirs.truncate(10);
705
706 let coverage_percentage = if total_components > 0 {
707 ((classified + cross_cutting) as f64 / total_components as f64) * 100.0
708 } else {
709 100.0
710 };
711
712 ClassificationCoverage {
713 total_components,
714 classified,
715 cross_cutting,
716 unclassified,
717 coverage_percentage,
718 unclassified_paths: unclassified_dirs,
719 }
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725 use crate::config::Config;
726 use crate::graph::DependencyGraph;
727 use crate::types::*;
728 use std::path::PathBuf;
729
730 fn make_component(id: &str, name: &str, layer: Option<ArchLayer>) -> Component {
731 Component {
732 id: ComponentId(id.to_string()),
733 name: name.to_string(),
734 kind: ComponentKind::Entity(EntityInfo {
735 name: name.to_string(),
736 fields: vec![],
737 methods: vec![],
738 is_active_record: false,
739 }),
740 layer,
741 location: SourceLocation {
742 file: PathBuf::from("test.go"),
743 line: 1,
744 column: 1,
745 },
746 is_cross_cutting: false,
747 architecture_mode: ArchitectureMode::Ddd,
748 }
749 }
750
751 fn make_dep(from: &str, to: &str) -> Dependency {
752 Dependency {
753 from: ComponentId(from.to_string()),
754 to: ComponentId(to.to_string()),
755 kind: DependencyKind::Import,
756 location: SourceLocation {
757 file: PathBuf::from("test.go"),
758 line: 10,
759 column: 1,
760 },
761 import_path: Some("some/import".to_string()),
762 }
763 }
764
765 #[test]
766 fn test_perfect_score_no_violations() {
767 let mut graph = DependencyGraph::new();
768 let c1 = make_component("infra", "InfraService", Some(ArchLayer::Infrastructure));
770 let c2 = make_component("domain", "DomainEntity", Some(ArchLayer::Domain));
771 graph.add_component(&c1);
772 graph.add_component(&c2);
773 graph.add_dependency(&make_dep("infra", "domain"));
774
775 let config = Config::default();
776 let score = calculate_score(&graph, &config);
777
778 assert_eq!(score.layer_isolation, 100.0);
779 assert_eq!(score.dependency_direction, 100.0);
780
781 let violations = detect_violations(&graph, &config);
782 assert!(
783 violations.is_empty(),
784 "no violations for correct dependency"
785 );
786 }
787
788 #[test]
789 fn test_violation_domain_to_infrastructure() {
790 let mut graph = DependencyGraph::new();
791 let c1 = make_component("domain", "Entity", Some(ArchLayer::Domain));
792 let c2 = make_component("infra", "Repo", Some(ArchLayer::Infrastructure));
793 graph.add_component(&c1);
794 graph.add_component(&c2);
795 graph.add_dependency(&make_dep("domain", "infra"));
796
797 let config = Config::default();
798 let violations = detect_violations(&graph, &config);
799
800 assert_eq!(violations.len(), 1);
801 assert_eq!(violations[0].severity, Severity::Error);
802 assert!(matches!(
803 violations[0].kind,
804 ViolationKind::LayerBoundary {
805 from_layer: ArchLayer::Domain,
806 to_layer: ArchLayer::Infrastructure,
807 }
808 ));
809 }
810
811 #[test]
812 fn test_circular_dependency_detection() {
813 let mut graph = DependencyGraph::new();
814 let c1 = make_component("a", "A", Some(ArchLayer::Domain));
815 let c2 = make_component("b", "B", Some(ArchLayer::Domain));
816 graph.add_component(&c1);
817 graph.add_component(&c2);
818 graph.add_dependency(&make_dep("a", "b"));
819 graph.add_dependency(&make_dep("b", "a"));
820
821 let config = Config::default();
822 let violations = detect_violations(&graph, &config);
823
824 let circular = violations
825 .iter()
826 .filter(|v| matches!(v.kind, ViolationKind::CircularDependency { .. }))
827 .count();
828 assert!(circular > 0, "should detect circular dependency");
829 }
830
831 #[test]
832 fn test_empty_graph_perfect_score() {
833 let graph = DependencyGraph::new();
834 let config = Config::default();
835 let score = calculate_score(&graph, &config);
836 assert_eq!(score.overall, 100.0);
837 }
838
839 #[test]
840 fn test_build_result() {
841 let graph = DependencyGraph::new();
842 let config = Config::default();
843 let result = build_result(&graph, &config, 0, &[]);
844 assert_eq!(result.component_count, 0);
845 assert_eq!(result.dependency_count, 0);
846 assert!(result.violations.is_empty());
847 assert!(result.metrics.is_some());
848 }
849
850 fn make_cross_cutting_component(id: &str, name: &str, layer: Option<ArchLayer>) -> Component {
851 Component {
852 id: ComponentId(id.to_string()),
853 name: name.to_string(),
854 kind: ComponentKind::Entity(EntityInfo {
855 name: name.to_string(),
856 fields: vec![],
857 methods: vec![],
858 is_active_record: false,
859 }),
860 layer,
861 location: SourceLocation {
862 file: PathBuf::from("test.go"),
863 line: 1,
864 column: 1,
865 },
866 is_cross_cutting: true,
867 architecture_mode: ArchitectureMode::Ddd,
868 }
869 }
870
871 #[test]
872 fn test_cross_cutting_excluded_from_layer_violations() {
873 let mut graph = DependencyGraph::new();
874 let c1 = make_component("domain", "Entity", Some(ArchLayer::Domain));
876 let c2 = make_cross_cutting_component("infra", "Logger", Some(ArchLayer::Infrastructure));
877 graph.add_component(&c1);
878 graph.add_component(&c2);
879 graph.add_dependency(&make_dep("domain", "infra"));
880
881 let config = Config::default();
882 let violations = detect_violations(&graph, &config);
883
884 let layer_violations: Vec<_> = violations
885 .iter()
886 .filter(|v| matches!(v.kind, ViolationKind::LayerBoundary { .. }))
887 .collect();
888 assert!(
889 layer_violations.is_empty(),
890 "cross-cutting target should suppress layer violations"
891 );
892 }
893
894 #[test]
895 fn test_cross_cutting_source_excluded_from_violations() {
896 let mut graph = DependencyGraph::new();
897 let c1 = make_cross_cutting_component("utils", "Utils", Some(ArchLayer::Domain));
898 let c2 = make_component("infra", "Repo", Some(ArchLayer::Infrastructure));
899 graph.add_component(&c1);
900 graph.add_component(&c2);
901 graph.add_dependency(&make_dep("utils", "infra"));
902
903 let config = Config::default();
904 let violations = detect_violations(&graph, &config);
905
906 let layer_violations: Vec<_> = violations
907 .iter()
908 .filter(|v| matches!(v.kind, ViolationKind::LayerBoundary { .. }))
909 .collect();
910 assert!(
911 layer_violations.is_empty(),
912 "cross-cutting source should suppress layer violations"
913 );
914 }
915
916 #[test]
917 fn test_cross_cutting_excluded_from_layer_isolation() {
918 let mut graph = DependencyGraph::new();
919 let c1 = make_component("domain", "Entity", Some(ArchLayer::Domain));
921 let c2 = make_cross_cutting_component("infra", "Logger", Some(ArchLayer::Infrastructure));
922 graph.add_component(&c1);
923 graph.add_component(&c2);
924 graph.add_dependency(&make_dep("domain", "infra"));
925
926 let isolation = calculate_layer_isolation(&graph);
927 assert_eq!(
928 isolation, 100.0,
929 "cross-cutting edges should be excluded from isolation"
930 );
931 }
932
933 #[test]
934 fn test_cross_cutting_excluded_from_dependency_direction() {
935 let mut graph = DependencyGraph::new();
936 let c1 = make_component("domain", "Entity", Some(ArchLayer::Domain));
937 let c2 = make_cross_cutting_component("infra", "Logger", Some(ArchLayer::Infrastructure));
938 graph.add_component(&c1);
939 graph.add_component(&c2);
940 graph.add_dependency(&make_dep("domain", "infra"));
941
942 let direction = calculate_dependency_direction(&graph);
943 assert_eq!(
944 direction, 100.0,
945 "cross-cutting edges should be excluded from dependency direction"
946 );
947 }
948
949 fn make_component_with_mode(
950 id: &str,
951 name: &str,
952 layer: Option<ArchLayer>,
953 mode: ArchitectureMode,
954 ) -> Component {
955 Component {
956 id: ComponentId(id.to_string()),
957 name: name.to_string(),
958 kind: ComponentKind::Entity(EntityInfo {
959 name: name.to_string(),
960 fields: vec![],
961 methods: vec![],
962 is_active_record: false,
963 }),
964 layer,
965 location: SourceLocation {
966 file: PathBuf::from("test.go"),
967 line: 1,
968 column: 1,
969 },
970 is_cross_cutting: false,
971 architecture_mode: mode,
972 }
973 }
974
975 #[test]
976 fn test_service_oriented_suppresses_layer_violations() {
977 let mut graph = DependencyGraph::new();
978 let c1 = make_component_with_mode(
979 "domain",
980 "Entity",
981 Some(ArchLayer::Domain),
982 ArchitectureMode::ServiceOriented,
983 );
984 let c2 = make_component_with_mode(
985 "infra",
986 "Repo",
987 Some(ArchLayer::Infrastructure),
988 ArchitectureMode::ServiceOriented,
989 );
990 graph.add_component(&c1);
991 graph.add_component(&c2);
992 graph.add_dependency(&make_dep("domain", "infra"));
993
994 let config = Config::default();
995 let violations = detect_violations(&graph, &config);
996
997 let layer_violations: Vec<_> = violations
998 .iter()
999 .filter(|v| matches!(v.kind, ViolationKind::LayerBoundary { .. }))
1000 .collect();
1001 assert!(
1002 layer_violations.is_empty(),
1003 "service-oriented mode should suppress layer boundary violations"
1004 );
1005 }
1006
1007 #[test]
1008 fn test_service_oriented_excluded_from_isolation() {
1009 let mut graph = DependencyGraph::new();
1010 let c1 = make_component_with_mode(
1011 "domain",
1012 "Entity",
1013 Some(ArchLayer::Domain),
1014 ArchitectureMode::ServiceOriented,
1015 );
1016 let c2 = make_component_with_mode(
1017 "infra",
1018 "Repo",
1019 Some(ArchLayer::Infrastructure),
1020 ArchitectureMode::ServiceOriented,
1021 );
1022 graph.add_component(&c1);
1023 graph.add_component(&c2);
1024 graph.add_dependency(&make_dep("domain", "infra"));
1025
1026 let isolation = calculate_layer_isolation(&graph);
1027 assert_eq!(
1028 isolation, 100.0,
1029 "service-oriented edges should be excluded from isolation"
1030 );
1031 }
1032
1033 #[test]
1034 fn test_service_oriented_excluded_from_direction() {
1035 let mut graph = DependencyGraph::new();
1036 let c1 = make_component_with_mode(
1037 "domain",
1038 "Entity",
1039 Some(ArchLayer::Domain),
1040 ArchitectureMode::ServiceOriented,
1041 );
1042 let c2 = make_component_with_mode(
1043 "infra",
1044 "Repo",
1045 Some(ArchLayer::Infrastructure),
1046 ArchitectureMode::ServiceOriented,
1047 );
1048 graph.add_component(&c1);
1049 graph.add_component(&c2);
1050 graph.add_dependency(&make_dep("domain", "infra"));
1051
1052 let direction = calculate_dependency_direction(&graph);
1053 assert_eq!(
1054 direction, 100.0,
1055 "service-oriented edges should be excluded from dependency direction"
1056 );
1057 }
1058
1059 #[test]
1060 fn test_active_record_suppresses_domain_infra_leak() {
1061 let mut graph = DependencyGraph::new();
1062 let c1 = make_component_with_mode(
1064 "domain",
1065 "User",
1066 Some(ArchLayer::Domain),
1067 ArchitectureMode::ActiveRecord,
1068 );
1069 let c2 = make_component_with_mode(
1070 "infra",
1071 "DB",
1072 Some(ArchLayer::Infrastructure),
1073 ArchitectureMode::ActiveRecord,
1074 );
1075 graph.add_component(&c1);
1076 graph.add_component(&c2);
1077 graph.add_dependency(&make_dep("domain", "infra"));
1078
1079 let config = Config::default();
1080 let violations = detect_violations(&graph, &config);
1081
1082 let leak_violations: Vec<_> = violations
1083 .iter()
1084 .filter(|v| matches!(v.kind, ViolationKind::DomainInfrastructureLeak { .. }))
1085 .collect();
1086 assert!(
1087 leak_violations.is_empty(),
1088 "active-record mode should suppress domain-infra leak violations"
1089 );
1090 }
1091
1092 #[test]
1093 fn test_ddd_mode_still_produces_violations() {
1094 let mut graph = DependencyGraph::new();
1096 let c1 = make_component("domain", "Entity", Some(ArchLayer::Domain));
1097 let c2 = make_component("infra", "Repo", Some(ArchLayer::Infrastructure));
1098 graph.add_component(&c1);
1099 graph.add_component(&c2);
1100 graph.add_dependency(&make_dep("domain", "infra"));
1101
1102 let config = Config::default();
1103 let violations = detect_violations(&graph, &config);
1104
1105 let layer_violations: Vec<_> = violations
1106 .iter()
1107 .filter(|v| matches!(v.kind, ViolationKind::LayerBoundary { .. }))
1108 .collect();
1109 assert!(
1110 !layer_violations.is_empty(),
1111 "DDD mode should still produce layer boundary violations"
1112 );
1113 }
1114
1115 #[test]
1116 fn test_init_coupling_detected() {
1117 let mut graph = DependencyGraph::new();
1118 let c1 = make_component("app::<init>", "<init>", Some(ArchLayer::Application));
1120 let c2 = make_component("infra::db", "db", Some(ArchLayer::Infrastructure));
1121 graph.add_component(&c1);
1122 graph.add_component(&c2);
1123 graph.add_dependency(&make_dep("app::<init>", "infra::db"));
1124
1125 let config = Config::default();
1126 let violations = detect_violations(&graph, &config);
1127
1128 let init_violations: Vec<_> = violations
1129 .iter()
1130 .filter(|v| matches!(v.kind, ViolationKind::InitFunctionCoupling { .. }))
1131 .collect();
1132 assert!(
1133 !init_violations.is_empty(),
1134 "should detect init function coupling"
1135 );
1136 }
1137
1138 #[test]
1139 fn test_init_coupling_disabled_via_config() {
1140 let mut graph = DependencyGraph::new();
1141 let c1 = make_component("app::<init>", "<init>", Some(ArchLayer::Application));
1142 let c2 = make_component("infra::db", "db", Some(ArchLayer::Infrastructure));
1143 graph.add_component(&c1);
1144 graph.add_component(&c2);
1145 graph.add_dependency(&make_dep("app::<init>", "infra::db"));
1146
1147 let mut config = Config::default();
1148 config.rules.detect_init_functions = false;
1149
1150 let violations = detect_violations(&graph, &config);
1151 let init_violations: Vec<_> = violations
1152 .iter()
1153 .filter(|v| matches!(v.kind, ViolationKind::InitFunctionCoupling { .. }))
1154 .collect();
1155 assert!(
1156 init_violations.is_empty(),
1157 "init detection disabled should produce no init violations"
1158 );
1159 }
1160
1161 #[test]
1162 fn test_classification_coverage_all_classified() {
1163 let mut graph = DependencyGraph::new();
1164 let c1 = make_component("a", "A", Some(ArchLayer::Domain));
1165 let c2 = make_component("b", "B", Some(ArchLayer::Application));
1166 graph.add_component(&c1);
1167 graph.add_component(&c2);
1168
1169 let coverage = compute_classification_coverage(&graph);
1170 assert_eq!(coverage.total_components, 2);
1171 assert_eq!(coverage.classified, 2);
1172 assert_eq!(coverage.cross_cutting, 0);
1173 assert_eq!(coverage.unclassified, 0);
1174 assert!((coverage.coverage_percentage - 100.0).abs() < f64::EPSILON);
1175 }
1176
1177 #[test]
1178 fn test_classification_coverage_mixed() {
1179 let mut graph = DependencyGraph::new();
1180 let c1 = make_component("domain::Entity", "Entity", Some(ArchLayer::Domain));
1181 let c2 = make_cross_cutting_component("utils::Logger", "Logger", None);
1182 let c3 = make_component("unknown::Foo", "Foo", None);
1183 graph.add_component(&c1);
1184 graph.add_component(&c2);
1185 graph.add_component(&c3);
1186
1187 let coverage = compute_classification_coverage(&graph);
1188 assert_eq!(coverage.total_components, 3);
1189 assert_eq!(coverage.classified, 1);
1190 assert_eq!(coverage.cross_cutting, 1);
1191 assert_eq!(coverage.unclassified, 1);
1192 assert!((coverage.coverage_percentage - 66.66666666666667).abs() < 0.01);
1194 assert_eq!(coverage.unclassified_paths.len(), 1);
1195 assert_eq!(coverage.unclassified_paths[0], "unknown");
1196 }
1197}