1use crate::updater::{
7 CargoTomlUpdater, CsprojUpdater, FileType, GemfileUpdater, GithubActionsUpdater, GoModUpdater,
8 Lang, MiseUpdater, PackageJsonUpdater, ParsedDependency, PreCommitUpdater, PyProjectUpdater,
9 RequirementsUpdater, TerraformUpdater, Updater,
10};
11use anyhow::Result;
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14use std::str::FromStr;
15
16#[derive(Debug, Clone)]
18pub struct PackageOccurrence {
19 pub file_path: PathBuf,
21 pub file_type: FileType,
23 pub version: String,
25 pub line_number: Option<usize>,
27 pub has_upper_bound: bool,
29}
30
31#[derive(Debug, Clone)]
33pub struct PackageAlignment {
34 pub package_name: String,
36 pub highest_version: String,
38 pub occurrences: Vec<PackageOccurrence>,
40 pub lang: Lang,
42}
43
44impl PackageAlignment {
45 pub fn has_misalignment(&self) -> bool {
47 self.occurrences
48 .iter()
49 .any(|o| !o.has_upper_bound && o.version != self.highest_version)
50 }
51
52 pub fn misaligned_occurrences(&self) -> Vec<&PackageOccurrence> {
54 self.occurrences
55 .iter()
56 .filter(|o| !o.has_upper_bound && o.version != self.highest_version)
57 .collect()
58 }
59}
60
61#[derive(Debug, Default)]
63pub struct AlignResult {
64 pub packages: Vec<PackageAlignment>,
66 pub misaligned_count: usize,
68 pub total_files: usize,
70}
71
72fn get_updater(file_type: FileType) -> Box<dyn Updater> {
74 match file_type {
75 FileType::Requirements => Box::new(RequirementsUpdater::new()),
76 FileType::PyProject => Box::new(PyProjectUpdater::new()),
77 FileType::PackageJson => Box::new(PackageJsonUpdater::new()),
78 FileType::CargoToml => Box::new(CargoTomlUpdater::new()),
79 FileType::GoMod => Box::new(GoModUpdater::new()),
80 FileType::Gemfile => Box::new(GemfileUpdater::new()),
81 FileType::Csproj => Box::new(CsprojUpdater::new()),
82 FileType::GithubActions => Box::new(GithubActionsUpdater::new()),
83 FileType::PreCommitConfig => Box::new(PreCommitUpdater::new()),
84 FileType::MiseToml | FileType::ToolVersions => Box::new(MiseUpdater::new()),
85 FileType::TerraformTf => Box::new(TerraformUpdater::new()),
86 }
87}
88
89fn to_occurrence(dep: &ParsedDependency, path: &Path, file_type: FileType) -> PackageOccurrence {
91 PackageOccurrence {
92 file_path: path.to_path_buf(),
93 file_type,
94 version: dep.version.clone(),
95 line_number: dep.line_number,
96 has_upper_bound: dep.has_upper_bound,
97 }
98}
99
100pub fn scan_packages(
102 files: &[(PathBuf, FileType)],
103) -> Result<HashMap<(String, Lang), Vec<PackageOccurrence>>> {
104 let mut packages: HashMap<(String, Lang), Vec<PackageOccurrence>> = HashMap::new();
105
106 for (path, file_type) in files {
107 let updater = get_updater(*file_type);
108 let deps = updater.parse_dependencies(path)?;
109 let lang = file_type.lang();
110
111 for dep in deps {
112 let key = (dep.name.to_lowercase(), lang);
113 packages
114 .entry(key)
115 .or_default()
116 .push(to_occurrence(&dep, path, *file_type));
117 }
118 }
119
120 Ok(packages)
121}
122
123pub fn find_alignments(packages: HashMap<(String, Lang), Vec<PackageOccurrence>>) -> AlignResult {
125 let mut result = AlignResult::default();
126
127 for ((package_name, lang), occurrences) in packages {
128 if occurrences.len() <= 1 {
130 continue;
131 }
132
133 let highest = find_highest_version(&occurrences, lang);
135
136 if let Some(highest_version) = highest {
137 let alignment = PackageAlignment {
138 package_name: package_name.clone(),
139 highest_version: highest_version.clone(),
140 occurrences,
141 lang,
142 };
143
144 if alignment.has_misalignment() {
145 result.misaligned_count += alignment.misaligned_occurrences().len();
146 }
147
148 result.packages.push(alignment);
149 }
150 }
151
152 result
154 .packages
155 .sort_by(|a, b| a.package_name.cmp(&b.package_name));
156
157 result
158}
159
160fn find_highest_version(occurrences: &[PackageOccurrence], lang: Lang) -> Option<String> {
162 occurrences
163 .iter()
164 .filter(|o| !o.has_upper_bound) .filter(|o| is_stable_version(&o.version, lang)) .max_by(|a, b| compare_versions(&a.version, &b.version, lang))
167 .map(|o| o.version.clone())
168}
169
170fn is_stable_version(version: &str, lang: Lang) -> bool {
172 match lang {
173 Lang::Python => {
174 let v = version.to_lowercase();
176 !v.contains("a")
177 && !v.contains("b")
178 && !v.contains("rc")
179 && !v.contains("alpha")
180 && !v.contains("beta")
181 && !v.contains("dev")
182 }
183 Lang::Node | Lang::Rust | Lang::Go | Lang::DotNet => {
184 !version.contains('-')
186 }
187 Lang::Ruby => {
188 let v = version.to_lowercase();
189 !v.contains(".pre")
190 && !v.contains(".rc")
191 && !v.contains(".beta")
192 && !v.contains(".alpha")
193 }
194 Lang::Actions | Lang::PreCommit | Lang::Mise | Lang::Terraform => {
195 let v = version.strip_prefix('v').unwrap_or(version);
196 !v.contains('-')
197 }
198 }
199}
200
201fn compare_versions(a: &str, b: &str, lang: Lang) -> std::cmp::Ordering {
203 match lang {
204 Lang::Python => compare_pep440(a, b),
205 Lang::Node | Lang::Rust | Lang::Ruby | Lang::DotNet => compare_semver(a, b),
206 Lang::Go => compare_go_version(a, b),
207 Lang::Actions | Lang::PreCommit | Lang::Mise | Lang::Terraform => {
208 let clean_a = a.trim_start_matches('v');
209 let clean_b = b.trim_start_matches('v');
210 compare_semver(clean_a, clean_b)
211 }
212 }
213}
214
215fn compare_pep440(a: &str, b: &str) -> std::cmp::Ordering {
217 match (
218 pep440_rs::Version::from_str(a),
219 pep440_rs::Version::from_str(b),
220 ) {
221 (Ok(va), Ok(vb)) => va.cmp(&vb),
222 _ => a.cmp(b), }
224}
225
226fn compare_semver(a: &str, b: &str) -> std::cmp::Ordering {
228 let clean_a = a.trim_start_matches(['^', '~', '=', 'v']);
230 let clean_b = b.trim_start_matches(['^', '~', '=', 'v']);
231
232 match (
233 semver::Version::parse(clean_a),
234 semver::Version::parse(clean_b),
235 ) {
236 (Ok(va), Ok(vb)) => va.cmp(&vb),
237 _ => clean_a.cmp(clean_b), }
239}
240
241fn compare_go_version(a: &str, b: &str) -> std::cmp::Ordering {
243 let clean_a = a.trim_start_matches('v');
245 let clean_b = b.trim_start_matches('v');
246 compare_semver(clean_a, clean_b)
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 #[test]
254 fn test_is_stable_version_python() {
255 assert!(is_stable_version("1.0.0", Lang::Python));
256 assert!(is_stable_version("2.31.0", Lang::Python));
257 assert!(!is_stable_version("1.0.0a1", Lang::Python));
258 assert!(!is_stable_version("1.0.0b2", Lang::Python));
259 assert!(!is_stable_version("1.0.0rc1", Lang::Python));
260 assert!(!is_stable_version("1.0.0dev1", Lang::Python));
261 assert!(!is_stable_version("1.0.0alpha", Lang::Python));
262 assert!(!is_stable_version("1.0.0beta", Lang::Python));
263 }
264
265 #[test]
266 fn test_is_stable_version_semver() {
267 assert!(is_stable_version("1.0.0", Lang::Node));
268 assert!(is_stable_version("4.17.21", Lang::Rust));
269 assert!(!is_stable_version("1.0.0-alpha", Lang::Node));
270 assert!(!is_stable_version("1.0.0-beta.1", Lang::Rust));
271 assert!(!is_stable_version("1.0.0-rc.1", Lang::Go));
272 }
273
274 #[test]
275 fn test_compare_versions_semver() {
276 use std::cmp::Ordering;
277 assert_eq!(compare_semver("1.0.0", "2.0.0"), Ordering::Less);
278 assert_eq!(compare_semver("2.0.0", "1.0.0"), Ordering::Greater);
279 assert_eq!(compare_semver("1.0.0", "1.0.0"), Ordering::Equal);
280 assert_eq!(compare_semver("1.5.0", "1.10.0"), Ordering::Less);
281 assert_eq!(compare_semver("^1.0.0", "^2.0.0"), Ordering::Less);
282 }
283
284 #[test]
285 fn test_package_alignment_has_misalignment() {
286 let alignment = PackageAlignment {
287 package_name: "requests".to_string(),
288 highest_version: "2.31.0".to_string(),
289 lang: Lang::Python,
290 occurrences: vec![
291 PackageOccurrence {
292 file_path: PathBuf::from("requirements.txt"),
293 file_type: FileType::Requirements,
294 version: "2.28.0".to_string(),
295 line_number: Some(1),
296 has_upper_bound: false,
297 },
298 PackageOccurrence {
299 file_path: PathBuf::from("requirements-dev.txt"),
300 file_type: FileType::Requirements,
301 version: "2.31.0".to_string(),
302 line_number: Some(1),
303 has_upper_bound: false,
304 },
305 ],
306 };
307
308 assert!(alignment.has_misalignment());
309 assert_eq!(alignment.misaligned_occurrences().len(), 1);
310 }
311
312 #[test]
313 fn test_package_alignment_skips_constrained() {
314 let alignment = PackageAlignment {
315 package_name: "django".to_string(),
316 highest_version: "4.2.0".to_string(),
317 lang: Lang::Python,
318 occurrences: vec![
319 PackageOccurrence {
320 file_path: PathBuf::from("requirements.txt"),
321 file_type: FileType::Requirements,
322 version: "3.2.0".to_string(),
323 line_number: Some(1),
324 has_upper_bound: true, },
326 PackageOccurrence {
327 file_path: PathBuf::from("requirements-dev.txt"),
328 file_type: FileType::Requirements,
329 version: "4.2.0".to_string(),
330 line_number: Some(1),
331 has_upper_bound: false,
332 },
333 ],
334 };
335
336 assert!(!alignment.has_misalignment());
338 }
339}