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#[derive(Debug, Clone)]
12pub struct UvPythonInfo {
13 pub full_name: String,
15 pub version: Version,
17 pub path: Option<PathBuf>,
19 pub is_installed: bool,
21 pub implementation: String,
23}
24
25#[derive(Debug, Clone)]
27pub struct UvPythonCheck {
28 pub series: String,
30 pub installed_version: Version,
32 pub latest_version: Version,
34 pub has_update: bool,
36 pub python_info: UvPythonInfo,
38}
39
40impl UvPythonCheck {
41 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#[derive(Debug, Deserialize)]
51struct PythonCycle {
52 cycle: String, latest: String, }
55
56pub 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 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 let is_installed = !line.contains("<download available>");
89
90 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]; let version_str = name_parts[1]; if full_name.contains("+freethreaded") {
101 continue;
102 }
103
104 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 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 let mut versions = HashMap::new();
147 for cycle in cycles {
148 if cycle.cycle.starts_with("3.") {
149 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 pub async fn discover_and_check(&self) -> Result<Vec<UvPythonCheck>> {
161 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()), };
168
169 let stdout = String::from_utf8_lossy(&output.stdout);
170 let installed = self.parse_uv_python_list(&stdout)?;
171
172 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 let latest_versions = self.fetch_latest_python_versions().await?;
184
185 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 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
215pub 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 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 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 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 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 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}