rta_core/analyzers/
dependencies.rs1use 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}