Skip to main content

pcu/
uv_python.rs

1use crate::global::UpgradeCommand;
2use check_updates_core::Version;
3use anyhow::Result;
4use serde::Deserialize;
5use std::collections::{HashMap, HashSet};
6use std::path::PathBuf;
7use std::process::Command;
8use std::str::FromStr;
9
10/// Information about an installed uv-managed Python version
11#[derive(Debug, Clone)]
12pub struct UvPythonInfo {
13    /// Full implementation name (e.g., "cpython-3.11.5-linux-x86_64-gnu")
14    pub full_name: String,
15    /// Python version (e.g., "3.11.5")
16    pub version: Version,
17    /// Installation path (if installed, otherwise None)
18    pub path: Option<PathBuf>,
19    /// Whether this is installed or just available for download
20    pub is_installed: bool,
21    /// Python implementation type (cpython, pypy, graalpy, etc.)
22    pub implementation: String,
23}
24
25/// Result of checking a Python series for updates
26#[derive(Debug, Clone)]
27pub struct UvPythonCheck {
28    /// The major.minor series (e.g., "3.11")
29    pub series: String,
30    /// Currently installed version in this series
31    pub installed_version: Version,
32    /// Latest available patch in this series from endoflife.date
33    pub latest_version: Version,
34    /// Whether an update is available
35    pub has_update: bool,
36    /// Full uv python info for the installed version
37    pub python_info: UvPythonInfo,
38}
39
40impl UvPythonCheck {
41    /// Get update severity for coloring (patch or minor)
42    pub fn is_patch_update(&self) -> bool {
43        self.has_update
44            && self.latest_version.major == self.installed_version.major
45            && self.latest_version.minor == self.installed_version.minor
46    }
47}
48
49/// Response from endoflife.date API for a Python cycle
50#[derive(Debug, Deserialize)]
51struct PythonCycle {
52    cycle: String,      // "3.11", "3.12", etc.
53    latest: String,     // "3.11.14", "3.12.12", etc.
54}
55
56/// Discovery and checking for uv-managed Python installations
57pub struct UvPythonDiscovery {}
58
59impl Default for UvPythonDiscovery {
60    fn default() -> Self {
61        Self::new()
62    }
63}
64
65impl UvPythonDiscovery {
66    pub fn new() -> Self {
67        Self {}
68    }
69
70    /// Parse `uv python list` output to find installed Python versions
71    fn parse_uv_python_list(&self, output: &str) -> Result<Vec<UvPythonInfo>> {
72        let mut versions = Vec::new();
73
74        for line in output.lines() {
75            let line = line.trim();
76            if line.is_empty() {
77                continue;
78            }
79
80            let parts: Vec<&str> = line.split_whitespace().collect();
81            if parts.is_empty() {
82                continue;
83            }
84
85            let full_name = parts[0];
86
87            // Skip if not installed (has "<download available>" suffix)
88            let is_installed = !line.contains("<download available>");
89
90            // Parse: "cpython-3.11.5-linux-x86_64-gnu"
91            let name_parts: Vec<&str> = full_name.split('-').collect();
92            if name_parts.len() < 2 {
93                continue;
94            }
95
96            let implementation = name_parts[0]; // "cpython", "pypy", etc.
97            let version_str = name_parts[1]; // "3.11.5"
98
99            // Skip freethreaded variants for simplicity
100            if full_name.contains("+freethreaded") {
101                continue;
102            }
103
104            // Skip non-cpython for now (can extend later)
105            if implementation != "cpython" {
106                continue;
107            }
108
109            if let Ok(version) = Version::from_str(version_str) {
110                let path = if is_installed && parts.len() > 1 {
111                    Some(PathBuf::from(parts[1]))
112                } else {
113                    None
114                };
115
116                versions.push(UvPythonInfo {
117                    full_name: full_name.to_string(),
118                    version,
119                    path,
120                    is_installed,
121                    implementation: implementation.to_string(),
122                });
123            }
124        }
125
126        Ok(versions)
127    }
128
129    /// Fetch latest versions for all Python series from endoflife.date
130    async fn fetch_latest_python_versions(&self) -> Result<HashMap<String, Version>> {
131        let url = "https://endoflife.date/api/python.json";
132
133        let client = reqwest::Client::builder()
134            .timeout(std::time::Duration::from_secs(5))
135            .build()?;
136
137        let response = client.get(url).send().await?;
138
139        if !response.status().is_success() {
140            anyhow::bail!("Failed to fetch Python version data");
141        }
142
143        let cycles: Vec<PythonCycle> = response.json().await?;
144
145        // Build map of "3.11" -> "3.11.14", "3.12" -> "3.12.12", etc.
146        let mut versions = HashMap::new();
147        for cycle in cycles {
148            if cycle.cycle.starts_with("3.") {
149                // Only Python 3.x
150                if let Ok(version) = Version::from_str(&cycle.latest) {
151                    versions.insert(cycle.cycle.clone(), version);
152                }
153            }
154        }
155
156        Ok(versions)
157    }
158
159    /// Discover installed uv Python versions and check for updates
160    pub async fn discover_and_check(&self) -> Result<Vec<UvPythonCheck>> {
161        // 1. Run `uv python list`
162        let output = Command::new("uv").args(["python", "list"]).output();
163
164        let output = match output {
165            Ok(o) if o.status.success() => o,
166            _ => return Ok(Vec::new()), // uv not installed or failed
167        };
168
169        let stdout = String::from_utf8_lossy(&output.stdout);
170        let installed = self.parse_uv_python_list(&stdout)?;
171
172        // Filter to only installed versions
173        let installed: Vec<_> = installed
174            .into_iter()
175            .filter(|v| v.is_installed)
176            .collect();
177
178        if installed.is_empty() {
179            return Ok(Vec::new());
180        }
181
182        // 2. Fetch latest versions per series from endoflife.date
183        let latest_versions = self.fetch_latest_python_versions().await?;
184
185        // 3. Build checks grouped by series
186        let mut checks = Vec::new();
187        let mut seen_series = HashSet::new();
188
189        for python in installed {
190            let series = format!("{}.{}", python.version.major, python.version.minor);
191
192            // Only check each series once (if multiple same series installed)
193            if seen_series.contains(&series) {
194                continue;
195            }
196            seen_series.insert(series.clone());
197
198            if let Some(latest) = latest_versions.get(&series) {
199                let has_update = latest > &python.version;
200
201                checks.push(UvPythonCheck {
202                    series: series.clone(),
203                    installed_version: python.version.clone(),
204                    latest_version: latest.clone(),
205                    has_update,
206                    python_info: python,
207                });
208            }
209        }
210
211        Ok(checks)
212    }
213}
214
215/// Generate upgrade commands for outdated uv Python versions
216pub fn generate_uv_python_upgrade_commands(checks: &[UvPythonCheck]) -> Vec<UpgradeCommand> {
217    let mut commands = Vec::new();
218
219    let outdated: Vec<_> = checks.iter().filter(|c| c.has_update).collect();
220
221    if outdated.is_empty() {
222        return commands;
223    }
224
225    // Generate: uv python install 3.11.14
226    for check in outdated {
227        commands.push(UpgradeCommand::Command(format!(
228            "uv python install {}",
229            check.latest_version
230        )));
231    }
232
233    commands
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn test_parse_uv_python_list() {
242        let discovery = UvPythonDiscovery::new();
243        let output = r#"cpython-3.11.5-linux-x86_64-gnu     /home/user/.local/share/uv/python/cpython-3.11.5-linux-x86_64-gnu/bin/python3.11
244cpython-3.12.2-linux-x86_64-gnu     /usr/bin/python3.12
245cpython-3.13.0-linux-x86_64-gnu     <download available>
246"#;
247        let versions = discovery.parse_uv_python_list(output).unwrap();
248
249        // Should have 3 versions total (2 installed, 1 download available)
250        assert_eq!(versions.len(), 3);
251
252        assert_eq!(versions[0].version.to_string(), "3.11.5");
253        assert_eq!(versions[0].implementation, "cpython");
254        assert!(versions[0].is_installed);
255        assert!(versions[0].path.is_some());
256
257        assert_eq!(versions[1].version.to_string(), "3.12.2");
258        assert!(versions[1].is_installed);
259        assert!(versions[1].path.is_some());
260
261        assert_eq!(versions[2].version.to_string(), "3.13.0");
262        assert!(!versions[2].is_installed);
263        assert!(versions[2].path.is_none());
264    }
265
266    #[test]
267    fn test_parse_uv_python_list_skip_freethreaded() {
268        let discovery = UvPythonDiscovery::new();
269        let output = r#"cpython-3.13.0+freethreaded-linux-x86_64-gnu     /path/to/python
270cpython-3.12.2-linux-x86_64-gnu     /usr/bin/python3.12
271"#;
272        let versions = discovery.parse_uv_python_list(output).unwrap();
273
274        // Should only have 1 version (freethreaded skipped)
275        assert_eq!(versions.len(), 1);
276        assert_eq!(versions[0].version.to_string(), "3.12.2");
277    }
278
279    #[test]
280    fn test_parse_uv_python_list_skip_non_cpython() {
281        let discovery = UvPythonDiscovery::new();
282        let output = r#"pypy-3.10.14-linux-x86_64-gnu     /path/to/pypy
283cpython-3.12.2-linux-x86_64-gnu     /usr/bin/python3.12
284"#;
285        let versions = discovery.parse_uv_python_list(output).unwrap();
286
287        // Should only have cpython version
288        assert_eq!(versions.len(), 1);
289        assert_eq!(versions[0].implementation, "cpython");
290        assert_eq!(versions[0].version.to_string(), "3.12.2");
291    }
292
293    #[test]
294    fn test_generate_upgrade_commands() {
295        let checks = vec![
296            UvPythonCheck {
297                series: "3.11".to_string(),
298                installed_version: Version::from_str("3.11.5").unwrap(),
299                latest_version: Version::from_str("3.11.14").unwrap(),
300                has_update: true,
301                python_info: UvPythonInfo {
302                    full_name: "cpython-3.11.5-linux-x86_64-gnu".to_string(),
303                    version: Version::from_str("3.11.5").unwrap(),
304                    path: None,
305                    is_installed: true,
306                    implementation: "cpython".to_string(),
307                },
308            },
309            UvPythonCheck {
310                series: "3.12".to_string(),
311                installed_version: Version::from_str("3.12.2").unwrap(),
312                latest_version: Version::from_str("3.12.12").unwrap(),
313                has_update: true,
314                python_info: UvPythonInfo {
315                    full_name: "cpython-3.12.2-linux-x86_64-gnu".to_string(),
316                    version: Version::from_str("3.12.2").unwrap(),
317                    path: None,
318                    is_installed: true,
319                    implementation: "cpython".to_string(),
320                },
321            },
322        ];
323
324        let commands = generate_uv_python_upgrade_commands(&checks);
325        assert_eq!(commands.len(), 2);
326
327        match &commands[0] {
328            UpgradeCommand::Command(cmd) => {
329                assert_eq!(cmd, "uv python install 3.11.14");
330            }
331            _ => panic!("Expected Command"),
332        }
333
334        match &commands[1] {
335            UpgradeCommand::Command(cmd) => {
336                assert_eq!(cmd, "uv python install 3.12.12");
337            }
338            _ => panic!("Expected Command"),
339        }
340    }
341
342    #[test]
343    fn test_is_patch_update() {
344        let check = UvPythonCheck {
345            series: "3.11".to_string(),
346            installed_version: Version::from_str("3.11.5").unwrap(),
347            latest_version: Version::from_str("3.11.14").unwrap(),
348            has_update: true,
349            python_info: UvPythonInfo {
350                full_name: "cpython-3.11.5-linux-x86_64-gnu".to_string(),
351                version: Version::from_str("3.11.5").unwrap(),
352                path: None,
353                is_installed: true,
354                implementation: "cpython".to_string(),
355            },
356        };
357
358        assert!(check.is_patch_update());
359
360        // No update
361        let check_no_update = UvPythonCheck {
362            series: "3.11".to_string(),
363            installed_version: Version::from_str("3.11.14").unwrap(),
364            latest_version: Version::from_str("3.11.14").unwrap(),
365            has_update: false,
366            python_info: UvPythonInfo {
367                full_name: "cpython-3.11.14-linux-x86_64-gnu".to_string(),
368                version: Version::from_str("3.11.14").unwrap(),
369                path: None,
370                is_installed: true,
371                implementation: "cpython".to_string(),
372            },
373        };
374
375        assert!(!check_no_update.is_patch_update());
376    }
377}