1use std::collections::{BTreeMap, BTreeSet, HashMap};
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12pub use crate::balance::external_crates::SCATTERED_EXTERNAL_BREADTH_THRESHOLD;
13use crate::CouplingIssue;
16use crate::metrics::coupling::CouplingMetrics;
17use crate::metrics::dimensions::{Distance, IntegrationStrength};
18use crate::metrics::project::ProjectMetrics;
19
20#[derive(Debug, Clone)]
22pub struct ExternalDependencyReport {
23 pub dependencies: Vec<ExternalDependencyUsage>,
25 pub scattered_couplings: Vec<CouplingIssue>,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
31pub struct ExternalDependencyUsage {
32 pub crate_name: String,
34 #[serde(skip_serializing_if = "Vec::is_empty")]
36 pub versions: Vec<String>,
37 pub breadth: usize,
39 pub total_references: usize,
41 pub dominant_strength: String,
43 pub source_modules: Vec<String>,
45}
46
47#[derive(Debug, Default)]
48struct ExternalAccumulator {
49 source_modules: BTreeSet<String>,
50 total_references: usize,
51 strength_counts: [usize; 4],
52}
53
54impl ExternalAccumulator {
55 fn add(&mut self, coupling: &CouplingMetrics) {
56 self.source_modules.insert(coupling.source.clone());
57 self.total_references += 1;
58 self.strength_counts[strength_index(coupling.strength)] += 1;
59 }
60
61 fn dominant_strength(&self) -> IntegrationStrength {
62 let (idx, _) = self
63 .strength_counts
64 .iter()
65 .enumerate()
66 .max_by(|(left_idx, left_count), (right_idx, right_count)| {
67 left_count
68 .cmp(right_count)
69 .then_with(|| strength_rank(*left_idx).cmp(&strength_rank(*right_idx)))
70 })
71 .unwrap_or((0, &0));
72 strength_from_index(idx)
73 }
74}
75
76pub fn analyze_external_dependencies(
78 metrics: &ProjectMetrics,
79 versions: &HashMap<String, Vec<String>>,
80) -> ExternalDependencyReport {
81 let known_external_crates = known_external_crates(metrics, versions);
82 let mut by_crate: BTreeMap<String, ExternalAccumulator> = BTreeMap::new();
83
84 for coupling in metrics
85 .couplings
86 .iter()
87 .filter(|coupling| coupling.distance == Distance::DifferentCrate)
88 {
89 let crate_name = external_crate_name(coupling);
90 if !known_external_crates.is_empty()
91 && !known_external_crates.contains(&normalize_crate_name(&crate_name))
92 {
93 continue;
94 }
95 by_crate.entry(crate_name).or_default().add(coupling);
96 }
97
98 let mut dependencies: Vec<_> = by_crate
99 .into_iter()
100 .map(|(crate_name, accumulator)| {
101 let dominant_strength = accumulator.dominant_strength();
102 let source_modules: Vec<_> = accumulator.source_modules.into_iter().collect();
103 let mut crate_versions = versions.get(&crate_name).cloned().unwrap_or_default();
104 crate_versions.sort();
105 crate_versions.dedup();
106
107 ExternalDependencyUsage {
108 crate_name,
109 versions: crate_versions,
110 breadth: source_modules.len(),
111 total_references: accumulator.total_references,
112 dominant_strength: strength_label(dominant_strength).to_string(),
113 source_modules,
114 }
115 })
116 .collect();
117
118 dependencies.sort_by(|left, right| {
119 right
120 .breadth
121 .cmp(&left.breadth)
122 .then_with(|| right.total_references.cmp(&left.total_references))
123 .then_with(|| left.crate_name.cmp(&right.crate_name))
124 });
125
126 let scattered_couplings =
127 crate::balance::external_crates::detect_scattered_external_coupling(&dependencies);
128
129 ExternalDependencyReport {
130 dependencies,
131 scattered_couplings,
132 }
133}
134
135fn known_external_crates(
136 metrics: &ProjectMetrics,
137 versions: &HashMap<String, Vec<String>>,
138) -> BTreeSet<String> {
139 let mut crates: BTreeSet<String> = metrics
140 .crate_dependencies
141 .values()
142 .flatten()
143 .map(|name| normalize_crate_name(name))
144 .collect();
145
146 if crates.is_empty() {
147 crates.extend(versions.keys().map(|name| normalize_crate_name(name)));
148 }
149
150 crates
151}
152
153pub fn detect_scattered_external_coupling(
155 dependencies: &[ExternalDependencyUsage],
156) -> Vec<CouplingIssue> {
157 crate::balance::external_crates::detect_scattered_external_coupling(dependencies)
158}
159
160pub fn load_lock_versions_near(start: &Path) -> HashMap<String, Vec<String>> {
163 let Some(lock_path) = find_cargo_lock(start) else {
164 return HashMap::new();
165 };
166 let Ok(content) = fs::read_to_string(lock_path) else {
167 return HashMap::new();
168 };
169 parse_lock_versions(&content).unwrap_or_default()
170}
171
172fn parse_lock_versions(content: &str) -> Option<HashMap<String, Vec<String>>> {
173 let lock: CargoLock = toml::from_str(content).ok()?;
174 let mut versions: HashMap<String, Vec<String>> = HashMap::new();
175
176 for package in lock.package {
177 versions
178 .entry(package.name)
179 .or_default()
180 .push(package.version);
181 }
182
183 for package_versions in versions.values_mut() {
184 package_versions.sort();
185 package_versions.dedup();
186 }
187
188 Some(versions)
189}
190
191#[derive(Debug, Deserialize)]
192struct CargoLock {
193 package: Vec<CargoLockPackage>,
194}
195
196#[derive(Debug, Deserialize)]
197struct CargoLockPackage {
198 name: String,
199 version: String,
200}
201
202fn find_cargo_lock(start: &Path) -> Option<PathBuf> {
203 let mut current = if start.is_file() {
204 start.parent().map(Path::to_path_buf)
205 } else {
206 Some(start.to_path_buf())
207 };
208
209 while let Some(dir) = current {
210 let lock_path = dir.join("Cargo.lock");
211 if lock_path.exists() {
212 return Some(lock_path);
213 }
214 current = dir.parent().map(Path::to_path_buf);
215 }
216
217 None
218}
219
220fn external_crate_name(coupling: &CouplingMetrics) -> String {
221 coupling.target_crate.clone().unwrap_or_else(|| {
222 coupling
223 .target
224 .split("::")
225 .next()
226 .unwrap_or(&coupling.target)
227 .to_string()
228 })
229}
230
231fn normalize_crate_name(crate_name: &str) -> String {
232 crate_name.replace('-', "_")
233}
234
235fn strength_index(strength: IntegrationStrength) -> usize {
236 match strength {
237 IntegrationStrength::Contract => 0,
238 IntegrationStrength::Model => 1,
239 IntegrationStrength::Functional => 2,
240 IntegrationStrength::Intrusive => 3,
241 }
242}
243
244fn strength_rank(index: usize) -> usize {
245 index
246}
247
248fn strength_from_index(index: usize) -> IntegrationStrength {
249 match index {
250 0 => IntegrationStrength::Contract,
251 1 => IntegrationStrength::Model,
252 2 => IntegrationStrength::Functional,
253 _ => IntegrationStrength::Intrusive,
254 }
255}
256
257fn strength_label(strength: IntegrationStrength) -> &'static str {
258 match strength {
259 IntegrationStrength::Intrusive => "Intrusive",
260 IntegrationStrength::Functional => "Functional",
261 IntegrationStrength::Model => "Model",
262 IntegrationStrength::Contract => "Contract",
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use crate::metrics::coupling::CouplingMetrics;
270 use crate::volatility::Volatility;
271
272 fn external_coupling(
273 source: &str,
274 target: &str,
275 strength: IntegrationStrength,
276 ) -> CouplingMetrics {
277 let mut coupling = CouplingMetrics::new(
278 source.to_string(),
279 format!("{target}::Item"),
280 strength,
281 Distance::DifferentCrate,
282 Volatility::Low,
283 );
284 coupling.target_crate = Some(target.to_string());
285 coupling
286 }
287
288 #[test]
289 fn aggregation_counts_breadth_per_external_crate() {
290 let mut metrics = ProjectMetrics::new();
291 metrics.add_coupling(external_coupling(
292 "app::a",
293 "serde",
294 IntegrationStrength::Model,
295 ));
296 metrics.add_coupling(external_coupling(
297 "app::a",
298 "serde",
299 IntegrationStrength::Functional,
300 ));
301 metrics.add_coupling(external_coupling(
302 "app::b",
303 "serde",
304 IntegrationStrength::Functional,
305 ));
306 metrics.add_coupling(external_coupling(
307 "app::c",
308 "clap",
309 IntegrationStrength::Model,
310 ));
311 metrics.add_coupling(CouplingMetrics::new(
312 "app::a".to_string(),
313 "app::internal".to_string(),
314 IntegrationStrength::Model,
315 Distance::DifferentModule,
316 Volatility::Low,
317 ));
318
319 let mut versions = HashMap::new();
320 versions.insert("serde".to_string(), vec!["1.0.0".to_string()]);
321 let report = analyze_external_dependencies(&metrics, &versions);
322
323 let serde = report
324 .dependencies
325 .iter()
326 .find(|dependency| dependency.crate_name == "serde")
327 .unwrap();
328 assert_eq!(serde.breadth, 2);
329 assert_eq!(serde.total_references, 3);
330 assert_eq!(serde.dominant_strength, "Functional");
331 assert_eq!(serde.versions, vec!["1.0.0"]);
332 assert_eq!(serde.source_modules, vec!["app::a", "app::b"]);
333 }
334
335 #[test]
336 fn aggregation_filters_unresolved_type_names_when_dependency_inventory_exists() {
337 let mut metrics = ProjectMetrics::new();
338 metrics
339 .crate_dependencies
340 .insert("app".to_string(), vec!["serde".to_string()]);
341 metrics.add_coupling(external_coupling(
342 "app::a",
343 "serde",
344 IntegrationStrength::Model,
345 ));
346 metrics.add_coupling(external_coupling(
347 "app::b",
348 "Vec",
349 IntegrationStrength::Functional,
350 ));
351
352 let report = analyze_external_dependencies(&metrics, &HashMap::new());
353
354 assert_eq!(report.dependencies.len(), 1);
355 assert_eq!(report.dependencies[0].crate_name, "serde");
356 }
357
358 #[test]
359 fn cargo_lock_versions_are_parsed() {
360 let content = r#"
361[[package]]
362name = "serde"
363version = "1.0.228"
364
365[[package]]
366name = "serde"
367version = "1.0.229"
368
369[[package]]
370name = "clap"
371version = "4.6.0"
372"#;
373
374 let versions = parse_lock_versions(content).unwrap();
375
376 assert_eq!(
377 versions.get("serde").unwrap(),
378 &vec!["1.0.228".to_string(), "1.0.229".to_string()]
379 );
380 assert_eq!(versions.get("clap").unwrap(), &vec!["4.6.0".to_string()]);
381 }
382}