Skip to main content

pcu/
global.rs

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/// Source of a globally installed package
10#[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/// A globally installed package
28#[derive(Debug, Clone)]
29pub struct GlobalPackage {
30    pub name: String,
31    pub installed_version: Version,
32    pub source: GlobalSource,
33    /// Python version (only set for pip --user packages)
34    pub python_version: Option<String>,
35}
36
37/// Result of checking a global package
38#[derive(Debug, Clone)]
39pub struct GlobalCheck {
40    pub package: GlobalPackage,
41    pub latest: Version,
42    pub has_update: bool,
43}
44
45impl GlobalCheck {
46    /// Get update severity for coloring
47    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
66/// Discovers globally installed packages from various sources
67pub 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    /// Discover all globally installed packages
79    pub fn discover(&self) -> Vec<GlobalPackage> {
80        let mut packages = Vec::new();
81
82        // Try each source, silently skip if not available
83        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    /// Discover uv tools using `uv tool list`
91    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()), // uv not installed or failed, skip silently
99        }
100    }
101
102    /// Parse output of `uv tool list`
103    /// Format: "package_name vX.Y.Z" or "package_name X.Y.Z"
104    /// May also have lines starting with "-" for entry points (skip these)
105    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            // Parse "name vX.Y.Z" or "name X.Y.Z" format
115            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    /// Discover pipx packages
135    fn discover_pipx_packages(&self) -> Result<Vec<GlobalPackage>> {
136        // Try `pipx list --json` for structured output
137        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                // Fall back to scanning ~/.local/pipx/venvs/
145                self.discover_pipx_from_directory()
146            }
147        }
148    }
149
150    /// Parse pipx list --json output
151    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    /// Fall back: scan ~/.local/pipx/venvs/ directory
175    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                // Try to get version from the package's metadata
192                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    /// Get version of a pipx package by reading its dist-info
207    fn get_pipx_package_version(&self, venv_path: &Path, package_name: &str) -> Option<Version> {
208        // Look in the venv's site-packages for the dist-info
209        let site_packages = venv_path.join("lib");
210
211        if !site_packages.exists() {
212            return None;
213        }
214
215        // Find the python directory (e.g., python3.11)
216        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        // Look for the dist-info directory
227        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                // Check if this dist-info matches our package
237                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    /// Discover pip --user packages from ~/.local/lib/python3.x/site-packages/
248    /// Now tracks which Python version each package belongs to
249    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        // Find all python3.x directories and collect them sorted
263        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        // Sort by version descending so newer Python versions come first
272        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        // Track seen packages to avoid duplicates across Python versions
279        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            // Extract version like "3.11" from "python3.11"
284            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    /// Parse a site-packages directory for installed packages
300    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        // Look for .dist-info directories
309        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                // Parse: "package_name-1.2.3.dist-info"
315                if let Some((pkg_name, version)) = self.parse_dist_info_name(&name) {
316                    // Normalize package name for deduplication
317                    let normalized = pkg_name.to_lowercase().replace('-', "_");
318
319                    // Skip if we've already seen this package (from another Python version)
320                    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    /// Parse a dist-info directory name to extract package name and version
339    /// Format: "package_name-1.2.3.dist-info"
340    fn parse_dist_info_name(&self, name: &str) -> Option<(String, Version)> {
341        let without_suffix = name.strip_suffix(".dist-info")?;
342
343        // Find the last hyphen that separates name from version
344        // Version always starts with a digit
345        let mut split_idx = None;
346        for (i, c) in without_suffix.char_indices().rev() {
347            if c == '-' {
348                // Check if what follows is a version (starts with digit)
349                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
369/// Group checks by source for upgrade command generation
370pub 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
382/// Check if a Python version is available on the system
383pub fn is_python_available(version: &str) -> bool {
384    // Try python3.X --version
385    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
393/// Get the user's home directory path for display
394fn 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/// An upgrade command or a comment (for unavailable Python versions)
402#[derive(Debug, Clone)]
403pub enum UpgradeCommand {
404    /// A shell command to run
405    Command(String),
406    /// A comment (Python version not available)
407    Comment(String),
408}
409
410/// Generate upgrade commands for each source
411pub 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        // Group pip packages by Python version
425        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        // Generate a command for each Python version
441        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        // Note: HashMap iteration order is not guaranteed, so we check by finding
515        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        // Simple case
525        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        // Package with hyphen in name
532        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        // Package with underscore
539        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        // Major update
563        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        // Minor update
571        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        // Patch update
579        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        // No update
587        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        // Should have uv, pipx, and either a pip command or comment for 3.11
642        assert!(commands.len() >= 3);
643
644        // Check for uv command
645        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        // Check for pipx command
649        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        // Check for pip command or comment for Python 3.11
653        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}