python_check_updates/parsers/
lockfiles.rs1use 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
9pub struct LockfileParser;
11
12#[derive(Debug, Deserialize)]
14struct TomlPackage {
15 name: String,
16 version: String,
17}
18
19#[derive(Debug, Deserialize)]
21struct TomlLockFile {
22 package: Vec<TomlPackage>,
23}
24
25#[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 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 pub fn find_and_parse(&self, dir: &PathBuf) -> Result<HashMap<String, Version>> {
59 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 Ok(HashMap::new())
71 }
72
73 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 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 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 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 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 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 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 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 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 assert_eq!(versions.len(), 0);
310 }
311}