Skip to main content

boundary_core/
metrics.rs

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/// Result for a single service in a multi-service analysis.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ServiceAnalysisResult {
15    pub service_name: String,
16    pub result: AnalysisResult,
17}
18
19/// Result of analyzing a monorepo with multiple services.
20#[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/// A module shared between multiple services.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct SharedModule {
30    pub path: String,
31    pub used_by: Vec<String>,
32}
33
34/// Aggregate multiple service results into a combined result.
35pub 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    // Weighted average by component count
55    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/// Breakdown of architecture scores.
90#[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/// Full analysis result.
99#[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
109/// Calculate architecture score from the dependency graph.
110pub 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    // Clamp to 0-100
121    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
131/// Detect all violations in the dependency graph.
132pub fn detect_violations(graph: &DependencyGraph, config: &Config) -> Vec<Violation> {
133    let mut violations = Vec::new();
134
135    // Layer boundary violations
136    detect_layer_violations(graph, config, &mut violations);
137
138    // Circular dependency violations
139    detect_circular_dependencies(graph, config, &mut violations);
140
141    // Pattern violations (DDD structural checks)
142    detect_pattern_violations(graph, config, &mut violations);
143
144    // Init function coupling violations
145    detect_init_violations(graph, config, &mut violations);
146
147    // Custom rules
148    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        // Service-oriented mode skips all layer boundary checks
182        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        // Use the location of the first component in the cycle
238        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
258/// Infrastructure-related import path keywords.
259const 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    // Collect port names for adapter-without-port check
288    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    // Check 1: Adapter without port
300    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        // Check if there's a matching port name pattern
312        let has_port = port_names.iter().any(|port| {
313            let port_lower = port.to_lowercase();
314            // e.g., "UserHandler" matches "UserPort" or "UserRepository"
315            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    // Check 2: DB access in domain layer (domain importing infrastructure paths)
347    for (src, _tgt, edge) in graph.edges_with_nodes() {
348        if src.is_cross_cutting {
349            continue;
350        }
351        // ActiveRecord mode allows domain to import infrastructure
352        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    // Check 3: Domain entity directly depending on infrastructure component
382    for (src, tgt, edge) in graph.edges_with_nodes() {
383        if src.is_cross_cutting || tgt.is_cross_cutting {
384            continue;
385        }
386        // ActiveRecord mode allows domain→infrastructure
387        if src.architecture_mode == ArchitectureMode::ActiveRecord {
388            continue;
389        }
390        if src.layer == Some(ArchLayer::Domain) && tgt.layer == Some(ArchLayer::Infrastructure) {
391            // Already covered by layer boundary violations, but add specific
392            // "missing repository pattern" detail if the target looks like a concrete impl
393            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        // Only check edges from init functions (component ID contains "<init>")
441        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
478/// Layer isolation: percentage of cross-layer edges that go in the correct direction.
479/// Edges involving unclassified components count against isolation since they
480/// represent components that haven't been properly placed in a layer.
481fn 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        // Service-oriented mode is exempt from isolation scoring
495        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                    // Same-layer edges are fine, don't count them
502                    continue;
503                }
504                total += 1;
505                if !from_layer.violates_dependency_on(&to_layer) {
506                    correct += 1;
507                }
508            }
509            _ => {
510                // Edges involving unclassified components penalize isolation
511                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
522/// Dependency direction: percentage of all edges that flow in a valid direction.
523/// Edges involving unclassified components are not counted as correct — they
524/// represent unresolved architecture that needs classification.
525fn 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, // unclassified edges are not correct
549        })
550        .count();
551
552    (correct as f64 / non_cross_cutting.len() as f64) * 100.0
553}
554
555/// Interface coverage: ratio of ports to total components (higher = better separation).
556fn calculate_interface_coverage(graph: &DependencyGraph) -> f64 {
557    let nodes = graph.nodes();
558    if nodes.is_empty() {
559        return 100.0;
560    }
561
562    // Count nodes in domain layer (likely ports) vs infrastructure (adapters)
563    let mut ports = 0u64;
564    let mut adapters = 0u64;
565
566    for node in &nodes {
567        // Heuristic: names containing "Port", "Interface", or in domain layer with interface-like names
568        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    // Ideal: every adapter has a port. Score = min(ports/adapters, 1.0) * 100
585    let ratio = (ports as f64 / adapters as f64).min(1.0);
586    ratio * 100.0
587}
588
589/// Build a complete `AnalysisResult`.
590pub 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    // Components by kind
616    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    // Components by layer
632    let components_by_layer = graph.nodes_by_layer();
633
634    // Violations by kind
635    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    // Dependency depth
649    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    // Layer coupling
658    let layer_coupling = graph.layer_coupling_matrix();
659
660    // Classification coverage
661    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            // Extract parent directory from component ID
693            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    // Sort and truncate to ~10 entries
703    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        // Infrastructure -> Domain (correct direction)
769        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        // Domain -> Infrastructure would normally be a violation
875        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        // This edge would normally reduce isolation score
920        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        // A domain component that imports DB types
1063        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        // Verify DDD mode (default) still catches violations
1095        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        // init component in application layer calling infrastructure
1119        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        // (1 + 1) / 3 * 100 = 66.67
1193        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}