1use std::fs;
2use std::path::{Path, PathBuf};
3
4#[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#[derive(Debug, Clone)]
28pub struct DetectedFile {
29 pub path: PathBuf,
30 pub package_manager: PackageManager,
31}
32
33pub 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 pub fn detect(&self) -> anyhow::Result<Vec<DetectedFile>> {
45 let mut detected_files = Vec::new();
46
47 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 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 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 fn detect_pyproject_manager(&self, pyproject_path: &Path) -> anyhow::Result<Option<PackageManager>> {
89 let contents = fs::read_to_string(pyproject_path)?;
90
91 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 let has_poetry_section = contents.contains("[tool.poetry]");
98 let has_pdm_section = contents.contains("[tool.pdm]");
99
100 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 contents.contains("[project]") &&
111 (contents.contains("dependencies") || contents.contains("[project.dependencies]")) {
112 Ok(Some(PackageManager::Uv))
113 } else {
114 Ok(None)
116 }
117 }
118 }
119
120 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 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}