cargo_coupling/design/
graph.rs1use crate::{Distance, ProjectMetrics};
4use serde::Serialize;
5use std::collections::{BTreeMap, BTreeSet, VecDeque};
6
7#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
8pub struct ImpactPath {
9 pub module: String,
10 pub path: Vec<String>,
12}
13
14#[derive(Debug, Clone, Default, Serialize)]
15pub struct Reachability {
16 pub paths: Vec<ImpactPath>,
17 pub max_depth: Option<usize>,
18 pub truncated: bool,
19}
20
21#[derive(Debug, Clone, Default)]
22pub struct DependencyGraph {
23 pub nodes: BTreeSet<String>,
24 pub outgoing: BTreeMap<String, BTreeSet<String>>,
25 pub incoming: BTreeMap<String, BTreeSet<String>>,
26 pub unresolved: BTreeSet<String>,
27}
28
29impl DependencyGraph {
30 pub fn from_metrics(metrics: &ProjectMetrics) -> Self {
31 let names = Self {
32 nodes: metrics.modules.keys().cloned().collect(),
33 ..Self::default()
34 };
35 let mut graph = names.clone();
36 for edge in &metrics.couplings {
39 if edge.distance == Distance::DifferentCrate {
40 continue;
41 }
42 let source = names
43 .resolve(&edge.source)
44 .unwrap_or_else(|| edge.source.clone());
45 let target = names
46 .resolve(&edge.target)
47 .unwrap_or_else(|| edge.target.clone());
48 if !metrics.modules.contains_key(&source) {
49 graph.unresolved.insert(source.clone());
50 }
51 if !metrics.modules.contains_key(&target) {
52 graph.unresolved.insert(target.clone());
53 }
54 graph.nodes.insert(source.clone());
55 graph.nodes.insert(target.clone());
56 if source != target {
57 graph
58 .outgoing
59 .entry(source.clone())
60 .or_default()
61 .insert(target.clone());
62 graph.incoming.entry(target).or_default().insert(source);
63 }
64 }
65 graph
66 }
67
68 pub fn resolve(&self, name: &str) -> Option<String> {
69 if self.nodes.contains(name) {
70 return Some(name.to_string());
71 }
72 if let Some(found) = self
73 .nodes
74 .iter()
75 .filter(|key| name.ends_with(&format!("::{key}")))
76 .max_by_key(|key| key.len())
77 {
78 return Some(found.clone());
79 }
80 let mut matches = self
81 .nodes
82 .iter()
83 .filter(|key| key.ends_with(&format!("::{name}")));
84 let found = matches.next()?;
85 matches.next().is_none().then(|| found.clone())
86 }
87
88 pub fn select(&self, selector: &str) -> Vec<String> {
89 let Ok(pattern) = glob::Pattern::new(selector) else {
90 return vec![];
91 };
92 self.nodes
93 .iter()
94 .filter(|node| pattern.matches(node))
95 .cloned()
96 .collect()
97 }
98
99 pub fn impact(&self, origin: &str, max_depth: Option<usize>) -> Reachability {
100 self.walk(origin, max_depth, &self.incoming)
101 }
102
103 pub fn dependencies(&self, origin: &str, max_depth: Option<usize>) -> Reachability {
104 self.walk(origin, max_depth, &self.outgoing)
105 }
106
107 fn walk(
108 &self,
109 origin: &str,
110 max_depth: Option<usize>,
111 edges: &BTreeMap<String, BTreeSet<String>>,
112 ) -> Reachability {
113 let mut result = Reachability {
114 max_depth,
115 ..Reachability::default()
116 };
117 let Some(origin) = self.resolve(origin) else {
118 return result;
119 };
120 let mut visited = BTreeSet::from([origin.clone()]);
121 let mut queue = VecDeque::from([vec![origin]]);
122 let mut frontier = BTreeSet::new();
123 while let Some(path) = queue.pop_front() {
124 let current = path.last().expect("nonempty BFS path");
125 if max_depth.is_some_and(|depth| path.len() > depth) {
126 frontier.insert(current.clone());
127 continue;
128 }
129 for next in edges.get(current).into_iter().flatten() {
130 if visited.insert(next.clone()) {
131 let mut next_path = path.clone();
132 next_path.push(next.clone());
133 result.paths.push(ImpactPath {
134 module: next.clone(),
135 path: next_path.clone(),
136 });
137 queue.push_back(next_path);
138 }
139 }
140 }
141 result.truncated = frontier.iter().any(|node| {
142 edges
143 .get(node)
144 .into_iter()
145 .flatten()
146 .any(|next| !visited.contains(next))
147 });
148 result
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155 use crate::{CouplingMetrics, IntegrationStrength, ModuleMetrics, Volatility};
156
157 #[test]
158 fn qualified_names_choose_the_longest_boundary_and_short_names_remain_ambiguous() {
159 let graph = DependencyGraph {
160 nodes: [
161 "design".into(),
162 "web::design".into(),
163 "web::graph".into(),
164 "design::graph".into(),
165 ]
166 .into(),
167 ..Default::default()
168 };
169 assert_eq!(
170 graph.resolve("cargo-coupling::web::design"),
171 Some("web::design".into())
172 );
173 assert_eq!(graph.resolve("graph"), None);
174 assert_eq!(graph.resolve("design"), Some("design".into()));
175 }
176
177 #[test]
178 fn paths_cover_three_hops_and_stop_at_explicit_limit_without_cycle_loops() {
179 let mut metrics = ProjectMetrics::new();
180 for name in ["a", "b", "c", "d"] {
181 metrics.add_module(ModuleMetrics::new(format!("{name}.rs").into(), name.into()));
182 }
183 for (source, target) in [("b", "a"), ("c", "b"), ("d", "c"), ("a", "d")] {
184 metrics.add_coupling(CouplingMetrics::new(
185 source.into(),
186 target.into(),
187 IntegrationStrength::Functional,
188 Distance::DifferentModule,
189 Volatility::High,
190 ));
191 }
192 let graph = DependencyGraph::from_metrics(&metrics);
193 assert_eq!(
194 graph.impact("a", None).paths.last().unwrap().path,
195 ["a", "b", "c", "d"]
196 );
197 let bounded = graph.impact("a", Some(2));
198 assert_eq!(bounded.paths.len(), 2);
199 assert!(bounded.truncated);
200 assert!(!graph.impact("a", Some(3)).truncated);
201 }
202}