Skip to main content

cargo_coupling/
external.rs

1//! External dependency coupling analysis.
2//!
3//! This module aggregates `DifferentCrate` coupling records into a snapshot of
4//! how broadly third-party crates are used across internal modules.
5
6use 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
20/// Number of internal modules above which direct third-party usage is considered scattered.
21pub const SCATTERED_EXTERNAL_BREADTH_THRESHOLD: usize = 3;
22
23/// External dependency coupling report.
24#[derive(Debug, Clone)]
25pub struct ExternalDependencyReport {
26    /// External crates sorted by breadth, then references.
27    pub dependencies: Vec<ExternalDependencyUsage>,
28    /// Scattered external coupling findings.
29    pub scattered_couplings: Vec<CouplingIssue>,
30}
31
32/// Aggregated usage of one external crate.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
34pub struct ExternalDependencyUsage {
35    /// Crate name.
36    pub crate_name: String,
37    /// Resolved versions from Cargo.lock, when available.
38    #[serde(skip_serializing_if = "Vec::is_empty")]
39    pub versions: Vec<String>,
40    /// Number of distinct internal modules with direct coupling to this crate.
41    pub breadth: usize,
42    /// Total direct references observed in coupling metrics.
43    pub total_references: usize,
44    /// Most common integration strength, breaking ties toward stronger coupling.
45    pub dominant_strength: String,
46    /// Internal modules with direct coupling to this crate.
47    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
79/// Analyze external dependency coupling using optional lockfile versions.
80pub 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
155/// Detect external crates used directly from many internal modules.
156pub 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
185/// Read resolved package versions from the nearest Cargo.lock. Missing or
186/// unparsable lockfiles are treated as absent.
187pub 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}