Skip to main content

tsift_algorithms/
coupling.rs

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); // core -> util
201        assert_eq!(core.fan_in, 1); // cli -> core
202        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}