Skip to main content

cha_core/graph/
layers.rs

1use std::collections::{BTreeMap, HashMap, HashSet};
2
3use super::Module;
4
5/// Layer information for a module.
6#[derive(Debug, Clone)]
7pub struct LayerInfo {
8    pub name: String,
9    pub level: usize,
10    pub file_count: usize,
11    pub fan_in: usize,
12    pub fan_out: usize,
13    pub instability: f64,
14    pub lcom4: usize,
15    pub tcc: f64,
16    pub cohesion: f64,
17}
18
19/// A detected layer violation.
20#[derive(Debug, Clone)]
21pub struct LayerViolation {
22    pub from_module: String,
23    pub to_module: String,
24    pub from_level: usize,
25    pub to_level: usize,
26}
27
28/// Infer layers from modules and file-level imports.
29/// Returns (layers sorted by instability, violations).
30pub fn infer_layers(
31    modules: &[Module],
32    file_imports: &[(String, String)],
33) -> (Vec<LayerInfo>, Vec<LayerViolation>) {
34    let file_to_mod: HashMap<&str, &str> = modules
35        .iter()
36        .flat_map(|m| m.files.iter().map(|f| (f.as_str(), m.name.as_str())))
37        .collect();
38
39    let (fan_in, fan_out) = compute_module_fans(&file_to_mod, file_imports);
40    let mut layers = build_layers(modules, &fan_in, &fan_out);
41
42    layers.sort_by(|a, b| a.instability.partial_cmp(&b.instability).unwrap());
43    for (i, l) in layers.iter_mut().enumerate() {
44        l.level = i;
45    }
46
47    let violations = detect_violations(&layers, &file_to_mod, file_imports);
48    (layers, violations)
49}
50
51type FanMap<'a> = HashMap<&'a str, HashSet<&'a str>>;
52
53fn compute_module_fans<'a>(
54    file_to_mod: &HashMap<&'a str, &'a str>,
55    file_imports: &[(String, String)],
56) -> (FanMap<'a>, FanMap<'a>) {
57    let mut fan_in: HashMap<&str, HashSet<&str>> = HashMap::new();
58    let mut fan_out: HashMap<&str, HashSet<&str>> = HashMap::new();
59    for (from, to) in file_imports {
60        let fm = file_to_mod.get(from.as_str()).copied().unwrap_or("");
61        let tm = file_to_mod.get(to.as_str()).copied().unwrap_or("");
62        if !fm.is_empty() && !tm.is_empty() && fm != tm {
63            fan_out.entry(fm).or_default().insert(tm);
64            fan_in.entry(tm).or_default().insert(fm);
65        }
66    }
67    (fan_in, fan_out)
68}
69
70fn build_layers(
71    modules: &[Module],
72    fan_in: &HashMap<&str, HashSet<&str>>,
73    fan_out: &HashMap<&str, HashSet<&str>>,
74) -> Vec<LayerInfo> {
75    modules
76        .iter()
77        .map(|m| {
78            let fi = fan_in.get(m.name.as_str()).map(|s| s.len()).unwrap_or(0);
79            let fo = fan_out.get(m.name.as_str()).map(|s| s.len()).unwrap_or(0);
80            let total = fi + fo;
81            LayerInfo {
82                name: m.name.clone(),
83                level: 0,
84                file_count: m.files.len(),
85                fan_in: fi,
86                fan_out: fo,
87                instability: if total > 0 {
88                    fo as f64 / total as f64
89                } else {
90                    0.5
91                },
92                lcom4: m.lcom4,
93                tcc: m.tcc,
94                cohesion: m.cohesion,
95            }
96        })
97        .collect()
98}
99
100fn is_parent_child(a: &str, b: &str) -> bool {
101    let a = a.trim_end_matches("/*").trim_end_matches('/');
102    let b = b.trim_end_matches("/*").trim_end_matches('/');
103    a.starts_with(&format!("{b}/")) || b.starts_with(&format!("{a}/"))
104}
105
106fn detect_violations(
107    layers: &[LayerInfo],
108    file_to_mod: &HashMap<&str, &str>,
109    file_imports: &[(String, String)],
110) -> Vec<LayerViolation> {
111    let level_map: BTreeMap<&str, usize> =
112        layers.iter().map(|l| (l.name.as_str(), l.level)).collect();
113
114    let mut seen: BTreeMap<(&str, &str), (usize, usize)> = BTreeMap::new();
115    for (from, to) in file_imports {
116        let fm = file_to_mod.get(from.as_str()).copied().unwrap_or("");
117        let tm = file_to_mod.get(to.as_str()).copied().unwrap_or("");
118        if fm == tm || fm.is_empty() || tm.is_empty() {
119            continue;
120        }
121        // Skip parent→child: a module importing its own sub-module is natural
122        if is_parent_child(fm, tm) {
123            continue;
124        }
125        if let (Some(&fl), Some(&tl)) = (level_map.get(fm), level_map.get(tm))
126            && fl < tl
127        {
128            seen.entry((fm, tm)).or_insert((fl, tl));
129        }
130    }
131
132    seen.into_iter()
133        .map(|((from, to), (fl, tl))| LayerViolation {
134            from_module: from.to_string(),
135            to_module: to.to_string(),
136            from_level: fl,
137            to_level: tl,
138        })
139        .collect()
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn stable_module_importing_volatile_is_violation() {
148        let modules = vec![
149            Module {
150                name: "core".into(),
151                files: vec!["core/a.rs".into()],
152                lcom4: 1,
153                tcc: 1.0,
154                cohesion: 1.0,
155            },
156            Module {
157                name: "ui".into(),
158                files: vec!["ui/b.rs".into()],
159                lcom4: 1,
160                tcc: 1.0,
161                cohesion: 1.0,
162            },
163        ];
164        // core imports ui (core has high fan-in → stable, ui has high fan-out → volatile)
165        // But with only this edge, core fan-out=1, ui fan-in=1
166        // core: I=1.0, ui: I=0.0 → ui is more stable
167        // So ui(L0) importing core(L1) would be violation
168        // But here core imports ui, so core(L1) → ui(L0) = not violation
169        let imports = vec![("core/a.rs".into(), "ui/b.rs".into())];
170        let (layers, violations) = infer_layers(&modules, &imports);
171        assert_eq!(layers.len(), 2);
172        // No violation: higher instability importing lower is fine
173        assert!(violations.is_empty());
174    }
175}