Skip to main content

cargo_coupling/balance/
external_crates.rs

1// ===== External Crate Heuristics =====
2
3use crate::external::ExternalDependencyUsage;
4use crate::{CouplingIssue, IssueType, RefactoringAction, Severity};
5
6/// Number of internal modules above which direct third-party usage is considered scattered.
7pub const SCATTERED_EXTERNAL_BREADTH_THRESHOLD: usize = 3;
8
9/// Crate stability classification
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum CrateStability {
12    /// Rust language fundamentals (std, core, alloc) - always ignore
13    Fundamental,
14    /// Highly stable, ubiquitous crates (serde, thiserror) - low concern
15    Stable,
16    /// Infrastructure crates (tokio, tracing) - medium concern
17    Infrastructure,
18    /// Regular external crate - normal analysis
19    Normal,
20}
21
22/// Check the stability classification of a crate
23pub fn classify_crate_stability(crate_name: &str) -> CrateStability {
24    // Extract the base crate name (before ::)
25    let base_name = crate_name.split("::").next().unwrap_or(crate_name).trim();
26
27    match base_name {
28        // Rust fundamentals - always safe to depend on
29        "std" | "core" | "alloc" => CrateStability::Fundamental,
30
31        // Highly stable, de-facto standard crates
32        "serde" | "serde_json" | "serde_yaml" | "toml" |  // Serialization
33        "thiserror" | "anyhow" |                           // Error handling
34        "log" |                                            // Logging trait
35        "chrono" | "time" |                                // Date/time
36        "uuid" |                                           // UUIDs
37        "regex" |                                          // Regex
38        "lazy_static" | "once_cell" |                      // Statics
39        "bytes" | "memchr" |                               // Byte utilities
40        "itertools" |                                      // Iterator utilities
41        "derive_more" | "strum"                            // Derive macros
42        => CrateStability::Stable,
43
44        // Infrastructure crates - stable but architectural decisions
45        "tokio" | "async-std" | "smol" |                   // Async runtimes
46        "async-trait" |                                    // Async traits
47        "futures" | "futures-util" |                       // Futures
48        "tracing" | "tracing-subscriber" |                 // Tracing
49        "tracing-opentelemetry" | "opentelemetry" |        // Observability
50        "opentelemetry-otlp" | "opentelemetry_sdk" |
51        "hyper" | "reqwest" | "http" |                     // HTTP
52        "tonic" | "prost" |                                // gRPC
53        "sqlx" | "diesel" | "sea-orm" |                    // Database
54        "clap" | "structopt"                               // CLI
55        => CrateStability::Infrastructure,
56
57        // Everything else
58        _ => CrateStability::Normal,
59    }
60}
61
62/// Check if a crate should be excluded from issue detection
63pub fn should_skip_crate(crate_name: &str) -> bool {
64    matches!(
65        classify_crate_stability(crate_name),
66        CrateStability::Fundamental
67    )
68}
69
70/// Check if a crate should have reduced severity
71pub fn should_reduce_severity(crate_name: &str) -> bool {
72    matches!(
73        classify_crate_stability(crate_name),
74        CrateStability::Stable | CrateStability::Infrastructure
75    )
76}
77
78/// Check if this is an external crate (not part of the workspace)
79/// External crates are identified by not containing "::" or starting with known external patterns
80pub fn is_external_crate(target: &str, source: &str) -> bool {
81    // If target doesn't have ::, it might be external
82    // But we need to check if it's the same workspace member
83
84    // Extract the crate/module prefix
85    let target_prefix = target.split("::").next().unwrap_or(target);
86    let source_prefix = source.split("::").next().unwrap_or(source);
87
88    // If they have the same prefix, it's internal
89    if target_prefix == source_prefix {
90        return false;
91    }
92
93    // If target looks like an external crate pattern (no workspace prefix match)
94    // Check if it's a known stable/infrastructure crate
95    let stability = classify_crate_stability(target);
96    matches!(
97        stability,
98        CrateStability::Fundamental | CrateStability::Stable | CrateStability::Infrastructure
99    )
100}
101
102/// Detect external crates used directly from many internal modules.
103pub fn detect_scattered_external_coupling(
104    dependencies: &[ExternalDependencyUsage],
105) -> Vec<CouplingIssue> {
106    dependencies
107        .iter()
108        .filter(|dependency| dependency.breadth > SCATTERED_EXTERNAL_BREADTH_THRESHOLD)
109        .map(|dependency| {
110            let severity = scattered_severity(dependency.breadth);
111            CouplingIssue {
112                issue_type: IssueType::ScatteredExternalCoupling,
113                severity,
114                source: format!("{} internal modules", dependency.breadth),
115                target: dependency.crate_name.clone(),
116                description: format!(
117                    "{} is directly used from {} internal modules ({} references). Third-party upgrade risk is spread across the codebase.",
118                    dependency.crate_name, dependency.breadth, dependency.total_references
119                ),
120                refactoring: RefactoringAction::General {
121                    action: format!(
122                        "Introduce a `{}` facade/wrapper module and route direct crate usage through it",
123                        facade_module_name(&dependency.crate_name)
124                    ),
125                },
126                balance_score: scattered_balance_score(dependency.breadth),
127            }
128        })
129        .collect()
130}
131
132fn scattered_severity(breadth: usize) -> Severity {
133    if breadth >= 10 {
134        Severity::Critical
135    } else if breadth >= 6 {
136        Severity::High
137    } else {
138        Severity::Medium
139    }
140}
141
142fn scattered_balance_score(breadth: usize) -> f64 {
143    1.0 - (breadth as f64 / 12.0).min(1.0)
144}
145
146fn facade_module_name(crate_name: &str) -> String {
147    format!("{}_facade", crate_name.replace('-', "_"))
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn scattered_coupling_is_flagged_above_threshold() {
156        let dependency = ExternalDependencyUsage {
157            crate_name: "reqwest".to_string(),
158            versions: vec![],
159            breadth: 4,
160            total_references: 9,
161            dominant_strength: "Functional".to_string(),
162            source_modules: vec![
163                "a".to_string(),
164                "b".to_string(),
165                "c".to_string(),
166                "d".to_string(),
167            ],
168        };
169
170        let issues = detect_scattered_external_coupling(&[dependency]);
171
172        assert_eq!(issues.len(), 1);
173        assert_eq!(issues[0].issue_type, IssueType::ScatteredExternalCoupling);
174        assert_eq!(issues[0].severity, Severity::Medium);
175        assert_eq!(issues[0].target, "reqwest");
176        assert!(format!("{}", issues[0].refactoring).contains("facade"));
177    }
178}