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
12pub use crate::balance::external_crates::SCATTERED_EXTERNAL_BREADTH_THRESHOLD;
13// Consume the crate's published facade rather than deep `balance::*` paths: the
14// re-exported surface stays stable when the balance package reorganizes internally.
15use crate::CouplingIssue;
16use crate::metrics::coupling::CouplingMetrics;
17use crate::metrics::dimensions::{Distance, IntegrationStrength};
18use crate::metrics::project::ProjectMetrics;
19
20/// External dependency coupling report.
21#[derive(Debug, Clone)]
22pub struct ExternalDependencyReport {
23    /// External crates sorted by breadth, then references.
24    pub dependencies: Vec<ExternalDependencyUsage>,
25    /// Scattered external coupling findings.
26    pub scattered_couplings: Vec<CouplingIssue>,
27}
28
29/// Aggregated usage of one external crate.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
31pub struct ExternalDependencyUsage {
32    /// Crate name.
33    pub crate_name: String,
34    /// Resolved versions from Cargo.lock, when available.
35    #[serde(skip_serializing_if = "Vec::is_empty")]
36    pub versions: Vec<String>,
37    /// Number of distinct internal modules with direct coupling to this crate.
38    pub breadth: usize,
39    /// Total direct references observed in coupling metrics.
40    pub total_references: usize,
41    /// Most common integration strength, breaking ties toward stronger coupling.
42    pub dominant_strength: String,
43    /// Internal modules with direct coupling to this crate.
44    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
76/// Analyze external dependency coupling using optional lockfile versions.
77pub 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
153/// Detect external crates used directly from many internal modules.
154pub fn detect_scattered_external_coupling(
155    dependencies: &[ExternalDependencyUsage],
156) -> Vec<CouplingIssue> {
157    crate::balance::external_crates::detect_scattered_external_coupling(dependencies)
158}
159
160/// Read resolved package versions from the nearest Cargo.lock. Missing or
161/// unparsable lockfiles are treated as absent.
162pub 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}