python_check_updates/
resolver.rs

1use crate::parsers::Dependency;
2use crate::pypi::PackageInfo;
3use crate::version::{Version, VersionSpec};
4use crate::cli::Args;
5
6/// Result of checking a dependency
7#[derive(Debug, Clone)]
8pub struct DependencyCheck {
9    /// The original dependency
10    pub dependency: Dependency,
11    /// Currently installed version (from lock file)
12    pub installed: Option<Version>,
13    /// Latest version within the constraint (same major)
14    pub in_range: Option<Version>,
15    /// Absolute latest version
16    pub latest: Version,
17    /// What the spec will be updated to (None means no update)
18    pub update_to: Option<VersionSpec>,
19}
20
21impl DependencyCheck {
22    /// Check if this dependency has any update available
23    pub fn has_update(&self) -> bool {
24        self.update_to.is_some()
25    }
26
27    /// Get the update severity (major, minor, patch)
28    pub fn update_severity(&self) -> Option<UpdateSeverity> {
29        let current = self.installed.as_ref().or(self.dependency.version_spec.base_version())?;
30        let target = self.update_to.as_ref()?.base_version()?;
31
32        if target.major > current.major {
33            Some(UpdateSeverity::Major)
34        } else if target.minor > current.minor {
35            Some(UpdateSeverity::Minor)
36        } else if target.patch > current.patch {
37            Some(UpdateSeverity::Patch)
38        } else {
39            None
40        }
41    }
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum UpdateSeverity {
46    Major,
47    Minor,
48    Patch,
49}
50
51/// Resolves dependencies and determines what updates are available
52pub struct DependencyResolver {
53    args: Args,
54}
55
56impl DependencyResolver {
57    pub fn new(args: Args) -> Self {
58        Self { args }
59    }
60
61    /// Resolve a single dependency
62    pub fn resolve(
63        &self,
64        dependency: &Dependency,
65        package_info: &PackageInfo,
66        installed: Option<&Version>,
67    ) -> DependencyCheck {
68        let latest = package_info.latest.clone();
69
70        // Calculate "in range" - latest version that satisfies the constraint
71        // For unbounded specs, this means same major version (considering installed version)
72        let in_range = self.calculate_in_range(
73            &dependency.version_spec,
74            &package_info.versions,
75            installed,
76        );
77
78        // Calculate latest version with same major (for -m flag)
79        let latest_same_major = self.calculate_latest_same_major(
80            &dependency.version_spec,
81            &package_info.versions,
82            installed,
83        );
84
85        // Calculate what version spec to update to
86        let update_to = self.calculate_update_to(
87            &dependency.version_spec,
88            &in_range,
89            &latest,
90            &latest_same_major,
91            installed,
92        );
93
94        DependencyCheck {
95            dependency: dependency.clone(),
96            installed: installed.cloned(),
97            in_range,
98            latest,
99            update_to,
100        }
101    }
102
103    /// Calculate the latest version with the same major version
104    /// For unbounded specs, considers the installed version's major if higher
105    fn calculate_latest_same_major(
106        &self,
107        spec: &VersionSpec,
108        available_versions: &[Version],
109        installed: Option<&Version>,
110    ) -> Option<Version> {
111        let base = spec.base_version()?;
112
113        // For unbounded specs, use the installed version's major if it's higher
114        let target_major = match spec {
115            VersionSpec::Minimum(_) | VersionSpec::GreaterThan(_) => {
116                if let Some(inst) = installed {
117                    base.major.max(inst.major)
118                } else {
119                    base.major
120                }
121            }
122            _ => base.major,
123        };
124
125        available_versions
126            .iter()
127            .filter(|v| v.major == target_major)
128            .max()
129            .cloned()
130    }
131
132    /// Calculate the latest version "in range" for the constraint
133    /// For unbounded specs (>=X.Y.Z), considers installed version to determine target major
134    fn calculate_in_range(
135        &self,
136        spec: &VersionSpec,
137        available_versions: &[Version],
138        installed: Option<&Version>,
139    ) -> Option<Version> {
140        // Filter versions based on the spec's constraints
141        let max_major = spec.max_major();
142
143        available_versions
144            .iter()
145            .filter(|v| {
146                // First check if it satisfies the spec
147                if !spec.satisfies(v) {
148                    return false;
149                }
150
151                // For unbounded specs (Minimum), limit to same major version
152                // but consider installed version if it's on a higher major
153                match spec {
154                    VersionSpec::Minimum(base) | VersionSpec::GreaterThan(base) => {
155                        // If installed version exists and is on a higher major,
156                        // use that major for "in range" calculation
157                        let target_major = if let Some(inst) = installed {
158                            base.major.max(inst.major)
159                        } else {
160                            base.major
161                        };
162                        v.major == target_major
163                    }
164                    _ => {
165                        // For other specs, check against max_major if available
166                        if let Some(max_maj) = max_major {
167                            v.major <= max_maj
168                        } else {
169                            true
170                        }
171                    }
172                }
173            })
174            .max()
175            .cloned()
176    }
177
178    /// Calculate what the version spec should be updated to
179    fn calculate_update_to(
180        &self,
181        current_spec: &VersionSpec,
182        in_range: &Option<Version>,
183        latest: &Version,
184        latest_same_major: &Option<Version>,
185        installed: Option<&Version>,
186    ) -> Option<VersionSpec> {
187        // Handle pinned versions specially
188        if let VersionSpec::Pinned(current_version) = current_spec {
189            if self.args.force_latest {
190                // -f flag: update to absolute latest
191                if current_version != latest {
192                    return Some(VersionSpec::Pinned(latest.clone()));
193                }
194                return None;
195            } else if self.args.minor {
196                // -m flag: update to latest same major
197                if let Some(target) = latest_same_major {
198                    if current_version != target {
199                        return Some(VersionSpec::Pinned(target.clone()));
200                    }
201                }
202                return None;
203            } else {
204                // Default mode: pinned versions don't update
205                return None;
206            }
207        }
208
209        // For non-pinned specs
210        let target_version = if self.args.force_latest {
211            // -f flag: everything updates to absolute latest
212            latest
213        } else {
214            // Default/-m mode: update to in_range
215            in_range.as_ref()?
216        };
217
218        // Check if we need to update
219        let needs_update = if let Some(base) = current_spec.base_version() {
220            base != target_version
221        } else {
222            // Can't determine, assume update needed
223            true
224        };
225
226        if !needs_update {
227            return None;
228        }
229
230        // Don't suggest updates that would be downgrades from installed version
231        if let Some(inst) = installed {
232            if target_version < inst {
233                return None;
234            }
235        }
236
237        Some(current_spec.with_version(target_version))
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use crate::cli::Args;
245    use crate::parsers::Dependency;
246    use crate::version::{Version, VersionSpec};
247    use std::path::PathBuf;
248    use std::str::FromStr;
249
250    fn create_test_dependency(name: &str, spec_str: &str) -> Dependency {
251        Dependency {
252            name: name.to_string(),
253            version_spec: VersionSpec::parse(spec_str).unwrap(),
254            source_file: PathBuf::from("test.txt"),
255            line_number: 1,
256            original_line: format!("{}=={}", name, spec_str),
257        }
258    }
259
260    fn create_package_info(name: &str, versions: Vec<&str>) -> PackageInfo {
261        let version_objects: Vec<Version> = versions
262            .iter()
263            .map(|v| Version::from_str(v).unwrap())
264            .collect();
265        let latest = version_objects.last().unwrap().clone();
266
267        PackageInfo {
268            name: name.to_string(),
269            versions: version_objects,
270            latest: latest.clone(),
271            latest_stable: Some(latest),
272        }
273    }
274
275    #[test]
276    fn test_default_mode_pinned_no_update() {
277        let args = Args {
278            path: None,
279            global: false,
280            update: false,
281            minor: false,
282            force_latest: false,
283            pre_release: false,
284        };
285        let resolver = DependencyResolver::new(args);
286
287        let dep = create_test_dependency("requests", "==2.28.0");
288        let pkg_info = create_package_info("requests", vec!["2.28.0", "2.32.3", "3.1.0"]);
289
290        let result = resolver.resolve(&dep, &pkg_info, None);
291
292        // Default mode: pinned versions don't update
293        assert!(result.update_to.is_none());
294    }
295
296    #[test]
297    fn test_default_mode_range_updates_in_range() {
298        let args = Args {
299            path: None,
300            global: false,
301            update: false,
302            minor: false,
303            force_latest: false,
304            pre_release: false,
305        };
306        let resolver = DependencyResolver::new(args);
307
308        let dep = create_test_dependency("requests", ">=2.28.0,<3.0.0");
309        let pkg_info = create_package_info("requests", vec!["2.28.0", "2.32.3", "3.1.0"]);
310
311        let result = resolver.resolve(&dep, &pkg_info, None);
312
313        // Should update to latest in range (2.32.3)
314        assert!(result.update_to.is_some());
315        assert_eq!(result.in_range.unwrap().to_string(), "2.32.3");
316    }
317
318    #[test]
319    fn test_default_mode_unbounded_updates_same_major() {
320        let args = Args {
321            path: None,
322            global: false,
323            update: false,
324            minor: false,
325            force_latest: false,
326            pre_release: false,
327        };
328        let resolver = DependencyResolver::new(args);
329
330        let dep = create_test_dependency("requests", ">=2.28.0");
331        let pkg_info = create_package_info("requests", vec!["2.28.0", "2.32.3", "3.1.0"]);
332
333        let result = resolver.resolve(&dep, &pkg_info, None);
334
335        // In range should be 2.32.3 (latest same major)
336        assert_eq!(result.in_range.as_ref().unwrap().to_string(), "2.32.3");
337        // Should update to >=2.32.3
338        assert!(result.update_to.is_some());
339    }
340
341    #[test]
342    fn test_minor_flag_pinned_updates_same_major() {
343        let args = Args {
344            path: None,
345            global: false,
346            update: false,
347            minor: true,
348            force_latest: false,
349            pre_release: false,
350        };
351        let resolver = DependencyResolver::new(args);
352
353        let dep = create_test_dependency("numpy", "==1.24.0");
354        let pkg_info = create_package_info("numpy", vec!["1.24.0", "1.26.0", "2.1.0"]);
355
356        let result = resolver.resolve(&dep, &pkg_info, None);
357
358        // -m flag: should update pinned to latest same major (1.26.0)
359        assert!(result.update_to.is_some());
360        if let Some(VersionSpec::Pinned(v)) = &result.update_to {
361            assert_eq!(v.major, 1);
362            assert_eq!(v.minor, 26);
363        } else {
364            panic!("Expected pinned version spec");
365        }
366    }
367
368    #[test]
369    fn test_force_latest_flag_all_update_to_latest() {
370        let args = Args {
371            path: None,
372            global: false,
373            update: false,
374            minor: false,
375            force_latest: true,
376            pre_release: false,
377        };
378        let resolver = DependencyResolver::new(args);
379
380        let dep = create_test_dependency("flask", "^2.0.0");
381        let pkg_info = create_package_info("flask", vec!["2.0.0", "2.3.3", "3.0.0"]);
382
383        let result = resolver.resolve(&dep, &pkg_info, None);
384
385        // -f flag: should update to absolute latest (3.0.0)
386        assert!(result.update_to.is_some());
387        if let Some(spec) = &result.update_to {
388            assert_eq!(spec.base_version().unwrap().to_string(), "3.0.0");
389        }
390    }
391
392    #[test]
393    fn test_caret_constraint_same_major() {
394        let args = Args {
395            path: None,
396            global: false,
397            update: false,
398            minor: false,
399            force_latest: false,
400            pre_release: false,
401        };
402        let resolver = DependencyResolver::new(args);
403
404        let dep = create_test_dependency("flask", "^2.0.0");
405        let pkg_info = create_package_info("flask", vec!["2.0.0", "2.3.3", "3.0.0"]);
406
407        let result = resolver.resolve(&dep, &pkg_info, None);
408
409        // Caret means same major, in_range should be 2.3.3
410        assert_eq!(result.in_range.as_ref().unwrap().to_string(), "2.3.3");
411    }
412
413    #[test]
414    fn test_no_update_when_already_latest() {
415        let args = Args {
416            path: None,
417            global: false,
418            update: false,
419            minor: false,
420            force_latest: false,
421            pre_release: false,
422        };
423        let resolver = DependencyResolver::new(args);
424
425        let dep = create_test_dependency("flask", ">=2.3.3");
426        let pkg_info = create_package_info("flask", vec!["2.0.0", "2.3.3"]);
427
428        let result = resolver.resolve(&dep, &pkg_info, None);
429
430        // Already at latest in range, no update
431        assert!(result.update_to.is_none());
432    }
433
434    #[test]
435    fn test_unbounded_spec_with_higher_installed_version() {
436        // Test case: spec is >=0.1.0 but installed is 1.25.0
437        // Should suggest updating constraint to >=1.25.0 (not >=0.9.1 which would be wrong)
438        let args = Args {
439            path: None,
440            global: false,
441            update: false,
442            minor: false,
443            force_latest: false,
444            pre_release: false,
445        };
446        let resolver = DependencyResolver::new(args);
447
448        let dep = create_test_dependency("mcp", ">=0.1.0");
449        // Available: 0.1.0, 0.5.0, 0.9.1, 1.0.0, 1.20.0, 1.25.0
450        let pkg_info = create_package_info(
451            "mcp",
452            vec!["0.1.0", "0.5.0", "0.9.1", "1.0.0", "1.20.0", "1.25.0"],
453        );
454
455        // Installed version is 1.25.0 (from lock file)
456        let installed = Version::from_str("1.25.0").unwrap();
457        let result = resolver.resolve(&dep, &pkg_info, Some(&installed));
458
459        // in_range should be 1.25.0 (latest in same major as installed)
460        assert_eq!(result.in_range.as_ref().unwrap().to_string(), "1.25.0");
461
462        // Should suggest updating constraint from >=0.1.0 to >=1.25.0
463        assert!(result.update_to.is_some());
464        assert_eq!(result.update_to.unwrap().to_string(), ">=1.25.0");
465    }
466
467    #[test]
468    fn test_unbounded_spec_with_higher_installed_but_newer_available() {
469        // Test case: spec is >=0.1.0, installed is 1.20.0, latest is 1.25.0
470        // Should suggest updating to >=1.25.0
471        let args = Args {
472            path: None,
473            global: false,
474            update: false,
475            minor: false,
476            force_latest: false,
477            pre_release: false,
478        };
479        let resolver = DependencyResolver::new(args);
480
481        let dep = create_test_dependency("mcp", ">=0.1.0");
482        let pkg_info = create_package_info(
483            "mcp",
484            vec!["0.1.0", "0.5.0", "0.9.1", "1.0.0", "1.20.0", "1.25.0"],
485        );
486
487        // Installed version is 1.20.0
488        let installed = Version::from_str("1.20.0").unwrap();
489        let result = resolver.resolve(&dep, &pkg_info, Some(&installed));
490
491        // in_range should be 1.25.0 (latest in major 1.x)
492        assert_eq!(result.in_range.as_ref().unwrap().to_string(), "1.25.0");
493
494        // Should suggest updating to >=1.25.0
495        assert!(result.update_to.is_some());
496        assert_eq!(result.update_to.unwrap().to_string(), ">=1.25.0");
497    }
498}