Skip to main content

pcu/parsers/
lockfiles.rs

1use check_updates_core::Version;
2use anyhow::{Context, Result};
3use serde::Deserialize;
4use std::collections::HashMap;
5use std::fs;
6use std::path::Path;
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: &Path) -> 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: &Path) -> 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: &Path) -> 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: &Path) -> 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: &Path) -> 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: &Path) -> 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 std::path::PathBuf;
187    use tempfile::NamedTempFile;
188
189    #[test]
190    fn test_parse_uv_lock() {
191        let lock_content = r#"
192version = 1
193
194[[package]]
195name = "requests"
196version = "2.31.0"
197
198[[package]]
199name = "numpy"
200version = "1.24.3"
201
202[[package]]
203name = "flask"
204version = "2.3.0"
205"#;
206
207        let mut temp_file = NamedTempFile::new().unwrap();
208        temp_file.write_all(lock_content.as_bytes()).unwrap();
209        let path = temp_file.path().to_path_buf();
210
211        let parser = LockfileParser::new();
212        let versions = parser.parse_uv_lock(&path).unwrap();
213
214        assert_eq!(versions.len(), 3);
215        assert_eq!(versions.get("requests").unwrap().to_string(), "2.31.0");
216        assert_eq!(versions.get("numpy").unwrap().to_string(), "1.24.3");
217        assert_eq!(versions.get("flask").unwrap().to_string(), "2.3.0");
218    }
219
220    #[test]
221    fn test_parse_poetry_lock() {
222        let lock_content = r#"
223[[package]]
224name = "requests"
225version = "2.31.0"
226description = "Python HTTP for Humans."
227
228[[package]]
229name = "Django"
230version = "4.2.0"
231description = "A high-level Python Web framework"
232"#;
233
234        let mut temp_file = NamedTempFile::new().unwrap();
235        temp_file.write_all(lock_content.as_bytes()).unwrap();
236        let path = temp_file.path().to_path_buf();
237
238        let parser = LockfileParser::new();
239        let versions = parser.parse_poetry_lock(&path).unwrap();
240
241        assert_eq!(versions.len(), 2);
242        assert_eq!(versions.get("requests").unwrap().to_string(), "2.31.0");
243        assert_eq!(versions.get("django").unwrap().to_string(), "4.2.0");
244    }
245
246    #[test]
247    fn test_parse_pdm_lock() {
248        let lock_content = r#"
249[[package]]
250name = "click"
251version = "8.1.3"
252
253[[package]]
254name = "Flask"
255version = "2.3.0"
256"#;
257
258        let mut temp_file = NamedTempFile::new().unwrap();
259        temp_file.write_all(lock_content.as_bytes()).unwrap();
260        let path = temp_file.path().to_path_buf();
261
262        let parser = LockfileParser::new();
263        let versions = parser.parse_pdm_lock(&path).unwrap();
264
265        assert_eq!(versions.len(), 2);
266        assert_eq!(versions.get("click").unwrap().to_string(), "8.1.3");
267        assert_eq!(versions.get("flask").unwrap().to_string(), "2.3.0");
268    }
269
270    #[test]
271    fn test_can_parse() {
272        let parser = LockfileParser::new();
273
274        assert!(parser.can_parse(&PathBuf::from("uv.lock")));
275        assert!(parser.can_parse(&PathBuf::from("poetry.lock")));
276        assert!(parser.can_parse(&PathBuf::from("pdm.lock")));
277        assert!(!parser.can_parse(&PathBuf::from("requirements.txt")));
278    }
279
280    #[test]
281    fn test_find_and_parse() {
282        let temp_dir = tempfile::tempdir().unwrap();
283        let dir_path = temp_dir.path().to_path_buf();
284
285        // Create a uv.lock file
286        let lock_path = dir_path.join("uv.lock");
287        let lock_content = r#"
288[[package]]
289name = "requests"
290version = "2.31.0"
291"#;
292        fs::write(&lock_path, lock_content).unwrap();
293
294        let parser = LockfileParser::new();
295        let versions = parser.find_and_parse(&dir_path).unwrap();
296
297        assert_eq!(versions.len(), 1);
298        assert_eq!(versions.get("requests").unwrap().to_string(), "2.31.0");
299    }
300
301    #[test]
302    fn test_find_and_parse_no_lockfile() {
303        let temp_dir = tempfile::tempdir().unwrap();
304        let dir_path = temp_dir.path().to_path_buf();
305
306        let parser = LockfileParser::new();
307        let versions = parser.find_and_parse(&dir_path).unwrap();
308
309        // Should return empty map, not an error
310        assert_eq!(versions.len(), 0);
311    }
312}