python_check_updates/parsers/
lockfiles.rs

1use crate::version::Version;
2use anyhow::{Context, Result};
3use serde::Deserialize;
4use std::collections::HashMap;
5use std::fs;
6use std::path::PathBuf;
7use std::str::FromStr;
8
9/// Parser for various lock files to get installed versions
10pub struct LockfileParser;
11
12/// Structure for parsing uv.lock and poetry.lock [[package]] sections
13#[derive(Debug, Deserialize)]
14struct TomlPackage {
15    name: String,
16    version: String,
17}
18
19/// Structure for parsing TOML lock files with [[package]] arrays
20#[derive(Debug, Deserialize)]
21struct TomlLockFile {
22    package: Vec<TomlPackage>,
23}
24
25/// Structure for parsing pdm.lock which has [package.metadata] section
26#[derive(Debug, Deserialize)]
27struct PdmLockFile {
28    package: Vec<PdmPackage>,
29}
30
31#[derive(Debug, Deserialize)]
32struct PdmPackage {
33    name: String,
34    version: String,
35}
36
37impl LockfileParser {
38    pub fn new() -> Self {
39        Self
40    }
41
42    /// Parse a lock file and return a map of package name -> installed version
43    pub fn parse(&self, path: &PathBuf) -> Result<HashMap<String, Version>> {
44        let filename = path
45            .file_name()
46            .and_then(|n| n.to_str())
47            .context("Invalid lock file path")?;
48
49        match filename {
50            "uv.lock" => self.parse_uv_lock(path),
51            "poetry.lock" => self.parse_poetry_lock(path),
52            "pdm.lock" => self.parse_pdm_lock(path),
53            _ => anyhow::bail!("Unsupported lock file: {}", filename),
54        }
55    }
56
57    /// Try to find and parse any lock file in the given directory
58    pub fn find_and_parse(&self, dir: &PathBuf) -> Result<HashMap<String, Version>> {
59        // Priority order: uv.lock, poetry.lock, pdm.lock
60        let lock_files = ["uv.lock", "poetry.lock", "pdm.lock"];
61
62        for filename in &lock_files {
63            let lock_path = dir.join(filename);
64            if lock_path.exists() {
65                return self.parse(&lock_path);
66            }
67        }
68
69        // No lock file found - return empty map
70        Ok(HashMap::new())
71    }
72
73    /// Check if we can parse this lock file
74    pub fn can_parse(&self, path: &PathBuf) -> bool {
75        path.file_name()
76            .and_then(|n| n.to_str())
77            .map(|n| {
78                n == "uv.lock"
79                    || n == "poetry.lock"
80                    || n == "pdm.lock"
81                    || n == "Pipfile.lock"
82                    || n == "conda-lock.yml"
83            })
84            .unwrap_or(false)
85    }
86
87    /// Parse uv.lock file (TOML format with [[package]] sections)
88    fn parse_uv_lock(&self, path: &PathBuf) -> Result<HashMap<String, Version>> {
89        let content = fs::read_to_string(path)
90            .with_context(|| format!("Failed to read uv.lock at {:?}", path))?;
91
92        let lock_file: TomlLockFile = toml::from_str(&content)
93            .with_context(|| format!("Failed to parse uv.lock at {:?}", path))?;
94
95        let mut versions = HashMap::new();
96        for package in lock_file.package {
97            // Normalize package name to lowercase
98            let name = package.name.to_lowercase().replace('_', "-");
99
100            match Version::from_str(&package.version) {
101                Ok(version) => {
102                    versions.insert(name, version);
103                }
104                Err(e) => {
105                    // Log warning but continue parsing other packages
106                    eprintln!(
107                        "Warning: Failed to parse version '{}' for package '{}': {}",
108                        package.version, package.name, e
109                    );
110                }
111            }
112        }
113
114        Ok(versions)
115    }
116
117    /// Parse poetry.lock file (TOML format with [[package]] sections)
118    fn parse_poetry_lock(&self, path: &PathBuf) -> Result<HashMap<String, Version>> {
119        let content = fs::read_to_string(path)
120            .with_context(|| format!("Failed to read poetry.lock at {:?}", path))?;
121
122        let lock_file: TomlLockFile = toml::from_str(&content)
123            .with_context(|| format!("Failed to parse poetry.lock at {:?}", path))?;
124
125        let mut versions = HashMap::new();
126        for package in lock_file.package {
127            // Normalize package name to lowercase
128            let name = package.name.to_lowercase().replace('_', "-");
129
130            match Version::from_str(&package.version) {
131                Ok(version) => {
132                    versions.insert(name, version);
133                }
134                Err(e) => {
135                    eprintln!(
136                        "Warning: Failed to parse version '{}' for package '{}': {}",
137                        package.version, package.name, e
138                    );
139                }
140            }
141        }
142
143        Ok(versions)
144    }
145
146    /// Parse pdm.lock file (TOML format)
147    fn parse_pdm_lock(&self, path: &PathBuf) -> Result<HashMap<String, Version>> {
148        let content = fs::read_to_string(path)
149            .with_context(|| format!("Failed to read pdm.lock at {:?}", path))?;
150
151        let lock_file: PdmLockFile = toml::from_str(&content)
152            .with_context(|| format!("Failed to parse pdm.lock at {:?}", path))?;
153
154        let mut versions = HashMap::new();
155        for package in lock_file.package {
156            // Normalize package name to lowercase
157            let name = package.name.to_lowercase().replace('_', "-");
158
159            match Version::from_str(&package.version) {
160                Ok(version) => {
161                    versions.insert(name, version);
162                }
163                Err(e) => {
164                    eprintln!(
165                        "Warning: Failed to parse version '{}' for package '{}': {}",
166                        package.version, package.name, e
167                    );
168                }
169            }
170        }
171
172        Ok(versions)
173    }
174}
175
176impl Default for LockfileParser {
177    fn default() -> Self {
178        Self::new()
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use std::io::Write;
186    use tempfile::NamedTempFile;
187
188    #[test]
189    fn test_parse_uv_lock() {
190        let lock_content = r#"
191version = 1
192
193[[package]]
194name = "requests"
195version = "2.31.0"
196
197[[package]]
198name = "numpy"
199version = "1.24.3"
200
201[[package]]
202name = "flask"
203version = "2.3.0"
204"#;
205
206        let mut temp_file = NamedTempFile::new().unwrap();
207        temp_file.write_all(lock_content.as_bytes()).unwrap();
208        let path = temp_file.path().to_path_buf();
209
210        let parser = LockfileParser::new();
211        let versions = parser.parse_uv_lock(&path).unwrap();
212
213        assert_eq!(versions.len(), 3);
214        assert_eq!(versions.get("requests").unwrap().to_string(), "2.31.0");
215        assert_eq!(versions.get("numpy").unwrap().to_string(), "1.24.3");
216        assert_eq!(versions.get("flask").unwrap().to_string(), "2.3.0");
217    }
218
219    #[test]
220    fn test_parse_poetry_lock() {
221        let lock_content = r#"
222[[package]]
223name = "requests"
224version = "2.31.0"
225description = "Python HTTP for Humans."
226
227[[package]]
228name = "Django"
229version = "4.2.0"
230description = "A high-level Python Web framework"
231"#;
232
233        let mut temp_file = NamedTempFile::new().unwrap();
234        temp_file.write_all(lock_content.as_bytes()).unwrap();
235        let path = temp_file.path().to_path_buf();
236
237        let parser = LockfileParser::new();
238        let versions = parser.parse_poetry_lock(&path).unwrap();
239
240        assert_eq!(versions.len(), 2);
241        assert_eq!(versions.get("requests").unwrap().to_string(), "2.31.0");
242        assert_eq!(versions.get("django").unwrap().to_string(), "4.2.0");
243    }
244
245    #[test]
246    fn test_parse_pdm_lock() {
247        let lock_content = r#"
248[[package]]
249name = "click"
250version = "8.1.3"
251
252[[package]]
253name = "Flask"
254version = "2.3.0"
255"#;
256
257        let mut temp_file = NamedTempFile::new().unwrap();
258        temp_file.write_all(lock_content.as_bytes()).unwrap();
259        let path = temp_file.path().to_path_buf();
260
261        let parser = LockfileParser::new();
262        let versions = parser.parse_pdm_lock(&path).unwrap();
263
264        assert_eq!(versions.len(), 2);
265        assert_eq!(versions.get("click").unwrap().to_string(), "8.1.3");
266        assert_eq!(versions.get("flask").unwrap().to_string(), "2.3.0");
267    }
268
269    #[test]
270    fn test_can_parse() {
271        let parser = LockfileParser::new();
272
273        assert!(parser.can_parse(&PathBuf::from("uv.lock")));
274        assert!(parser.can_parse(&PathBuf::from("poetry.lock")));
275        assert!(parser.can_parse(&PathBuf::from("pdm.lock")));
276        assert!(!parser.can_parse(&PathBuf::from("requirements.txt")));
277    }
278
279    #[test]
280    fn test_find_and_parse() {
281        let temp_dir = tempfile::tempdir().unwrap();
282        let dir_path = temp_dir.path().to_path_buf();
283
284        // Create a uv.lock file
285        let lock_path = dir_path.join("uv.lock");
286        let lock_content = r#"
287[[package]]
288name = "requests"
289version = "2.31.0"
290"#;
291        fs::write(&lock_path, lock_content).unwrap();
292
293        let parser = LockfileParser::new();
294        let versions = parser.find_and_parse(&dir_path).unwrap();
295
296        assert_eq!(versions.len(), 1);
297        assert_eq!(versions.get("requests").unwrap().to_string(), "2.31.0");
298    }
299
300    #[test]
301    fn test_find_and_parse_no_lockfile() {
302        let temp_dir = tempfile::tempdir().unwrap();
303        let dir_path = temp_dir.path().to_path_buf();
304
305        let parser = LockfileParser::new();
306        let versions = parser.find_and_parse(&dir_path).unwrap();
307
308        // Should return empty map, not an error
309        assert_eq!(versions.len(), 0);
310    }
311}