1use check_updates_core::{UpdateSeverity, Version};
2use anyhow::Result;
3use std::collections::{HashMap, HashSet};
4use std::fs;
5use std::path::Path;
6use std::process::Command;
7use std::str::FromStr;
8
9#[derive(Debug, Clone, PartialEq, Eq, Hash)]
11pub enum GlobalSource {
12 Uv,
13 Pipx,
14 PipUser,
15}
16
17impl std::fmt::Display for GlobalSource {
18 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19 match self {
20 GlobalSource::Uv => write!(f, "uv"),
21 GlobalSource::Pipx => write!(f, "pipx"),
22 GlobalSource::PipUser => write!(f, "pip"),
23 }
24 }
25}
26
27#[derive(Debug, Clone)]
29pub struct GlobalPackage {
30 pub name: String,
31 pub installed_version: Version,
32 pub source: GlobalSource,
33 pub python_version: Option<String>,
35}
36
37#[derive(Debug, Clone)]
39pub struct GlobalCheck {
40 pub package: GlobalPackage,
41 pub latest: Version,
42 pub has_update: bool,
43}
44
45impl GlobalCheck {
46 pub fn update_severity(&self) -> Option<UpdateSeverity> {
48 if !self.has_update {
49 return None;
50 }
51 let current = &self.package.installed_version;
52 let target = &self.latest;
53
54 if target.major > current.major {
55 Some(UpdateSeverity::Major)
56 } else if target.minor > current.minor {
57 Some(UpdateSeverity::Minor)
58 } else if target.patch > current.patch {
59 Some(UpdateSeverity::Patch)
60 } else {
61 None
62 }
63 }
64}
65
66pub struct GlobalPackageDiscovery {
68 _include_prerelease: bool,
69}
70
71impl GlobalPackageDiscovery {
72 pub fn new(include_prerelease: bool) -> Self {
73 Self {
74 _include_prerelease: include_prerelease,
75 }
76 }
77
78 pub fn discover(&self) -> Vec<GlobalPackage> {
80 let mut packages = Vec::new();
81
82 packages.extend(self.discover_uv_tools().unwrap_or_default());
84 packages.extend(self.discover_pipx_packages().unwrap_or_default());
85 packages.extend(self.discover_pip_user_packages().unwrap_or_default());
86
87 packages
88 }
89
90 fn discover_uv_tools(&self) -> Result<Vec<GlobalPackage>> {
92 let output = Command::new("uv").args(["tool", "list"]).output();
93
94 match output {
95 Ok(output) if output.status.success() => {
96 self.parse_uv_tool_list(&String::from_utf8_lossy(&output.stdout))
97 }
98 _ => Ok(Vec::new()), }
100 }
101
102 fn parse_uv_tool_list(&self, output: &str) -> Result<Vec<GlobalPackage>> {
106 let mut packages = Vec::new();
107
108 for line in output.lines() {
109 let line = line.trim();
110 if line.is_empty() || line.starts_with('-') {
111 continue;
112 }
113
114 let parts: Vec<&str> = line.split_whitespace().collect();
116 if parts.len() >= 2 {
117 let name = parts[0].to_string();
118 let version_str = parts[1].trim_start_matches('v');
119
120 if let Ok(version) = Version::from_str(version_str) {
121 packages.push(GlobalPackage {
122 name,
123 installed_version: version,
124 source: GlobalSource::Uv,
125 python_version: None,
126 });
127 }
128 }
129 }
130
131 Ok(packages)
132 }
133
134 fn discover_pipx_packages(&self) -> Result<Vec<GlobalPackage>> {
136 let output = Command::new("pipx").args(["list", "--json"]).output();
138
139 match output {
140 Ok(output) if output.status.success() => {
141 self.parse_pipx_json(&String::from_utf8_lossy(&output.stdout))
142 }
143 _ => {
144 self.discover_pipx_from_directory()
146 }
147 }
148 }
149
150 fn parse_pipx_json(&self, json_str: &str) -> Result<Vec<GlobalPackage>> {
152 let data: serde_json::Value = serde_json::from_str(json_str)?;
153 let mut packages = Vec::new();
154
155 if let Some(venvs) = data.get("venvs").and_then(|v| v.as_object()) {
156 for (name, venv_data) in venvs {
157 if let Some(version_str) = venv_data
158 .pointer("/metadata/main_package/package_version")
159 .and_then(|v| v.as_str())
160 && let Ok(version) = Version::from_str(version_str) {
161 packages.push(GlobalPackage {
162 name: name.clone(),
163 installed_version: version,
164 source: GlobalSource::Pipx,
165 python_version: None,
166 });
167 }
168 }
169 }
170
171 Ok(packages)
172 }
173
174 fn discover_pipx_from_directory(&self) -> Result<Vec<GlobalPackage>> {
176 let pipx_dir = dirs::home_dir()
177 .map(|h| h.join(".local/pipx/venvs"))
178 .filter(|p| p.exists());
179
180 let Some(pipx_dir) = pipx_dir else {
181 return Ok(Vec::new());
182 };
183
184 let mut packages = Vec::new();
185
186 for entry in fs::read_dir(&pipx_dir)? {
187 let entry = entry?;
188 if entry.path().is_dir() {
189 let name = entry.file_name().to_string_lossy().to_string();
190
191 if let Some(version) = self.get_pipx_package_version(&entry.path(), &name) {
193 packages.push(GlobalPackage {
194 name,
195 installed_version: version,
196 source: GlobalSource::Pipx,
197 python_version: None,
198 });
199 }
200 }
201 }
202
203 Ok(packages)
204 }
205
206 fn get_pipx_package_version(&self, venv_path: &Path, package_name: &str) -> Option<Version> {
208 let site_packages = venv_path.join("lib");
210
211 if !site_packages.exists() {
212 return None;
213 }
214
215 let python_dir = fs::read_dir(&site_packages)
217 .ok()?
218 .filter_map(std::result::Result::ok)
219 .find(|e| e.file_name().to_string_lossy().starts_with("python"))?;
220
221 let actual_site_packages = python_dir.path().join("site-packages");
222 if !actual_site_packages.exists() {
223 return None;
224 }
225
226 let normalized_name = package_name.to_lowercase().replace('-', "_");
228 for entry in fs::read_dir(&actual_site_packages).ok()? {
229 let entry = entry.ok()?;
230 let name = entry.file_name().to_string_lossy().to_string();
231 if name.ends_with(".dist-info") {
232 let dist_name = name
233 .strip_suffix(".dist-info")?
234 .to_lowercase()
235 .replace('-', "_");
236 if dist_name.starts_with(&normalized_name)
238 && let Some((_, version)) = self.parse_dist_info_name(&name) {
239 return Some(version);
240 }
241 }
242 }
243
244 None
245 }
246
247 fn discover_pip_user_packages(&self) -> Result<Vec<GlobalPackage>> {
250 let user_lib = dirs::home_dir().map(|h| h.join(".local/lib"));
251
252 let Some(user_lib) = user_lib else {
253 return Ok(Vec::new());
254 };
255
256 if !user_lib.exists() {
257 return Ok(Vec::new());
258 }
259
260 let mut packages = Vec::new();
261
262 let mut python_dirs: Vec<_> = fs::read_dir(&user_lib)?
264 .filter_map(std::result::Result::ok)
265 .filter(|e| {
266 let name = e.file_name().to_string_lossy().to_string();
267 name.starts_with("python3.") || name.starts_with("python2.")
268 })
269 .collect();
270
271 python_dirs.sort_by(|a, b| {
273 let a_name = a.file_name().to_string_lossy().to_string();
274 let b_name = b.file_name().to_string_lossy().to_string();
275 b_name.cmp(&a_name)
276 });
277
278 let mut seen_packages: HashSet<String> = HashSet::new();
280
281 for entry in python_dirs {
282 let dir_name = entry.file_name().to_string_lossy().to_string();
283 let python_version = dir_name.strip_prefix("python").unwrap_or(&dir_name);
285
286 let site_packages = entry.path().join("site-packages");
287 if site_packages.exists() {
288 packages.extend(self.parse_site_packages(
289 &site_packages,
290 python_version,
291 &mut seen_packages,
292 )?);
293 }
294 }
295
296 Ok(packages)
297 }
298
299 fn parse_site_packages(
301 &self,
302 site_packages: &Path,
303 python_version: &str,
304 seen: &mut HashSet<String>,
305 ) -> Result<Vec<GlobalPackage>> {
306 let mut packages = Vec::new();
307
308 for entry in fs::read_dir(site_packages)? {
310 let entry = entry?;
311 let name = entry.file_name().to_string_lossy().to_string();
312
313 if name.ends_with(".dist-info") {
314 if let Some((pkg_name, version)) = self.parse_dist_info_name(&name) {
316 let normalized = pkg_name.to_lowercase().replace('-', "_");
318
319 if seen.contains(&normalized) {
321 continue;
322 }
323 seen.insert(normalized);
324
325 packages.push(GlobalPackage {
326 name: pkg_name,
327 installed_version: version,
328 source: GlobalSource::PipUser,
329 python_version: Some(python_version.to_string()),
330 });
331 }
332 }
333 }
334
335 Ok(packages)
336 }
337
338 fn parse_dist_info_name(&self, name: &str) -> Option<(String, Version)> {
341 let without_suffix = name.strip_suffix(".dist-info")?;
342
343 let mut split_idx = None;
346 for (i, c) in without_suffix.char_indices().rev() {
347 if c == '-' {
348 if without_suffix[i + 1..]
350 .chars()
351 .next()
352 .is_some_and(|c| c.is_ascii_digit())
353 {
354 split_idx = Some(i);
355 break;
356 }
357 }
358 }
359
360 let idx = split_idx?;
361 let pkg_name = &without_suffix[..idx];
362 let version_str = &without_suffix[idx + 1..];
363
364 let version = Version::from_str(version_str).ok()?;
365 Some((pkg_name.to_string(), version))
366 }
367}
368
369pub fn group_by_source(checks: &[GlobalCheck]) -> HashMap<GlobalSource, Vec<&GlobalCheck>> {
371 checks
372 .iter()
373 .filter(|c| c.has_update)
374 .fold(HashMap::new(), |mut acc, check| {
375 acc.entry(check.package.source.clone())
376 .or_insert_with(Vec::new)
377 .push(check);
378 acc
379 })
380}
381
382pub fn is_python_available(version: &str) -> bool {
384 let cmd = format!("python{version}");
386 Command::new(&cmd)
387 .arg("--version")
388 .output()
389 .map(|o| o.status.success())
390 .unwrap_or(false)
391}
392
393fn get_pip_user_path(python_version: &str) -> String {
395 dirs::home_dir()
396 .map(|h| h.join(format!(".local/lib/python{python_version}")))
397 .map(|p| p.display().to_string())
398 .unwrap_or_else(|| format!("~/.local/lib/python{python_version}"))
399}
400
401#[derive(Debug, Clone)]
403pub enum UpgradeCommand {
404 Command(String),
406 Comment(String),
408}
409
410pub fn generate_upgrade_commands(checks: &[GlobalCheck]) -> Vec<UpgradeCommand> {
412 let updates_by_source = group_by_source(checks);
413 let mut commands = Vec::new();
414
415 if updates_by_source.contains_key(&GlobalSource::Uv) {
416 commands.push(UpgradeCommand::Command("uv tool upgrade --all".to_string()));
417 }
418
419 if updates_by_source.contains_key(&GlobalSource::Pipx) {
420 commands.push(UpgradeCommand::Command("pipx upgrade-all".to_string()));
421 }
422
423 if let Some(pip_updates) = updates_by_source.get(&GlobalSource::PipUser) {
424 let mut by_python: std::collections::BTreeMap<String, Vec<&str>> =
426 std::collections::BTreeMap::new();
427
428 for check in pip_updates {
429 let py_version = check
430 .package
431 .python_version
432 .clone()
433 .unwrap_or_else(|| "unknown".to_string());
434 by_python
435 .entry(py_version)
436 .or_default()
437 .push(check.package.name.as_str());
438 }
439
440 for (py_version, package_names) in by_python {
442 if is_python_available(&py_version) {
443 commands.push(UpgradeCommand::Command(format!(
444 "python{} -m pip install --user --upgrade {}",
445 py_version,
446 package_names.join(" ")
447 )));
448 } else {
449 let path = get_pip_user_path(&py_version);
450 commands.push(UpgradeCommand::Comment(format!(
451 "Python {py_version} is no longer installed. Consider removing {path} if nothing uses it."
452 )));
453 }
454 }
455 }
456
457 commands
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463
464 #[test]
465 fn test_parse_uv_tool_list() {
466 let discovery = GlobalPackageDiscovery::new(false);
467 let output = r#"ruff v0.14.10
468 - ruff
469ty v0.0.5
470 - ty
471"#;
472 let packages = discovery.parse_uv_tool_list(output).unwrap();
473 assert_eq!(packages.len(), 2);
474 assert_eq!(packages[0].name, "ruff");
475 assert_eq!(packages[0].installed_version.to_string(), "0.14.10");
476 assert_eq!(packages[0].source, GlobalSource::Uv);
477 assert_eq!(packages[1].name, "ty");
478 assert_eq!(packages[1].installed_version.to_string(), "0.0.5");
479 }
480
481 #[test]
482 fn test_parse_uv_tool_list_without_v_prefix() {
483 let discovery = GlobalPackageDiscovery::new(false);
484 let output = "black 24.10.0\n";
485 let packages = discovery.parse_uv_tool_list(output).unwrap();
486 assert_eq!(packages.len(), 1);
487 assert_eq!(packages[0].name, "black");
488 assert_eq!(packages[0].installed_version.to_string(), "24.10.0");
489 }
490
491 #[test]
492 fn test_parse_pipx_json() {
493 let discovery = GlobalPackageDiscovery::new(false);
494 let json = r#"{
495 "venvs": {
496 "black": {
497 "metadata": {
498 "main_package": {
499 "package_version": "24.10.0"
500 }
501 }
502 },
503 "ruff": {
504 "metadata": {
505 "main_package": {
506 "package_version": "0.14.9"
507 }
508 }
509 }
510 }
511 }"#;
512 let packages = discovery.parse_pipx_json(json).unwrap();
513 assert_eq!(packages.len(), 2);
514 let black = packages.iter().find(|p| p.name == "black").unwrap();
516 assert_eq!(black.installed_version.to_string(), "24.10.0");
517 assert_eq!(black.source, GlobalSource::Pipx);
518 }
519
520 #[test]
521 fn test_parse_dist_info_name() {
522 let discovery = GlobalPackageDiscovery::new(false);
523
524 let result = discovery.parse_dist_info_name("requests-2.28.0.dist-info");
526 assert!(result.is_some());
527 let (name, version) = result.unwrap();
528 assert_eq!(name, "requests");
529 assert_eq!(version.to_string(), "2.28.0");
530
531 let result = discovery.parse_dist_info_name("typing-extensions-4.12.2.dist-info");
533 assert!(result.is_some());
534 let (name, version) = result.unwrap();
535 assert_eq!(name, "typing-extensions");
536 assert_eq!(version.to_string(), "4.12.2");
537
538 let result = discovery.parse_dist_info_name("my_package-1.0.0.dist-info");
540 assert!(result.is_some());
541 let (name, version) = result.unwrap();
542 assert_eq!(name, "my_package");
543 assert_eq!(version.to_string(), "1.0.0");
544 }
545
546 #[test]
547 fn test_global_source_display() {
548 assert_eq!(GlobalSource::Uv.to_string(), "uv");
549 assert_eq!(GlobalSource::Pipx.to_string(), "pipx");
550 assert_eq!(GlobalSource::PipUser.to_string(), "pip");
551 }
552
553 #[test]
554 fn test_update_severity() {
555 let pkg = GlobalPackage {
556 name: "test".to_string(),
557 installed_version: Version::from_str("1.0.0").unwrap(),
558 source: GlobalSource::Uv,
559 python_version: None,
560 };
561
562 let check = GlobalCheck {
564 package: pkg.clone(),
565 latest: Version::from_str("2.0.0").unwrap(),
566 has_update: true,
567 };
568 assert_eq!(check.update_severity(), Some(UpdateSeverity::Major));
569
570 let check = GlobalCheck {
572 package: pkg.clone(),
573 latest: Version::from_str("1.1.0").unwrap(),
574 has_update: true,
575 };
576 assert_eq!(check.update_severity(), Some(UpdateSeverity::Minor));
577
578 let check = GlobalCheck {
580 package: pkg.clone(),
581 latest: Version::from_str("1.0.1").unwrap(),
582 has_update: true,
583 };
584 assert_eq!(check.update_severity(), Some(UpdateSeverity::Patch));
585
586 let check = GlobalCheck {
588 package: pkg,
589 latest: Version::from_str("1.0.0").unwrap(),
590 has_update: false,
591 };
592 assert_eq!(check.update_severity(), None);
593 }
594
595 #[test]
596 fn test_generate_upgrade_commands() {
597 let checks = vec![
598 GlobalCheck {
599 package: GlobalPackage {
600 name: "ruff".to_string(),
601 installed_version: Version::from_str("0.14.9").unwrap(),
602 source: GlobalSource::Uv,
603 python_version: None,
604 },
605 latest: Version::from_str("0.14.10").unwrap(),
606 has_update: true,
607 },
608 GlobalCheck {
609 package: GlobalPackage {
610 name: "black".to_string(),
611 installed_version: Version::from_str("24.1.0").unwrap(),
612 source: GlobalSource::Pipx,
613 python_version: None,
614 },
615 latest: Version::from_str("24.10.0").unwrap(),
616 has_update: true,
617 },
618 GlobalCheck {
619 package: GlobalPackage {
620 name: "requests".to_string(),
621 installed_version: Version::from_str("2.28.0").unwrap(),
622 source: GlobalSource::PipUser,
623 python_version: Some("3.11".to_string()),
624 },
625 latest: Version::from_str("2.32.3").unwrap(),
626 has_update: true,
627 },
628 GlobalCheck {
629 package: GlobalPackage {
630 name: "flask".to_string(),
631 installed_version: Version::from_str("2.3.3").unwrap(),
632 source: GlobalSource::PipUser,
633 python_version: Some("3.11".to_string()),
634 },
635 latest: Version::from_str("3.0.0").unwrap(),
636 has_update: true,
637 },
638 ];
639
640 let commands = generate_upgrade_commands(&checks);
641 assert!(commands.len() >= 3);
643
644 let has_uv = commands.iter().any(|c| matches!(c, UpgradeCommand::Command(s) if s == "uv tool upgrade --all"));
646 assert!(has_uv, "Should have uv upgrade command");
647
648 let has_pipx = commands.iter().any(|c| matches!(c, UpgradeCommand::Command(s) if s == "pipx upgrade-all"));
650 assert!(has_pipx, "Should have pipx upgrade command");
651
652 let has_pip_311 = commands.iter().any(|c| {
654 match c {
655 UpgradeCommand::Command(s) => s.contains("python3.11") && s.contains("requests") && s.contains("flask"),
656 UpgradeCommand::Comment(s) => s.contains("3.11"),
657 }
658 });
659 assert!(has_pip_311, "Should have pip command or comment for Python 3.11");
660 }
661}