1use std::collections::{BTreeMap, BTreeSet, HashMap};
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12use crate::balance::action::RefactoringAction;
13use crate::balance::issue::CouplingIssue;
14use crate::balance::issue_type::IssueType;
15use crate::balance::severity::Severity;
16use crate::metrics::coupling::CouplingMetrics;
17use crate::metrics::dimensions::{Distance, IntegrationStrength};
18use crate::metrics::project::ProjectMetrics;
19
20pub const SCATTERED_EXTERNAL_BREADTH_THRESHOLD: usize = 3;
22
23#[derive(Debug, Clone)]
25pub struct ExternalDependencyReport {
26 pub dependencies: Vec<ExternalDependencyUsage>,
28 pub scattered_couplings: Vec<CouplingIssue>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
34pub struct ExternalDependencyUsage {
35 pub crate_name: String,
37 #[serde(skip_serializing_if = "Vec::is_empty")]
39 pub versions: Vec<String>,
40 pub breadth: usize,
42 pub total_references: usize,
44 pub dominant_strength: String,
46 pub source_modules: Vec<String>,
48}
49
50#[derive(Debug, Default)]
51struct ExternalAccumulator {
52 source_modules: BTreeSet<String>,
53 total_references: usize,
54 strength_counts: [usize; 4],
55}
56
57impl ExternalAccumulator {
58 fn add(&mut self, coupling: &CouplingMetrics) {
59 self.source_modules.insert(coupling.source.clone());
60 self.total_references += 1;
61 self.strength_counts[strength_index(coupling.strength)] += 1;
62 }
63
64 fn dominant_strength(&self) -> IntegrationStrength {
65 let (idx, _) = self
66 .strength_counts
67 .iter()
68 .enumerate()
69 .max_by(|(left_idx, left_count), (right_idx, right_count)| {
70 left_count
71 .cmp(right_count)
72 .then_with(|| strength_rank(*left_idx).cmp(&strength_rank(*right_idx)))
73 })
74 .unwrap_or((0, &0));
75 strength_from_index(idx)
76 }
77}
78
79pub fn analyze_external_dependencies(
81 metrics: &ProjectMetrics,
82 versions: &HashMap<String, Vec<String>>,
83) -> ExternalDependencyReport {
84 let known_external_crates = known_external_crates(metrics, versions);
85 let mut by_crate: BTreeMap<String, ExternalAccumulator> = BTreeMap::new();
86
87 for coupling in metrics
88 .couplings
89 .iter()
90 .filter(|coupling| coupling.distance == Distance::DifferentCrate)
91 {
92 let crate_name = external_crate_name(coupling);
93 if !known_external_crates.is_empty()
94 && !known_external_crates.contains(&normalize_crate_name(&crate_name))
95 {
96 continue;
97 }
98 by_crate.entry(crate_name).or_default().add(coupling);
99 }
100
101 let mut dependencies: Vec<_> = by_crate
102 .into_iter()
103 .map(|(crate_name, accumulator)| {
104 let dominant_strength = accumulator.dominant_strength();
105 let source_modules: Vec<_> = accumulator.source_modules.into_iter().collect();
106 let mut crate_versions = versions.get(&crate_name).cloned().unwrap_or_default();
107 crate_versions.sort();
108 crate_versions.dedup();
109
110 ExternalDependencyUsage {
111 crate_name,
112 versions: crate_versions,
113 breadth: source_modules.len(),
114 total_references: accumulator.total_references,
115 dominant_strength: strength_label(dominant_strength).to_string(),
116 source_modules,
117 }
118 })
119 .collect();
120
121 dependencies.sort_by(|left, right| {
122 right
123 .breadth
124 .cmp(&left.breadth)
125 .then_with(|| right.total_references.cmp(&left.total_references))
126 .then_with(|| left.crate_name.cmp(&right.crate_name))
127 });
128
129 let scattered_couplings = detect_scattered_external_coupling(&dependencies);
130
131 ExternalDependencyReport {
132 dependencies,
133 scattered_couplings,
134 }
135}
136
137fn known_external_crates(
138 metrics: &ProjectMetrics,
139 versions: &HashMap<String, Vec<String>>,
140) -> BTreeSet<String> {
141 let mut crates: BTreeSet<String> = metrics
142 .crate_dependencies
143 .values()
144 .flatten()
145 .map(|name| normalize_crate_name(name))
146 .collect();
147
148 if crates.is_empty() {
149 crates.extend(versions.keys().map(|name| normalize_crate_name(name)));
150 }
151
152 crates
153}
154
155pub fn detect_scattered_external_coupling(
157 dependencies: &[ExternalDependencyUsage],
158) -> Vec<CouplingIssue> {
159 dependencies
160 .iter()
161 .filter(|dependency| dependency.breadth > SCATTERED_EXTERNAL_BREADTH_THRESHOLD)
162 .map(|dependency| {
163 let severity = scattered_severity(dependency.breadth);
164 CouplingIssue {
165 issue_type: IssueType::ScatteredExternalCoupling,
166 severity,
167 source: format!("{} internal modules", dependency.breadth),
168 target: dependency.crate_name.clone(),
169 description: format!(
170 "{} is directly used from {} internal modules ({} references). Third-party upgrade risk is spread across the codebase.",
171 dependency.crate_name, dependency.breadth, dependency.total_references
172 ),
173 refactoring: RefactoringAction::General {
174 action: format!(
175 "Introduce a `{}` facade/wrapper module and route direct crate usage through it",
176 facade_module_name(&dependency.crate_name)
177 ),
178 },
179 balance_score: 1.0 - (dependency.breadth as f64 / 12.0).min(1.0),
180 }
181 })
182 .collect()
183}
184
185pub fn load_lock_versions_near(start: &Path) -> HashMap<String, Vec<String>> {
188 let Some(lock_path) = find_cargo_lock(start) else {
189 return HashMap::new();
190 };
191 let Ok(content) = fs::read_to_string(lock_path) else {
192 return HashMap::new();
193 };
194 parse_lock_versions(&content).unwrap_or_default()
195}
196
197fn parse_lock_versions(content: &str) -> Option<HashMap<String, Vec<String>>> {
198 let lock: CargoLock = toml::from_str(content).ok()?;
199 let mut versions: HashMap<String, Vec<String>> = HashMap::new();
200
201 for package in lock.package {
202 versions
203 .entry(package.name)
204 .or_default()
205 .push(package.version);
206 }
207
208 for package_versions in versions.values_mut() {
209 package_versions.sort();
210 package_versions.dedup();
211 }
212
213 Some(versions)
214}
215
216#[derive(Debug, Deserialize)]
217struct CargoLock {
218 package: Vec<CargoLockPackage>,
219}
220
221#[derive(Debug, Deserialize)]
222struct CargoLockPackage {
223 name: String,
224 version: String,
225}
226
227fn find_cargo_lock(start: &Path) -> Option<PathBuf> {
228 let mut current = if start.is_file() {
229 start.parent().map(Path::to_path_buf)
230 } else {
231 Some(start.to_path_buf())
232 };
233
234 while let Some(dir) = current {
235 let lock_path = dir.join("Cargo.lock");
236 if lock_path.exists() {
237 return Some(lock_path);
238 }
239 current = dir.parent().map(Path::to_path_buf);
240 }
241
242 None
243}
244
245fn external_crate_name(coupling: &CouplingMetrics) -> String {
246 coupling.target_crate.clone().unwrap_or_else(|| {
247 coupling
248 .target
249 .split("::")
250 .next()
251 .unwrap_or(&coupling.target)
252 .to_string()
253 })
254}
255
256fn normalize_crate_name(crate_name: &str) -> String {
257 crate_name.replace('-', "_")
258}
259
260fn scattered_severity(breadth: usize) -> Severity {
261 if breadth >= 10 {
262 Severity::Critical
263 } else if breadth >= 6 {
264 Severity::High
265 } else {
266 Severity::Medium
267 }
268}
269
270fn facade_module_name(crate_name: &str) -> String {
271 format!("{}_facade", crate_name.replace('-', "_"))
272}
273
274fn strength_index(strength: IntegrationStrength) -> usize {
275 match strength {
276 IntegrationStrength::Contract => 0,
277 IntegrationStrength::Model => 1,
278 IntegrationStrength::Functional => 2,
279 IntegrationStrength::Intrusive => 3,
280 }
281}
282
283fn strength_rank(index: usize) -> usize {
284 index
285}
286
287fn strength_from_index(index: usize) -> IntegrationStrength {
288 match index {
289 0 => IntegrationStrength::Contract,
290 1 => IntegrationStrength::Model,
291 2 => IntegrationStrength::Functional,
292 _ => IntegrationStrength::Intrusive,
293 }
294}
295
296fn strength_label(strength: IntegrationStrength) -> &'static str {
297 match strength {
298 IntegrationStrength::Intrusive => "Intrusive",
299 IntegrationStrength::Functional => "Functional",
300 IntegrationStrength::Model => "Model",
301 IntegrationStrength::Contract => "Contract",
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308 use crate::metrics::coupling::CouplingMetrics;
309 use crate::volatility::Volatility;
310
311 fn external_coupling(
312 source: &str,
313 target: &str,
314 strength: IntegrationStrength,
315 ) -> CouplingMetrics {
316 let mut coupling = CouplingMetrics::new(
317 source.to_string(),
318 format!("{target}::Item"),
319 strength,
320 Distance::DifferentCrate,
321 Volatility::Low,
322 );
323 coupling.target_crate = Some(target.to_string());
324 coupling
325 }
326
327 #[test]
328 fn aggregation_counts_breadth_per_external_crate() {
329 let mut metrics = ProjectMetrics::new();
330 metrics.add_coupling(external_coupling(
331 "app::a",
332 "serde",
333 IntegrationStrength::Model,
334 ));
335 metrics.add_coupling(external_coupling(
336 "app::a",
337 "serde",
338 IntegrationStrength::Functional,
339 ));
340 metrics.add_coupling(external_coupling(
341 "app::b",
342 "serde",
343 IntegrationStrength::Functional,
344 ));
345 metrics.add_coupling(external_coupling(
346 "app::c",
347 "clap",
348 IntegrationStrength::Model,
349 ));
350 metrics.add_coupling(CouplingMetrics::new(
351 "app::a".to_string(),
352 "app::internal".to_string(),
353 IntegrationStrength::Model,
354 Distance::DifferentModule,
355 Volatility::Low,
356 ));
357
358 let mut versions = HashMap::new();
359 versions.insert("serde".to_string(), vec!["1.0.0".to_string()]);
360 let report = analyze_external_dependencies(&metrics, &versions);
361
362 let serde = report
363 .dependencies
364 .iter()
365 .find(|dependency| dependency.crate_name == "serde")
366 .unwrap();
367 assert_eq!(serde.breadth, 2);
368 assert_eq!(serde.total_references, 3);
369 assert_eq!(serde.dominant_strength, "Functional");
370 assert_eq!(serde.versions, vec!["1.0.0"]);
371 assert_eq!(serde.source_modules, vec!["app::a", "app::b"]);
372 }
373
374 #[test]
375 fn aggregation_filters_unresolved_type_names_when_dependency_inventory_exists() {
376 let mut metrics = ProjectMetrics::new();
377 metrics
378 .crate_dependencies
379 .insert("app".to_string(), vec!["serde".to_string()]);
380 metrics.add_coupling(external_coupling(
381 "app::a",
382 "serde",
383 IntegrationStrength::Model,
384 ));
385 metrics.add_coupling(external_coupling(
386 "app::b",
387 "Vec",
388 IntegrationStrength::Functional,
389 ));
390
391 let report = analyze_external_dependencies(&metrics, &HashMap::new());
392
393 assert_eq!(report.dependencies.len(), 1);
394 assert_eq!(report.dependencies[0].crate_name, "serde");
395 }
396
397 #[test]
398 fn scattered_coupling_is_flagged_above_threshold() {
399 let dependency = ExternalDependencyUsage {
400 crate_name: "reqwest".to_string(),
401 versions: vec![],
402 breadth: 4,
403 total_references: 9,
404 dominant_strength: "Functional".to_string(),
405 source_modules: vec![
406 "a".to_string(),
407 "b".to_string(),
408 "c".to_string(),
409 "d".to_string(),
410 ],
411 };
412
413 let issues = detect_scattered_external_coupling(&[dependency]);
414
415 assert_eq!(issues.len(), 1);
416 assert_eq!(issues[0].issue_type, IssueType::ScatteredExternalCoupling);
417 assert_eq!(issues[0].severity, Severity::Medium);
418 assert_eq!(issues[0].target, "reqwest");
419 assert!(format!("{}", issues[0].refactoring).contains("facade"));
420 }
421
422 #[test]
423 fn cargo_lock_versions_are_parsed() {
424 let content = r#"
425[[package]]
426name = "serde"
427version = "1.0.228"
428
429[[package]]
430name = "serde"
431version = "1.0.229"
432
433[[package]]
434name = "clap"
435version = "4.6.0"
436"#;
437
438 let versions = parse_lock_versions(content).unwrap();
439
440 assert_eq!(
441 versions.get("serde").unwrap(),
442 &vec!["1.0.228".to_string(), "1.0.229".to_string()]
443 );
444 assert_eq!(versions.get("clap").unwrap(), &vec!["4.6.0".to_string()]);
445 }
446}