Skip to main content

rta_core/analyzers/
dependencies.rs

1use std::collections::BTreeSet;
2
3use crate::{analyzers::Analyzer, collector::RepositorySnapshot, model::DependencyHealth};
4
5pub struct DependencyAnalyzer;
6
7impl Analyzer<DependencyHealth> for DependencyAnalyzer {
8    fn analyze(&self, snapshot: &RepositorySnapshot) -> DependencyHealth {
9        let mut direct = BTreeSet::new();
10        let mut critical_dependencies = Vec::new();
11        let mut outdated_indicators = Vec::new();
12        let mut maintenance_risks = Vec::new();
13
14        for manifest in &snapshot.manifests {
15            for (name, version) in manifest
16                .dependencies
17                .iter()
18                .chain(manifest.dev_dependencies.iter())
19                .chain(manifest.build_dependencies.iter())
20            {
21                direct.insert(name.clone());
22                if is_critical(name) {
23                    critical_dependencies.push(format!("{name} in {}", manifest.relative_path));
24                }
25                if version.contains('*') || version.contains("path") || version.contains("git") {
26                    maintenance_risks.push(format!(
27                        "{name} uses a non-registry or broad version declaration in {}",
28                        manifest.relative_path
29                    ));
30                }
31                if version.starts_with("0.") || version.contains("\"0.") {
32                    outdated_indicators.push(format!(
33                        "{name} is pinned to pre-1.0 API surface in {}",
34                        manifest.relative_path
35                    ));
36                }
37            }
38        }
39
40        let total_dependencies = direct.len();
41        if total_dependencies > 80 {
42            maintenance_risks
43                .push("Very high direct dependency count for due diligence review.".into());
44        }
45
46        let mut score = 100_i32;
47        score -= (maintenance_risks.len() as i32 * 8).min(32);
48        score -= (outdated_indicators.len() as i32 * 4).min(24);
49        score -= if total_dependencies > 60 { 15 } else { 0 };
50        score -= if critical_dependencies.len() > 12 {
51            8
52        } else {
53            0
54        };
55
56        DependencyHealth {
57            total_dependencies,
58            direct_dependencies: total_dependencies,
59            critical_dependencies,
60            outdated_indicators,
61            maintenance_risks,
62            score: score.clamp(0, 100) as u8,
63        }
64    }
65}
66
67fn is_critical(name: &str) -> bool {
68    matches!(
69        name,
70        "tokio"
71            | "serde"
72            | "sqlx"
73            | "diesel"
74            | "axum"
75            | "actix-web"
76            | "hyper"
77            | "reqwest"
78            | "openssl"
79            | "ring"
80            | "rustls"
81            | "tonic"
82            | "prost"
83    )
84}
85
86#[cfg(test)]
87mod tests {
88    use std::{collections::BTreeMap, path::PathBuf};
89
90    use crate::{
91        analyzers::Analyzer,
92        collector::{CargoManifest, RepositorySnapshot},
93    };
94
95    use super::DependencyAnalyzer;
96
97    #[test]
98    fn empty_repository_has_no_dependency_risk() {
99        let report = DependencyAnalyzer.analyze(&snapshot(Vec::new()));
100
101        assert_eq!(report.direct_dependencies, 0);
102        assert_eq!(report.score, 100);
103    }
104
105    #[test]
106    fn typical_manifest_detects_direct_and_critical_dependencies() {
107        let report = DependencyAnalyzer.analyze(&snapshot(vec![manifest(
108            "Cargo.toml",
109            "app",
110            [("serde", "1"), ("tokio", "1")],
111            [],
112            [],
113        )]));
114
115        assert_eq!(report.direct_dependencies, 2);
116        assert_eq!(report.critical_dependencies.len(), 2);
117        assert_eq!(report.score, 100);
118    }
119
120    #[test]
121    fn extreme_dependency_count_is_penalized() {
122        let dependencies = (0..81)
123            .map(|index| (format!("dep_{index}"), "1".to_string()))
124            .collect::<BTreeMap<_, _>>();
125        let report = DependencyAnalyzer.analyze(&snapshot(vec![CargoManifest {
126            relative_path: "Cargo.toml".into(),
127            package_name: Some("app".into()),
128            workspace_members: Vec::new(),
129            dependencies,
130            dev_dependencies: BTreeMap::new(),
131            build_dependencies: BTreeMap::new(),
132        }]));
133
134        assert_eq!(report.direct_dependencies, 81);
135        assert!(report.score < 90);
136        assert!(report
137            .maintenance_risks
138            .iter()
139            .any(|risk| risk.contains("Very high direct dependency count")));
140    }
141
142    #[test]
143    fn adversarial_dependency_names_do_not_match_critical_substrings() {
144        let report = DependencyAnalyzer.analyze(&snapshot(vec![manifest(
145            "Cargo.toml",
146            "app",
147            [("serde_fake", "1"), ("tokioish", "1")],
148            [],
149            [],
150        )]));
151
152        assert!(report.critical_dependencies.is_empty());
153    }
154
155    fn snapshot(manifests: Vec<CargoManifest>) -> RepositorySnapshot {
156        RepositorySnapshot {
157            root: PathBuf::from("/tmp/repo"),
158            files: Vec::new(),
159            manifests,
160        }
161    }
162
163    fn manifest<const D: usize, const DEV: usize, const B: usize>(
164        relative_path: &str,
165        package_name: &str,
166        dependencies: [(&str, &str); D],
167        dev_dependencies: [(&str, &str); DEV],
168        build_dependencies: [(&str, &str); B],
169    ) -> CargoManifest {
170        CargoManifest {
171            relative_path: relative_path.into(),
172            package_name: Some(package_name.into()),
173            workspace_members: Vec::new(),
174            dependencies: dependency_map(dependencies),
175            dev_dependencies: dependency_map(dev_dependencies),
176            build_dependencies: dependency_map(build_dependencies),
177        }
178    }
179
180    fn dependency_map<const N: usize>(dependencies: [(&str, &str); N]) -> BTreeMap<String, String> {
181        dependencies
182            .into_iter()
183            .map(|(name, version)| (name.to_string(), version.to_string()))
184            .collect()
185    }
186}