Skip to main content

upd/
align.rs

1//! Alignment module for ensuring consistent package versions across a repository.
2//!
3//! This module provides functionality to find the highest version of each package
4//! used across multiple dependency files and update all occurrences to that version.
5
6use 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/// Represents a single occurrence of a package in a file
17#[derive(Debug, Clone)]
18pub struct PackageOccurrence {
19    /// Path to the file containing this package
20    pub file_path: PathBuf,
21    /// Type of the dependency file
22    pub file_type: FileType,
23    /// Version string as it appears in the file
24    pub version: String,
25    /// Line number where the package is defined (if available)
26    pub line_number: Option<usize>,
27    /// Whether this package has upper bound constraints (e.g., <3.0)
28    pub has_upper_bound: bool,
29}
30
31/// Result of alignment analysis for a single package
32#[derive(Debug, Clone)]
33pub struct PackageAlignment {
34    /// Name of the package
35    pub package_name: String,
36    /// The highest version found across all occurrences
37    pub highest_version: String,
38    /// All occurrences of this package
39    pub occurrences: Vec<PackageOccurrence>,
40    /// Language/ecosystem of this package
41    pub lang: Lang,
42}
43
44impl PackageAlignment {
45    /// Returns true if any occurrence is misaligned (not at highest version)
46    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    /// Returns only the misaligned occurrences (excluding those at highest version or with constraints)
53    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/// Overall result of alignment analysis
62#[derive(Debug, Default)]
63pub struct AlignResult {
64    /// All packages found with their alignments
65    pub packages: Vec<PackageAlignment>,
66    /// Total count of misaligned package occurrences
67    pub misaligned_count: usize,
68    /// Total number of files scanned
69    pub total_files: usize,
70}
71
72/// Get the appropriate updater for a file type
73fn 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
89/// Convert from updater's ParsedDependency to PackageOccurrence
90fn 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
100/// Scan all dependency files and collect package versions grouped by package name and language
101pub 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
123/// Find the highest version for each package and identify misalignments
124pub 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        // Skip packages that only appear once (already "aligned")
129        if occurrences.len() <= 1 {
130            continue;
131        }
132
133        // Find highest version, only considering non-constrained occurrences
134        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    // Sort by package name for consistent output
153    result
154        .packages
155        .sort_by(|a, b| a.package_name.cmp(&b.package_name));
156
157    result
158}
159
160/// Find the highest stable version among occurrences
161fn find_highest_version(occurrences: &[PackageOccurrence], lang: Lang) -> Option<String> {
162    occurrences
163        .iter()
164        .filter(|o| !o.has_upper_bound) // Skip constrained versions
165        .filter(|o| is_stable_version(&o.version, lang)) // Skip pre-releases
166        .max_by(|a, b| compare_versions(&a.version, &b.version, lang))
167        .map(|o| o.version.clone())
168}
169
170/// Check if a version is stable (not a pre-release)
171fn is_stable_version(version: &str, lang: Lang) -> bool {
172    match lang {
173        Lang::Python => {
174            // Python pre-release indicators: a, b, rc, alpha, beta, dev
175            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            // Semver pre-release indicator: hyphen followed by identifier
185            !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
201/// Compare two versions within the same ecosystem
202fn 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
215/// Compare PEP 440 versions
216fn 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), // Fallback to string comparison
223    }
224}
225
226/// Compare semver versions
227fn compare_semver(a: &str, b: &str) -> std::cmp::Ordering {
228    // Clean up version strings (remove ^, ~, = prefixes)
229    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), // Fallback to string comparison
238    }
239}
240
241/// Compare Go module versions
242fn compare_go_version(a: &str, b: &str) -> std::cmp::Ordering {
243    // Go uses semver with 'v' prefix
244    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, // Has constraint, should be skipped
325                },
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        // No misalignment because the lower version has upper bound constraint
337        assert!(!alignment.has_misalignment());
338    }
339}