1use serde::{Deserialize, Serialize};
2use std::collections::{BTreeMap, HashMap, HashSet};
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct ModuleCoupling {
6 pub module: String,
7 pub fan_out: usize,
8 pub fan_in: usize,
9 pub instability: f64,
10 pub afferent_coupling: usize,
11 pub efferent_coupling: usize,
12 pub dependents: Vec<String>,
13 pub dependencies: Vec<String>,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct CouplingReport {
18 pub modules: Vec<ModuleCoupling>,
19 pub avg_fan_out: f64,
20 pub avg_fan_in: f64,
21 pub max_fan_out: usize,
22 pub max_fan_in: usize,
23 pub highly_coupled: Vec<String>,
24 pub total_modules: usize,
25 pub total_edges: usize,
26}
27
28pub fn coupling_analysis(
29 edges: &[(String, String)],
30 module_of: &HashMap<String, String>,
31) -> CouplingReport {
32 let modules: HashSet<&str> = module_of.values().map(|s| s.as_str()).collect();
33 let mut module_list: Vec<&str> = modules.into_iter().collect();
34 module_list.sort();
35
36 if module_list.is_empty() || edges.is_empty() {
37 return CouplingReport {
38 modules: Vec::new(),
39 avg_fan_out: 0.0,
40 avg_fan_in: 0.0,
41 max_fan_out: 0,
42 max_fan_in: 0,
43 highly_coupled: Vec::new(),
44 total_modules: 0,
45 total_edges: 0,
46 };
47 }
48
49 let mut efferent: BTreeMap<String, HashSet<String>> = BTreeMap::new();
50 let mut afferent: BTreeMap<String, HashSet<String>> = BTreeMap::new();
51
52 for name in &module_list {
53 let m = name.to_string();
54 efferent.entry(m.clone()).or_default();
55 afferent.entry(m).or_default();
56 }
57
58 for (from, to) in edges {
59 let from_mod = match module_of.get(from) {
60 Some(m) => m.clone(),
61 None => continue,
62 };
63 let to_mod = match module_of.get(to) {
64 Some(m) => m.clone(),
65 None => continue,
66 };
67 if from_mod != to_mod {
68 efferent
69 .entry(from_mod.clone())
70 .or_default()
71 .insert(to_mod.clone());
72 afferent.entry(to_mod).or_default().insert(from_mod);
73 }
74 }
75
76 let mut modules_out = Vec::new();
77 let mut max_fan_out = 0usize;
78 let mut max_fan_in = 0usize;
79 let mut total_fan_out = 0usize;
80 let mut total_fan_in = 0usize;
81
82 for name in &module_list {
83 let n = name.to_string();
84 let deps = efferent.get(&n).cloned().unwrap_or_default();
85 let dependents = afferent.get(&n).cloned().unwrap_or_default();
86 let fan_out = deps.len();
87 let fan_in = dependents.len();
88 max_fan_out = max_fan_out.max(fan_out);
89 max_fan_in = max_fan_in.max(fan_in);
90 total_fan_out += fan_out;
91 total_fan_in += fan_in;
92
93 let instability = if fan_in + fan_out > 0 {
94 fan_out as f64 / (fan_in + fan_out) as f64
95 } else {
96 0.0
97 };
98
99 let mut dep_list: Vec<String> = deps.into_iter().collect();
100 dep_list.sort();
101 let mut dep_of: Vec<String> = dependents.into_iter().collect();
102 dep_of.sort();
103
104 modules_out.push(ModuleCoupling {
105 module: n,
106 fan_out,
107 fan_in,
108 instability,
109 afferent_coupling: fan_in,
110 efferent_coupling: fan_out,
111 dependents: dep_of,
112 dependencies: dep_list,
113 });
114 }
115
116 let num_modules = modules_out.len();
117 let avg_fan_out = if num_modules > 0 {
118 total_fan_out as f64 / num_modules as f64
119 } else {
120 0.0
121 };
122 let avg_fan_in = if num_modules > 0 {
123 total_fan_in as f64 / num_modules as f64
124 } else {
125 0.0
126 };
127
128 let avg_plus_std = avg_fan_out + (avg_fan_out * 0.5).max(1.0);
129 let highly_coupled: Vec<String> = modules_out
130 .iter()
131 .filter(|m| m.fan_out as f64 > avg_plus_std)
132 .map(|m| m.module.clone())
133 .collect();
134
135 modules_out.sort_by(|a, b| b.fan_out.cmp(&a.fan_out).then(a.module.cmp(&b.module)));
136
137 CouplingReport {
138 modules: modules_out,
139 avg_fan_out,
140 avg_fan_in,
141 max_fan_out,
142 max_fan_in,
143 highly_coupled,
144 total_modules: num_modules,
145 total_edges: edges.len(),
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 fn e(a: &str, b: &str) -> (String, String) {
154 (a.to_string(), b.to_string())
155 }
156
157 fn make_module_map(pairs: &[(&str, &str)]) -> HashMap<String, String> {
158 pairs
159 .iter()
160 .map(|(k, v)| (k.to_string(), v.to_string()))
161 .collect()
162 }
163
164 #[test]
165 fn empty_edges() {
166 let report = coupling_analysis(&[], &HashMap::new());
167 assert_eq!(report.total_modules, 0);
168 }
169
170 #[test]
171 fn single_module_no_coupling() {
172 let edges = vec![e("a", "b"), e("b", "c")];
173 let map = make_module_map(&[("a", "core"), ("b", "core"), ("c", "core")]);
174 let report = coupling_analysis(&edges, &map);
175 assert_eq!(report.total_modules, 1);
176 assert_eq!(report.modules[0].fan_out, 0);
177 assert_eq!(report.modules[0].fan_in, 0);
178 }
179
180 #[test]
181 fn two_modules_coupling() {
182 let edges = vec![e("a", "b")];
183 let map = make_module_map(&[("a", "mod_a"), ("b", "mod_b")]);
184 let report = coupling_analysis(&edges, &map);
185 assert_eq!(report.total_modules, 2);
186 let mod_a = report.modules.iter().find(|m| m.module == "mod_a").unwrap();
187 let mod_b = report.modules.iter().find(|m| m.module == "mod_b").unwrap();
188 assert_eq!(mod_a.fan_out, 1);
189 assert_eq!(mod_b.fan_in, 1);
190 assert!(mod_a.dependencies.contains(&"mod_b".to_string()));
191 assert!(mod_b.dependents.contains(&"mod_a".to_string()));
192 }
193
194 #[test]
195 fn instability_computed() {
196 let edges = vec![e("a", "b"), e("a", "c"), e("d", "a")];
197 let map = make_module_map(&[("a", "core"), ("b", "util"), ("c", "util"), ("d", "cli")]);
198 let report = coupling_analysis(&edges, &map);
199 let core = report.modules.iter().find(|m| m.module == "core").unwrap();
200 assert_eq!(core.fan_out, 1); assert_eq!(core.fan_in, 1); let expected = 1.0 / 2.0;
203 assert!((core.instability - expected).abs() < 0.001);
204 }
205
206 #[test]
207 fn highly_coupled_detected() {
208 let mut edges = Vec::new();
209 let mut map = HashMap::new();
210 for i in 0..20 {
211 map.insert(format!("hub_{i}"), "hub".to_string());
212 map.insert(format!("target_{i}"), format!("target_{i}"));
213 edges.push(e(&format!("hub_{i}"), &format!("target_{i}")));
214 }
215 let report = coupling_analysis(&edges, &map);
216 assert!(report.highly_coupled.contains(&"hub".to_string()));
217 }
218
219 #[test]
220 fn sorted_by_fan_out_descending() {
221 let edges = vec![e("a", "b"), e("a", "c"), e("b", "c")];
222 let map = make_module_map(&[("a", "mod_a"), ("b", "mod_b"), ("c", "mod_c")]);
223 let report = coupling_analysis(&edges, &map);
224 for i in 1..report.modules.len() {
225 assert!(report.modules[i - 1].fan_out >= report.modules[i].fan_out);
226 }
227 }
228
229 #[test]
230 fn unknown_nodes_skipped() {
231 let edges = vec![e("a", "unknown")];
232 let map = make_module_map(&[("a", "core")]);
233 let report = coupling_analysis(&edges, &map);
234 assert_eq!(report.total_modules, 1);
235 }
236}