Skip to main content

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