1use std::collections::{BTreeMap, HashMap, HashSet};
2
3use super::Module;
4
5#[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#[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 pub gap: f64,
28 pub evidence: Vec<(String, String)>,
30}
31
32pub 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 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 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 assert!(violations.is_empty());
197 }
198}