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    /// Instability gap (to - from). Larger = more severe.
27    pub gap: f64,
28    /// Specific file-level imports causing this violation.
29    pub evidence: Vec<(String, String)>,
30}
31
32/// Infer layers from modules and file-level imports.
33/// Returns (layers sorted by instability, violations).
34pub fn infer_layers(
35    modules: &[Module],
36    file_imports: &[(String, String)],
37) -> (Vec<LayerInfo>, Vec<LayerViolation>) {
38    let file_to_mod: HashMap<&str, &str> = modules
39        .iter()
40        .flat_map(|m| m.files.iter().map(|f| (f.as_str(), m.name.as_str())))
41        .collect();
42
43    let (fan_in, fan_out) = compute_module_fans(&file_to_mod, file_imports);
44    let mut layers = build_layers(modules, &fan_in, &fan_out);
45
46    layers.sort_by(|a, b| a.instability.partial_cmp(&b.instability).unwrap());
47    for (i, l) in layers.iter_mut().enumerate() {
48        l.level = i;
49    }
50
51    let violations = detect_violations(&layers, &file_to_mod, file_imports);
52    (layers, violations)
53}
54
55type FanMap<'a> = HashMap<&'a str, HashSet<&'a str>>;
56
57fn compute_module_fans<'a>(
58    file_to_mod: &HashMap<&'a str, &'a str>,
59    file_imports: &[(String, String)],
60) -> (FanMap<'a>, FanMap<'a>) {
61    let mut fan_in: HashMap<&str, HashSet<&str>> = HashMap::new();
62    let mut fan_out: HashMap<&str, HashSet<&str>> = HashMap::new();
63    for (from, to) in file_imports {
64        let fm = file_to_mod.get(from.as_str()).copied().unwrap_or("");
65        let tm = file_to_mod.get(to.as_str()).copied().unwrap_or("");
66        if !fm.is_empty() && !tm.is_empty() && fm != tm {
67            fan_out.entry(fm).or_default().insert(tm);
68            fan_in.entry(tm).or_default().insert(fm);
69        }
70    }
71    (fan_in, fan_out)
72}
73
74fn build_layers(
75    modules: &[Module],
76    fan_in: &HashMap<&str, HashSet<&str>>,
77    fan_out: &HashMap<&str, HashSet<&str>>,
78) -> Vec<LayerInfo> {
79    modules
80        .iter()
81        .map(|m| {
82            let fi = fan_in.get(m.name.as_str()).map(|s| s.len()).unwrap_or(0);
83            let fo = fan_out.get(m.name.as_str()).map(|s| s.len()).unwrap_or(0);
84            let total = fi + fo;
85            LayerInfo {
86                name: m.name.clone(),
87                level: 0,
88                file_count: m.files.len(),
89                fan_in: fi,
90                fan_out: fo,
91                instability: if total > 0 {
92                    fo as f64 / total as f64
93                } else {
94                    0.5
95                },
96                lcom4: m.lcom4,
97                tcc: m.tcc,
98                cohesion: m.cohesion,
99            }
100        })
101        .collect()
102}
103
104fn is_parent_child(a: &str, b: &str) -> bool {
105    let a = a.trim_end_matches("/*").trim_end_matches('/');
106    let b = b.trim_end_matches("/*").trim_end_matches('/');
107    a.starts_with(&format!("{b}/")) || b.starts_with(&format!("{a}/"))
108}
109
110fn detect_violations(
111    layers: &[LayerInfo],
112    file_to_mod: &HashMap<&str, &str>,
113    file_imports: &[(String, String)],
114) -> Vec<LayerViolation> {
115    let level_map: BTreeMap<&str, usize> =
116        layers.iter().map(|l| (l.name.as_str(), l.level)).collect();
117    let inst_map: BTreeMap<&str, f64> = layers
118        .iter()
119        .map(|l| (l.name.as_str(), l.instability))
120        .collect();
121
122    type ViolSeen<'a> = BTreeMap<(&'a str, &'a str), (usize, usize, Vec<(String, String)>)>;
123    let mut seen: ViolSeen = BTreeMap::new();
124    for (from, to) in file_imports {
125        let fm = file_to_mod.get(from.as_str()).copied().unwrap_or("");
126        let tm = file_to_mod.get(to.as_str()).copied().unwrap_or("");
127        if fm == tm || fm.is_empty() || tm.is_empty() {
128            continue;
129        }
130        if is_parent_child(fm, tm) {
131            continue;
132        }
133        if let (Some(&fl), Some(&tl)) = (level_map.get(fm), level_map.get(tm))
134            && fl < tl
135        {
136            let entry = seen.entry((fm, tm)).or_insert((fl, tl, Vec::new()));
137            entry.2.push((from.clone(), to.clone()));
138        }
139    }
140
141    let mut result: Vec<LayerViolation> = seen
142        .into_iter()
143        .map(|((from, to), (fl, tl, evidence))| {
144            let fi = inst_map.get(from).copied().unwrap_or(0.5);
145            let ti = inst_map.get(to).copied().unwrap_or(0.5);
146            LayerViolation {
147                from_module: from.to_string(),
148                to_module: to.to_string(),
149                from_level: fl,
150                to_level: tl,
151                gap: ti - fi,
152                evidence,
153            }
154        })
155        .collect();
156    // Sort by gap descending (most severe first)
157    result.sort_by(|a, b| {
158        b.gap
159            .partial_cmp(&a.gap)
160            .unwrap_or(std::cmp::Ordering::Equal)
161    });
162    result
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn stable_module_importing_volatile_is_violation() {
171        let modules = vec![
172            Module {
173                name: "core".into(),
174                files: vec!["core/a.rs".into()],
175                lcom4: 1,
176                tcc: 1.0,
177                cohesion: 1.0,
178            },
179            Module {
180                name: "ui".into(),
181                files: vec!["ui/b.rs".into()],
182                lcom4: 1,
183                tcc: 1.0,
184                cohesion: 1.0,
185            },
186        ];
187        // core imports ui (core has high fan-in → stable, ui has high fan-out → volatile)
188        // But with only this edge, core fan-out=1, ui fan-in=1
189        // core: I=1.0, ui: I=0.0 → ui is more stable
190        // So ui(L0) importing core(L1) would be violation
191        // But here core imports ui, so core(L1) → ui(L0) = not violation
192        let imports = vec![("core/a.rs".into(), "ui/b.rs".into())];
193        let (layers, violations) = infer_layers(&modules, &imports);
194        assert_eq!(layers.len(), 2);
195        // No violation: higher instability importing lower is fine
196        assert!(violations.is_empty());
197    }
198}