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 detect_violations(
101    layers: &[LayerInfo],
102    file_to_mod: &HashMap<&str, &str>,
103    file_imports: &[(String, String)],
104) -> Vec<LayerViolation> {
105    let level_map: BTreeMap<&str, usize> =
106        layers.iter().map(|l| (l.name.as_str(), l.level)).collect();
107
108    let mut seen: BTreeMap<(&str, &str), (usize, usize)> = BTreeMap::new();
109    for (from, to) in file_imports {
110        let fm = file_to_mod.get(from.as_str()).copied().unwrap_or("");
111        let tm = file_to_mod.get(to.as_str()).copied().unwrap_or("");
112        if fm == tm || fm.is_empty() || tm.is_empty() {
113            continue;
114        }
115        if let (Some(&fl), Some(&tl)) = (level_map.get(fm), level_map.get(tm))
116            && fl < tl
117        {
118            seen.entry((fm, tm)).or_insert((fl, tl));
119        }
120    }
121
122    seen.into_iter()
123        .map(|((from, to), (fl, tl))| LayerViolation {
124            from_module: from.to_string(),
125            to_module: to.to_string(),
126            from_level: fl,
127            to_level: tl,
128        })
129        .collect()
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn stable_module_importing_volatile_is_violation() {
138        let modules = vec![
139            Module {
140                name: "core".into(),
141                files: vec!["core/a.rs".into()],
142                lcom4: 1,
143                tcc: 1.0,
144                cohesion: 1.0,
145            },
146            Module {
147                name: "ui".into(),
148                files: vec!["ui/b.rs".into()],
149                lcom4: 1,
150                tcc: 1.0,
151                cohesion: 1.0,
152            },
153        ];
154        // core imports ui (core has high fan-in → stable, ui has high fan-out → volatile)
155        // But with only this edge, core fan-out=1, ui fan-in=1
156        // core: I=1.0, ui: I=0.0 → ui is more stable
157        // So ui(L0) importing core(L1) would be violation
158        // But here core imports ui, so core(L1) → ui(L0) = not violation
159        let imports = vec![("core/a.rs".into(), "ui/b.rs".into())];
160        let (layers, violations) = infer_layers(&modules, &imports);
161        assert_eq!(layers.len(), 2);
162        // No violation: higher instability importing lower is fine
163        assert!(violations.is_empty());
164    }
165}