python_check_updates/
detector.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4/// Detected package manager type
5#[derive(Debug, Clone, PartialEq, Eq, Hash)]
6pub enum PackageManager {
7    Pip,
8    Uv,
9    Poetry,
10    Pdm,
11    Conda,
12}
13
14impl std::fmt::Display for PackageManager {
15    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16        match self {
17            PackageManager::Pip => write!(f, "pip"),
18            PackageManager::Uv => write!(f, "uv"),
19            PackageManager::Poetry => write!(f, "poetry"),
20            PackageManager::Pdm => write!(f, "pdm"),
21            PackageManager::Conda => write!(f, "conda"),
22        }
23    }
24}
25
26/// Information about detected dependency files
27#[derive(Debug, Clone)]
28pub struct DetectedFile {
29    pub path: PathBuf,
30    pub package_manager: PackageManager,
31}
32
33/// Detects package managers and dependency files in a project
34pub struct ProjectDetector {
35    project_path: PathBuf,
36}
37
38impl ProjectDetector {
39    pub fn new(project_path: PathBuf) -> Self {
40        Self { project_path }
41    }
42
43    /// Detect all dependency files in the project
44    pub fn detect(&self) -> anyhow::Result<Vec<DetectedFile>> {
45        let mut detected_files = Vec::new();
46
47        // Check for pyproject.toml and determine which package manager
48        let pyproject_path = self.project_path.join("pyproject.toml");
49        if pyproject_path.exists() {
50            if let Some(pm) = self.detect_pyproject_manager(&pyproject_path)? {
51                detected_files.push(DetectedFile {
52                    path: pyproject_path,
53                    package_manager: pm,
54                });
55            }
56        }
57
58        // Check for requirements*.txt files (pip)
59        if let Ok(entries) = fs::read_dir(&self.project_path) {
60            for entry in entries.flatten() {
61                let path = entry.path();
62                if let Some(filename) = path.file_name() {
63                    let filename_str = filename.to_string_lossy();
64                    if filename_str.starts_with("requirements") && filename_str.ends_with(".txt") {
65                        detected_files.push(DetectedFile {
66                            path: path.clone(),
67                            package_manager: PackageManager::Pip,
68                        });
69                    }
70                }
71            }
72        }
73
74        // Check for conda environment files
75        for filename in &["environment.yml", "environment.yaml"] {
76            let conda_path = self.project_path.join(filename);
77            if conda_path.exists() {
78                detected_files.push(DetectedFile {
79                    path: conda_path,
80                    package_manager: PackageManager::Conda,
81                });
82            }
83        }
84
85        Ok(detected_files)
86    }
87
88    /// Detect which package manager uses pyproject.toml
89    fn detect_pyproject_manager(&self, pyproject_path: &Path) -> anyhow::Result<Option<PackageManager>> {
90        let contents = fs::read_to_string(pyproject_path)?;
91
92        // Check for lock files to disambiguate
93        let uv_lock = self.project_path.join("uv.lock");
94        let poetry_lock = self.project_path.join("poetry.lock");
95        let pdm_lock = self.project_path.join("pdm.lock");
96
97        // Check for tool sections in pyproject.toml
98        let has_poetry_section = contents.contains("[tool.poetry]");
99        let has_pdm_section = contents.contains("[tool.pdm]");
100
101        // Determine package manager based on lock files and tool sections
102        if poetry_lock.exists() || has_poetry_section {
103            Ok(Some(PackageManager::Poetry))
104        } else if pdm_lock.exists() || has_pdm_section {
105            Ok(Some(PackageManager::Pdm))
106        } else if uv_lock.exists() {
107            Ok(Some(PackageManager::Uv))
108        } else {
109            // If no lock file exists but pyproject.toml has dependencies,
110            // default to uv (PEP 621 standard)
111            if contents.contains("[project]") &&
112               (contents.contains("dependencies") || contents.contains("[project.dependencies]")) {
113                Ok(Some(PackageManager::Uv))
114            } else {
115                // No recognizable package manager
116                Ok(None)
117            }
118        }
119    }
120
121    /// Get the sync command to run after updating
122    pub fn get_sync_command(&self, pm: &PackageManager) -> &'static str {
123        match pm {
124            PackageManager::Pip => "pip install -r requirements.txt",
125            PackageManager::Uv => "uv lock",
126            PackageManager::Poetry => "poetry lock",
127            PackageManager::Pdm => "pdm lock",
128            PackageManager::Conda => "conda env update",
129        }
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use std::fs;
137    use tempfile::TempDir;
138
139    #[test]
140    fn test_detect_pip_requirements() {
141        let temp_dir = TempDir::new().unwrap();
142        let req_path = temp_dir.path().join("requirements.txt");
143        fs::write(&req_path, "requests==2.28.0\n").unwrap();
144
145        let detector = ProjectDetector::new(temp_dir.path().to_path_buf());
146        let detected = detector.detect().unwrap();
147
148        assert_eq!(detected.len(), 1);
149        assert_eq!(detected[0].package_manager, PackageManager::Pip);
150        assert_eq!(detected[0].path, req_path);
151    }
152
153    #[test]
154    fn test_detect_multiple_requirements() {
155        let temp_dir = TempDir::new().unwrap();
156        let req_path = temp_dir.path().join("requirements.txt");
157        let req_dev_path = temp_dir.path().join("requirements-dev.txt");
158        fs::write(&req_path, "requests==2.28.0\n").unwrap();
159        fs::write(&req_dev_path, "pytest==7.0.0\n").unwrap();
160
161        let detector = ProjectDetector::new(temp_dir.path().to_path_buf());
162        let detected = detector.detect().unwrap();
163
164        assert_eq!(detected.len(), 2);
165        assert!(detected.iter().all(|d| d.package_manager == PackageManager::Pip));
166    }
167
168    #[test]
169    fn test_detect_poetry() {
170        let temp_dir = TempDir::new().unwrap();
171        let pyproject_path = temp_dir.path().join("pyproject.toml");
172        let poetry_lock_path = temp_dir.path().join("poetry.lock");
173
174        fs::write(&pyproject_path, "[tool.poetry]\nname = \"test\"\n").unwrap();
175        fs::write(&poetry_lock_path, "").unwrap();
176
177        let detector = ProjectDetector::new(temp_dir.path().to_path_buf());
178        let detected = detector.detect().unwrap();
179
180        assert_eq!(detected.len(), 1);
181        assert_eq!(detected[0].package_manager, PackageManager::Poetry);
182        assert_eq!(detected[0].path, pyproject_path);
183    }
184
185    #[test]
186    fn test_detect_poetry_without_lock() {
187        let temp_dir = TempDir::new().unwrap();
188        let pyproject_path = temp_dir.path().join("pyproject.toml");
189
190        fs::write(&pyproject_path, "[tool.poetry]\nname = \"test\"\n").unwrap();
191
192        let detector = ProjectDetector::new(temp_dir.path().to_path_buf());
193        let detected = detector.detect().unwrap();
194
195        assert_eq!(detected.len(), 1);
196        assert_eq!(detected[0].package_manager, PackageManager::Poetry);
197    }
198
199    #[test]
200    fn test_detect_pdm() {
201        let temp_dir = TempDir::new().unwrap();
202        let pyproject_path = temp_dir.path().join("pyproject.toml");
203        let pdm_lock_path = temp_dir.path().join("pdm.lock");
204
205        fs::write(&pyproject_path, "[tool.pdm]\n").unwrap();
206        fs::write(&pdm_lock_path, "").unwrap();
207
208        let detector = ProjectDetector::new(temp_dir.path().to_path_buf());
209        let detected = detector.detect().unwrap();
210
211        assert_eq!(detected.len(), 1);
212        assert_eq!(detected[0].package_manager, PackageManager::Pdm);
213    }
214
215    #[test]
216    fn test_detect_uv() {
217        let temp_dir = TempDir::new().unwrap();
218        let pyproject_path = temp_dir.path().join("pyproject.toml");
219        let uv_lock_path = temp_dir.path().join("uv.lock");
220
221        fs::write(&pyproject_path, "[project]\nname = \"test\"\ndependencies = []\n").unwrap();
222        fs::write(&uv_lock_path, "").unwrap();
223
224        let detector = ProjectDetector::new(temp_dir.path().to_path_buf());
225        let detected = detector.detect().unwrap();
226
227        assert_eq!(detected.len(), 1);
228        assert_eq!(detected[0].package_manager, PackageManager::Uv);
229    }
230
231    #[test]
232    fn test_detect_uv_without_lock() {
233        let temp_dir = TempDir::new().unwrap();
234        let pyproject_path = temp_dir.path().join("pyproject.toml");
235
236        fs::write(&pyproject_path, "[project]\nname = \"test\"\ndependencies = [\"requests\"]\n").unwrap();
237
238        let detector = ProjectDetector::new(temp_dir.path().to_path_buf());
239        let detected = detector.detect().unwrap();
240
241        assert_eq!(detected.len(), 1);
242        assert_eq!(detected[0].package_manager, PackageManager::Uv);
243    }
244
245    #[test]
246    fn test_detect_conda_yml() {
247        let temp_dir = TempDir::new().unwrap();
248        let env_path = temp_dir.path().join("environment.yml");
249
250        fs::write(&env_path, "name: test\n").unwrap();
251
252        let detector = ProjectDetector::new(temp_dir.path().to_path_buf());
253        let detected = detector.detect().unwrap();
254
255        assert_eq!(detected.len(), 1);
256        assert_eq!(detected[0].package_manager, PackageManager::Conda);
257        assert_eq!(detected[0].path, env_path);
258    }
259
260    #[test]
261    fn test_detect_conda_yaml() {
262        let temp_dir = TempDir::new().unwrap();
263        let env_path = temp_dir.path().join("environment.yaml");
264
265        fs::write(&env_path, "name: test\n").unwrap();
266
267        let detector = ProjectDetector::new(temp_dir.path().to_path_buf());
268        let detected = detector.detect().unwrap();
269
270        assert_eq!(detected.len(), 1);
271        assert_eq!(detected[0].package_manager, PackageManager::Conda);
272    }
273
274    #[test]
275    fn test_detect_mixed_project() {
276        let temp_dir = TempDir::new().unwrap();
277        let req_path = temp_dir.path().join("requirements.txt");
278        let env_path = temp_dir.path().join("environment.yml");
279
280        fs::write(&req_path, "requests==2.28.0\n").unwrap();
281        fs::write(&env_path, "name: test\n").unwrap();
282
283        let detector = ProjectDetector::new(temp_dir.path().to_path_buf());
284        let detected = detector.detect().unwrap();
285
286        assert_eq!(detected.len(), 2);
287        assert!(detected.iter().any(|d| d.package_manager == PackageManager::Pip));
288        assert!(detected.iter().any(|d| d.package_manager == PackageManager::Conda));
289    }
290
291    #[test]
292    fn test_priority_poetry_over_others() {
293        let temp_dir = TempDir::new().unwrap();
294        let pyproject_path = temp_dir.path().join("pyproject.toml");
295        let poetry_lock_path = temp_dir.path().join("poetry.lock");
296        let uv_lock_path = temp_dir.path().join("uv.lock");
297
298        // Even if both locks exist, poetry.lock takes priority if [tool.poetry] exists
299        fs::write(&pyproject_path, "[tool.poetry]\nname = \"test\"\n").unwrap();
300        fs::write(&poetry_lock_path, "").unwrap();
301        fs::write(&uv_lock_path, "").unwrap();
302
303        let detector = ProjectDetector::new(temp_dir.path().to_path_buf());
304        let detected = detector.detect().unwrap();
305
306        assert_eq!(detected.len(), 1);
307        assert_eq!(detected[0].package_manager, PackageManager::Poetry);
308    }
309
310    #[test]
311    fn test_get_sync_command() {
312        let temp_dir = TempDir::new().unwrap();
313        let detector = ProjectDetector::new(temp_dir.path().to_path_buf());
314
315        assert_eq!(detector.get_sync_command(&PackageManager::Pip), "pip install -r requirements.txt");
316        assert_eq!(detector.get_sync_command(&PackageManager::Uv), "uv lock");
317        assert_eq!(detector.get_sync_command(&PackageManager::Poetry), "poetry lock");
318        assert_eq!(detector.get_sync_command(&PackageManager::Pdm), "pdm lock");
319        assert_eq!(detector.get_sync_command(&PackageManager::Conda), "conda env update");
320    }
321}