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 description(&self) -> &str {
24        "Cross-layer dependency violation"
25    }
26
27    fn analyze(&self, ctx: &AnalysisContext) -> Vec<Finding> {
28        if self.layers.is_empty() {
29            return vec![];
30        }
31
32        let file_path = ctx.file.path.to_string_lossy();
33        let file_layer = self.layer_of(&file_path);
34
35        ctx.model
36            .imports
37            .iter()
38            .filter_map(|imp| self.check_import(ctx, &file_layer, imp))
39            .collect()
40    }
41}
42
43impl LayerViolationAnalyzer {
44    /// Check a single import against the file's layer boundary.
45    fn check_import(
46        &self,
47        ctx: &AnalysisContext,
48        file_layer: &Option<(String, u32)>,
49        imp: &crate::ImportInfo,
50    ) -> Option<Finding> {
51        let import_layer = self.layer_of(&imp.source);
52        let (_, file_level) = file_layer.as_ref()?;
53        let (imp_name, imp_level) = import_layer.as_ref()?;
54        if file_level >= imp_level {
55            return None;
56        }
57        Some(Finding {
58            smell_name: "layer_violation".into(),
59            category: SmellCategory::Couplers,
60            severity: Severity::Error,
61            location: Location {
62                path: ctx.file.path.clone(),
63                start_line: imp.line,
64                end_line: imp.line,
65                name: None,
66            },
67            message: format!(
68                "Import `{}` violates layer boundary (importing from layer `{}` into lower layer)",
69                imp.source, imp_name
70            ),
71            suggested_refactorings: vec!["Move Method".into(), "Extract Interface".into()],
72            ..Default::default()
73        })
74    }
75
76    fn layer_of(&self, path: &str) -> Option<(String, u32)> {
77        self.layers
78            .iter()
79            .find(|(prefix, _)| path.contains(prefix.as_str()))
80            .map(|(name, level)| (name.clone(), *level))
81    }
82
83    /// Parse layers from config string: "domain:0,service:1,controller:2"
84    pub fn from_config_str(s: &str) -> Self {
85        let layers = s
86            .split(',')
87            .filter_map(|part| {
88                let mut parts = part.trim().splitn(2, ':');
89                let name = parts.next()?.trim().to_string();
90                let level = parts.next()?.trim().parse().ok()?;
91                Some((name, level))
92            })
93            .collect();
94        Self { layers }
95    }
96}