use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub use crate::balance::external_crates::SCATTERED_EXTERNAL_BREADTH_THRESHOLD;
use crate::CouplingIssue;
use crate::metrics::coupling::CouplingMetrics;
use crate::metrics::dimensions::{Distance, IntegrationStrength};
use crate::metrics::project::ProjectMetrics;
#[derive(Debug, Clone)]
pub struct ExternalDependencyReport {
pub dependencies: Vec<ExternalDependencyUsage>,
pub scattered_couplings: Vec<CouplingIssue>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ExternalDependencyUsage {
pub crate_name: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub versions: Vec<String>,
pub breadth: usize,
pub total_references: usize,
pub dominant_strength: String,
pub source_modules: Vec<String>,
}
#[derive(Debug, Default)]
struct ExternalAccumulator {
source_modules: BTreeSet<String>,
total_references: usize,
strength_counts: [usize; 4],
}
impl ExternalAccumulator {
fn add(&mut self, coupling: &CouplingMetrics) {
self.source_modules.insert(coupling.source.clone());
self.total_references += 1;
self.strength_counts[strength_index(coupling.strength)] += 1;
}
fn dominant_strength(&self) -> IntegrationStrength {
let (idx, _) = self
.strength_counts
.iter()
.enumerate()
.max_by(|(left_idx, left_count), (right_idx, right_count)| {
left_count
.cmp(right_count)
.then_with(|| strength_rank(*left_idx).cmp(&strength_rank(*right_idx)))
})
.unwrap_or((0, &0));
strength_from_index(idx)
}
}
pub fn analyze_external_dependencies(
metrics: &ProjectMetrics,
versions: &HashMap<String, Vec<String>>,
) -> ExternalDependencyReport {
let known_external_crates = known_external_crates(metrics, versions);
let mut by_crate: BTreeMap<String, ExternalAccumulator> = BTreeMap::new();
for coupling in metrics
.couplings
.iter()
.filter(|coupling| coupling.distance == Distance::DifferentCrate)
{
let crate_name = external_crate_name(coupling);
if !known_external_crates.is_empty()
&& !known_external_crates.contains(&normalize_crate_name(&crate_name))
{
continue;
}
by_crate.entry(crate_name).or_default().add(coupling);
}
let mut dependencies: Vec<_> = by_crate
.into_iter()
.map(|(crate_name, accumulator)| {
let dominant_strength = accumulator.dominant_strength();
let source_modules: Vec<_> = accumulator.source_modules.into_iter().collect();
let mut crate_versions = versions.get(&crate_name).cloned().unwrap_or_default();
crate_versions.sort();
crate_versions.dedup();
ExternalDependencyUsage {
crate_name,
versions: crate_versions,
breadth: source_modules.len(),
total_references: accumulator.total_references,
dominant_strength: strength_label(dominant_strength).to_string(),
source_modules,
}
})
.collect();
dependencies.sort_by(|left, right| {
right
.breadth
.cmp(&left.breadth)
.then_with(|| right.total_references.cmp(&left.total_references))
.then_with(|| left.crate_name.cmp(&right.crate_name))
});
let scattered_couplings =
crate::balance::external_crates::detect_scattered_external_coupling(&dependencies);
ExternalDependencyReport {
dependencies,
scattered_couplings,
}
}
fn known_external_crates(
metrics: &ProjectMetrics,
versions: &HashMap<String, Vec<String>>,
) -> BTreeSet<String> {
let mut crates: BTreeSet<String> = metrics
.crate_dependencies
.values()
.flatten()
.map(|name| normalize_crate_name(name))
.collect();
if crates.is_empty() {
crates.extend(versions.keys().map(|name| normalize_crate_name(name)));
}
crates
}
pub fn detect_scattered_external_coupling(
dependencies: &[ExternalDependencyUsage],
) -> Vec<CouplingIssue> {
crate::balance::external_crates::detect_scattered_external_coupling(dependencies)
}
pub fn load_lock_versions_near(start: &Path) -> HashMap<String, Vec<String>> {
let Some(lock_path) = find_cargo_lock(start) else {
return HashMap::new();
};
let Ok(content) = fs::read_to_string(lock_path) else {
return HashMap::new();
};
parse_lock_versions(&content).unwrap_or_default()
}
fn parse_lock_versions(content: &str) -> Option<HashMap<String, Vec<String>>> {
let lock: CargoLock = toml::from_str(content).ok()?;
let mut versions: HashMap<String, Vec<String>> = HashMap::new();
for package in lock.package {
versions
.entry(package.name)
.or_default()
.push(package.version);
}
for package_versions in versions.values_mut() {
package_versions.sort();
package_versions.dedup();
}
Some(versions)
}
#[derive(Debug, Deserialize)]
struct CargoLock {
package: Vec<CargoLockPackage>,
}
#[derive(Debug, Deserialize)]
struct CargoLockPackage {
name: String,
version: String,
}
fn find_cargo_lock(start: &Path) -> Option<PathBuf> {
let mut current = if start.is_file() {
start.parent().map(Path::to_path_buf)
} else {
Some(start.to_path_buf())
};
while let Some(dir) = current {
let lock_path = dir.join("Cargo.lock");
if lock_path.exists() {
return Some(lock_path);
}
current = dir.parent().map(Path::to_path_buf);
}
None
}
fn external_crate_name(coupling: &CouplingMetrics) -> String {
coupling.target_crate.clone().unwrap_or_else(|| {
coupling
.target
.split("::")
.next()
.unwrap_or(&coupling.target)
.to_string()
})
}
fn normalize_crate_name(crate_name: &str) -> String {
crate_name.replace('-', "_")
}
fn strength_index(strength: IntegrationStrength) -> usize {
match strength {
IntegrationStrength::Contract => 0,
IntegrationStrength::Model => 1,
IntegrationStrength::Functional => 2,
IntegrationStrength::Intrusive => 3,
}
}
fn strength_rank(index: usize) -> usize {
index
}
fn strength_from_index(index: usize) -> IntegrationStrength {
match index {
0 => IntegrationStrength::Contract,
1 => IntegrationStrength::Model,
2 => IntegrationStrength::Functional,
_ => IntegrationStrength::Intrusive,
}
}
fn strength_label(strength: IntegrationStrength) -> &'static str {
match strength {
IntegrationStrength::Intrusive => "Intrusive",
IntegrationStrength::Functional => "Functional",
IntegrationStrength::Model => "Model",
IntegrationStrength::Contract => "Contract",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::metrics::coupling::CouplingMetrics;
use crate::volatility::Volatility;
fn external_coupling(
source: &str,
target: &str,
strength: IntegrationStrength,
) -> CouplingMetrics {
let mut coupling = CouplingMetrics::new(
source.to_string(),
format!("{target}::Item"),
strength,
Distance::DifferentCrate,
Volatility::Low,
);
coupling.target_crate = Some(target.to_string());
coupling
}
#[test]
fn aggregation_counts_breadth_per_external_crate() {
let mut metrics = ProjectMetrics::new();
metrics.add_coupling(external_coupling(
"app::a",
"serde",
IntegrationStrength::Model,
));
metrics.add_coupling(external_coupling(
"app::a",
"serde",
IntegrationStrength::Functional,
));
metrics.add_coupling(external_coupling(
"app::b",
"serde",
IntegrationStrength::Functional,
));
metrics.add_coupling(external_coupling(
"app::c",
"clap",
IntegrationStrength::Model,
));
metrics.add_coupling(CouplingMetrics::new(
"app::a".to_string(),
"app::internal".to_string(),
IntegrationStrength::Model,
Distance::DifferentModule,
Volatility::Low,
));
let mut versions = HashMap::new();
versions.insert("serde".to_string(), vec!["1.0.0".to_string()]);
let report = analyze_external_dependencies(&metrics, &versions);
let serde = report
.dependencies
.iter()
.find(|dependency| dependency.crate_name == "serde")
.unwrap();
assert_eq!(serde.breadth, 2);
assert_eq!(serde.total_references, 3);
assert_eq!(serde.dominant_strength, "Functional");
assert_eq!(serde.versions, vec!["1.0.0"]);
assert_eq!(serde.source_modules, vec!["app::a", "app::b"]);
}
#[test]
fn aggregation_filters_unresolved_type_names_when_dependency_inventory_exists() {
let mut metrics = ProjectMetrics::new();
metrics
.crate_dependencies
.insert("app".to_string(), vec!["serde".to_string()]);
metrics.add_coupling(external_coupling(
"app::a",
"serde",
IntegrationStrength::Model,
));
metrics.add_coupling(external_coupling(
"app::b",
"Vec",
IntegrationStrength::Functional,
));
let report = analyze_external_dependencies(&metrics, &HashMap::new());
assert_eq!(report.dependencies.len(), 1);
assert_eq!(report.dependencies[0].crate_name, "serde");
}
#[test]
fn cargo_lock_versions_are_parsed() {
let content = r#"
[[package]]
name = "serde"
version = "1.0.228"
[[package]]
name = "serde"
version = "1.0.229"
[[package]]
name = "clap"
version = "4.6.0"
"#;
let versions = parse_lock_versions(content).unwrap();
assert_eq!(
versions.get("serde").unwrap(),
&vec!["1.0.228".to_string(), "1.0.229".to_string()]
);
assert_eq!(versions.get("clap").unwrap(), &vec!["4.6.0".to_string()]);
}
}