Skip to main content

check_updates_core/
resolver.rs

1use crate::types::{Dependency, DependencyCheck, PackageInfo, UpdateSeverity};
2use crate::version::{Version, VersionSpec};
3
4/// Resolves dependencies and determines what updates are available
5pub struct DependencyResolver;
6
7impl DependencyResolver {
8    pub fn new() -> Self {
9        Self
10    }
11
12    /// Resolve a single dependency
13    pub fn resolve(
14        &self,
15        dependency: &Dependency,
16        package_info: &PackageInfo,
17        installed: Option<&Version>,
18    ) -> DependencyCheck {
19        let latest = package_info.latest.clone();
20
21        // Calculate "in range" - latest version that satisfies the constraint
22        let in_range = self.calculate_in_range(
23            &dependency.version_spec,
24            &package_info.versions,
25            installed,
26        );
27
28        // Determine the target version for display
29        let current = installed.or_else(|| dependency.version_spec.base_version());
30
31        let (target, target_spec) = self.calculate_target(
32            &dependency.version_spec,
33            &in_range,
34            &latest,
35            current,
36        );
37
38        // Calculate severity based on current → target
39        let severity = Self::calculate_severity(current, target.as_ref());
40
41        // Calculate force spec (to absolute latest)
42        let force_spec = self.calculate_force_spec(
43            &dependency.version_spec,
44            &latest,
45            current,
46        );
47
48        DependencyCheck {
49            dependency: dependency.clone(),
50            installed: installed.cloned(),
51            in_range,
52            latest,
53            target,
54            target_spec,
55            severity,
56            force_spec,
57        }
58    }
59
60    /// Calculate the target version and spec for display
61    fn calculate_target(
62        &self,
63        current_spec: &VersionSpec,
64        in_range: &Option<Version>,
65        latest: &Version,
66        current: Option<&Version>,
67    ) -> (Option<Version>, Option<VersionSpec>) {
68        let current = match current {
69            Some(c) => c,
70            // No current version (no lockfile, no base_version for Complex specs).
71            // Still report latest as target so the dependency shows up in review,
72            // but with no spec (can't determine severity or offer a rewrite).
73            None => return (Some(latest.clone()), None),
74        };
75
76        // Check if in_range is an update
77        if let Some(ir) = in_range
78            && ir > current {
79                let spec = if current_spec.is_rewritable() {
80                    Some(current_spec.with_version(ir))
81                } else {
82                    None
83                };
84                return (Some(ir.clone()), spec);
85            }
86
87        // No in-range update, check if latest is an update
88        if latest > current {
89            let spec = if current_spec.is_rewritable() {
90                Some(current_spec.with_version(latest))
91            } else {
92                None
93            };
94            return (Some(latest.clone()), spec);
95        }
96
97        (None, None)
98    }
99
100    /// Calculate force spec (to absolute latest)
101    fn calculate_force_spec(
102        &self,
103        current_spec: &VersionSpec,
104        latest: &Version,
105        current: Option<&Version>,
106    ) -> Option<VersionSpec> {
107        let current = current?;
108
109        if latest > current && current_spec.is_rewritable() {
110            Some(current_spec.with_version(latest))
111        } else {
112            None
113        }
114    }
115
116    /// Calculate the severity of an update
117    pub fn calculate_severity(
118        current: Option<&Version>,
119        target: Option<&Version>,
120    ) -> Option<UpdateSeverity> {
121        let current = current?;
122        let target = target?;
123
124        if target.major > current.major {
125            Some(UpdateSeverity::Major)
126        } else if target.minor > current.minor {
127            Some(UpdateSeverity::Minor)
128        } else if target.patch > current.patch {
129            Some(UpdateSeverity::Patch)
130        } else {
131            None
132        }
133    }
134
135    /// Calculate the latest version "in range" for the constraint
136    fn calculate_in_range(
137        &self,
138        spec: &VersionSpec,
139        available_versions: &[Version],
140        installed: Option<&Version>,
141    ) -> Option<Version> {
142        available_versions
143            .iter()
144            .filter(|v| {
145                // Must satisfy the spec
146                if !spec.satisfies(v) {
147                    return false;
148                }
149
150                // For unbounded specs (Minimum, GreaterThan), limit to same major
151                match spec {
152                    VersionSpec::Minimum(base) | VersionSpec::GreaterThan(base) => {
153                        let target_major = if let Some(inst) = installed {
154                            base.major.max(inst.major)
155                        } else {
156                            base.major
157                        };
158                        v.major == target_major
159                    }
160                    _ => true,
161                }
162            })
163            .max()
164            .cloned()
165    }
166}
167
168impl Default for DependencyResolver {
169    fn default() -> Self {
170        Self::new()
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use std::path::PathBuf;
178    use std::str::FromStr;
179
180    fn create_test_dependency(name: &str, spec_str: &str) -> Dependency {
181        Dependency {
182            name: name.to_string(),
183            version_spec: VersionSpec::parse(spec_str).unwrap(),
184            source_file: PathBuf::from("test.txt"),
185            line_number: 1,
186            original_line: format!("{}=={}", name, spec_str),
187        }
188    }
189
190    fn create_package_info(name: &str, versions: Vec<&str>) -> PackageInfo {
191        let version_objects: Vec<Version> = versions
192            .iter()
193            .map(|v| Version::from_str(v).unwrap())
194            .collect();
195        let latest = version_objects.last().unwrap().clone();
196
197        PackageInfo {
198            name: name.to_string(),
199            versions: version_objects,
200            latest: latest.clone(),
201            latest_stable: Some(latest),
202        }
203    }
204
205    #[test]
206    fn test_in_range_update() {
207        let resolver = DependencyResolver::new();
208        let dep = create_test_dependency("requests", ">=2.28.0,<3.0.0");
209        let pkg_info = create_package_info("requests", vec!["2.28.0", "2.32.3", "3.1.0"]);
210
211        let installed = Version::from_str("2.28.0").unwrap();
212        let result = resolver.resolve(&dep, &pkg_info, Some(&installed));
213
214        // Target should be 2.32.3 (in-range update)
215        assert!(result.target.is_some());
216        assert_eq!(result.target.as_ref().unwrap().to_string(), "2.32.3");
217        assert_eq!(result.severity, Some(UpdateSeverity::Minor));
218
219        // Should have newer available (3.1.0)
220        assert!(result.has_newer_available());
221    }
222
223    #[test]
224    fn test_force_only_update() {
225        let resolver = DependencyResolver::new();
226        let dep = create_test_dependency("flask", "^2.0.0");
227        let pkg_info = create_package_info("flask", vec!["2.0.0", "2.3.3", "3.0.0"]);
228
229        // Installed at latest in-range (2.3.3)
230        let installed = Version::from_str("2.3.3").unwrap();
231        let result = resolver.resolve(&dep, &pkg_info, Some(&installed));
232
233        // Target should be 3.0.0 (no in-range update, so force)
234        assert!(result.target.is_some());
235        assert_eq!(result.target.as_ref().unwrap().to_string(), "3.0.0");
236        assert_eq!(result.severity, Some(UpdateSeverity::Major));
237
238        // No newer available (target IS the latest)
239        assert!(!result.has_newer_available());
240    }
241
242    #[test]
243    fn test_no_update_needed() {
244        let resolver = DependencyResolver::new();
245        let dep = create_test_dependency("flask", ">=2.3.3");
246        let pkg_info = create_package_info("flask", vec!["2.0.0", "2.3.3"]);
247
248        let installed = Version::from_str("2.3.3").unwrap();
249        let result = resolver.resolve(&dep, &pkg_info, Some(&installed));
250
251        // No update needed
252        assert!(result.target.is_none());
253        assert!(!result.has_update());
254    }
255}