Skip to main content

cha_core/plugins/
layer_violation.rs

1use crate::{AnalysisContext, Finding, Location, Plugin, Severity, SmellCategory};
2
3/// Detect imports that violate configured layer boundaries.
4/// Layers are defined as path prefixes with a numeric order.
5/// Lower layers must not import from higher layers.
6///
7/// Configure via .cha.toml:
8/// ```toml
9/// [plugins.layer_violation.options]
10/// layers = "domain:0,service:1,controller:2,ui:3"
11/// ```
12#[derive(Default)]
13pub struct LayerViolationAnalyzer {
14    /// Ordered layers: (prefix, level). Lower level = lower layer.
15    pub layers: Vec<(String, u32)>,
16}
17
18impl Plugin for LayerViolationAnalyzer {
19    fn name(&self) -> &str {
20        "layer_violation"
21    }
22
23    fn smells(&self) -> Vec<String> {
24        vec!["layer_violation".into()]
25    }
26
27    fn description(&self) -> &str {
28        "Cross-layer dependency violation"
29    }
30
31    fn analyze(&self, ctx: &AnalysisContext) -> Vec<Finding> {
32        if self.layers.is_empty() {
33            return vec![];
34        }
35
36        let file_path = ctx.file.path.to_string_lossy();
37        let file_layer = self.layer_of(&file_path);
38
39        ctx.model
40            .imports
41            .iter()
42            .filter_map(|imp| self.check_import(ctx, &file_layer, imp))
43            .collect()
44    }
45}
46
47impl LayerViolationAnalyzer {
48    /// Check a single import against the file's layer boundary.
49    fn check_import(
50        &self,
51        ctx: &AnalysisContext,
52        file_layer: &Option<(String, u32)>,
53        imp: &crate::ImportInfo,
54    ) -> Option<Finding> {
55        let import_layer = self.layer_of(&imp.source);
56        let (_, file_level) = file_layer.as_ref()?;
57        let (imp_name, imp_level) = import_layer.as_ref()?;
58        if file_level >= imp_level {
59            return None;
60        }
61        Some(Finding {
62            smell_name: "layer_violation".into(),
63            category: SmellCategory::Couplers,
64            severity: Severity::Error,
65            location: Location {
66                path: ctx.file.path.clone(),
67                start_line: imp.line,
68                start_col: imp.col,
69                end_line: imp.line,
70                name: None,
71                ..Default::default()
72            },
73            message: format!(
74                "Import `{}` violates layer boundary (importing from layer `{}` into lower layer)",
75                imp.source, imp_name
76            ),
77            suggested_refactorings: vec!["Move Method".into(), "Extract Interface".into()],
78            ..Default::default()
79        })
80    }
81
82    fn layer_of(&self, path: &str) -> Option<(String, u32)> {
83        self.layers
84            .iter()
85            .find(|(prefix, _)| path.contains(prefix.as_str()))
86            .map(|(name, level)| (name.clone(), *level))
87    }
88
89    /// Parse layers from config string: "domain:0,service:1,controller:2"
90    pub fn from_config_str(s: &str) -> Self {
91        let layers = s
92            .split(',')
93            .filter_map(|part| {
94                let mut parts = part.trim().splitn(2, ':');
95                let name = parts.next()?.trim().to_string();
96                let level = parts.next()?.trim().parse().ok()?;
97                Some((name, level))
98            })
99            .collect();
100        Self { layers }
101    }
102}