1use crate::parsers::Dependency;
2use crate::pypi::PackageInfo;
3use crate::version::{Version, VersionSpec};
4use crate::cli::Args;
5
6#[derive(Debug, Clone)]
8pub struct DependencyCheck {
9 pub dependency: Dependency,
11 pub installed: Option<Version>,
13 pub in_range: Option<Version>,
15 pub latest: Version,
17 pub update_to: Option<VersionSpec>,
19}
20
21impl DependencyCheck {
22 pub fn has_update(&self) -> bool {
24 self.update_to.is_some()
25 }
26
27 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
51pub struct DependencyResolver {
53 args: Args,
54}
55
56impl DependencyResolver {
57 pub fn new(args: Args) -> Self {
58 Self { args }
59 }
60
61 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 let in_range = self.calculate_in_range(
73 &dependency.version_spec,
74 &package_info.versions,
75 installed,
76 );
77
78 let latest_same_major = self.calculate_latest_same_major(
80 &dependency.version_spec,
81 &package_info.versions,
82 installed,
83 );
84
85 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 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 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 fn calculate_in_range(
135 &self,
136 spec: &VersionSpec,
137 available_versions: &[Version],
138 installed: Option<&Version>,
139 ) -> Option<Version> {
140 let max_major = spec.max_major();
142
143 available_versions
144 .iter()
145 .filter(|v| {
146 if !spec.satisfies(v) {
148 return false;
149 }
150
151 match spec {
154 VersionSpec::Minimum(base) | VersionSpec::GreaterThan(base) => {
155 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 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 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 if let VersionSpec::Pinned(current_version) = current_spec {
189 if self.args.force_latest {
190 if current_version != latest {
192 return Some(VersionSpec::Pinned(latest.clone()));
193 }
194 return None;
195 } else if self.args.minor {
196 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 return None;
206 }
207 }
208
209 let target_version = if self.args.force_latest {
211 latest
213 } else {
214 in_range.as_ref()?
216 };
217
218 let needs_update = if let Some(base) = current_spec.base_version() {
220 base != target_version
221 } else {
222 true
224 };
225
226 if !needs_update {
227 return None;
228 }
229
230 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 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 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 assert_eq!(result.in_range.as_ref().unwrap().to_string(), "2.32.3");
337 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 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 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 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 assert!(result.update_to.is_none());
432 }
433
434 #[test]
435 fn test_unbounded_spec_with_higher_installed_version() {
436 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 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 let installed = Version::from_str("1.25.0").unwrap();
457 let result = resolver.resolve(&dep, &pkg_info, Some(&installed));
458
459 assert_eq!(result.in_range.as_ref().unwrap().to_string(), "1.25.0");
461
462 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 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 let installed = Version::from_str("1.20.0").unwrap();
489 let result = resolver.resolve(&dep, &pkg_info, Some(&installed));
490
491 assert_eq!(result.in_range.as_ref().unwrap().to_string(), "1.25.0");
493
494 assert!(result.update_to.is_some());
496 assert_eq!(result.update_to.unwrap().to_string(), ">=1.25.0");
497 }
498}