changepacks_python/
finder.rs1use anyhow::{Context, Result};
2use async_trait::async_trait;
3use changepacks_core::{Project, ProjectFinder};
4use std::{
5 collections::HashMap,
6 path::{Path, PathBuf},
7};
8use tokio::fs::read_to_string;
9
10use crate::{package::PythonPackage, workspace::PythonWorkspace};
11
12#[derive(Debug)]
13pub struct PythonProjectFinder {
14 projects: HashMap<PathBuf, Project>,
15 project_files: Vec<&'static str>,
16}
17
18impl Default for PythonProjectFinder {
19 fn default() -> Self {
20 Self::new()
21 }
22}
23
24impl PythonProjectFinder {
25 pub fn new() -> Self {
26 Self {
27 projects: HashMap::new(),
28 project_files: vec!["pyproject.toml"],
29 }
30 }
31}
32
33#[async_trait]
34impl ProjectFinder for PythonProjectFinder {
35 fn projects(&self) -> Vec<&Project> {
36 self.projects.values().collect::<Vec<_>>()
37 }
38 fn projects_mut(&mut self) -> Vec<&mut Project> {
39 self.projects.values_mut().collect::<Vec<_>>()
40 }
41
42 fn project_files(&self) -> &[&str] {
43 &self.project_files
44 }
45
46 async fn visit(&mut self, path: &Path, relative_path: &Path) -> Result<()> {
47 if path.is_file()
48 && self.project_files().contains(
49 &path
50 .file_name()
51 .context(format!("File name not found - {}", path.display()))?
52 .to_str()
53 .context(format!("File name not found - {}", path.display()))?,
54 )
55 {
56 if self.projects.contains_key(path) {
57 return Ok(());
58 }
59 let pyproject_toml = read_to_string(path).await?;
61 let pyproject_toml: toml::Value = toml::from_str(&pyproject_toml)?;
62 let project = pyproject_toml
63 .get("project")
64 .context(format!("Project not found - {}", path.display()))?;
65
66 let (path, mut project) = if pyproject_toml
68 .get("tool")
69 .and_then(|t| t.get("uv").and_then(|u| u.get("workspace")))
70 .is_some()
71 {
72 let version = project
73 .get("version")
74 .and_then(|v| v.as_str())
75 .map(|v| v.to_string());
76 let name = project
77 .get("name")
78 .and_then(|v| v.as_str())
79 .map(|v| v.to_string());
80 (
81 path.to_path_buf(),
82 Project::Workspace(Box::new(PythonWorkspace::new(
83 name,
84 version,
85 path.to_path_buf(),
86 relative_path.to_path_buf(),
87 ))),
88 )
89 } else {
90 let version = project
91 .get("version")
92 .and_then(|v| v.as_str())
93 .map(|v| v.to_string());
94 let name = project
95 .get("name")
96 .and_then(|v| v.as_str())
97 .map(|v| v.to_string());
98
99 (
100 path.to_path_buf(),
101 Project::Package(Box::new(PythonPackage::new(
102 name,
103 version,
104 path.to_path_buf(),
105 relative_path.to_path_buf(),
106 ))),
107 )
108 };
109
110 if let Some(sources) = pyproject_toml
112 .get("tool")
113 .and_then(|t| t.get("uv").and_then(|u| u.get("sources")))
114 && let Some(sources) = sources.as_array()
115 {
116 for source in sources {
117 if let Some(source_str) = source.as_str() {
118 project.add_dependency(source_str);
119 }
120 }
121 }
122
123 self.projects.insert(path, project);
124 }
125 Ok(())
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132 use changepacks_core::Project;
133 use std::fs;
134 use tempfile::TempDir;
135
136 #[test]
137 fn test_python_project_finder_new() {
138 let finder = PythonProjectFinder::new();
139 assert_eq!(finder.project_files(), &["pyproject.toml"]);
140 assert_eq!(finder.projects().len(), 0);
141 }
142
143 #[test]
144 fn test_python_project_finder_default() {
145 let finder = PythonProjectFinder::default();
146 assert_eq!(finder.project_files(), &["pyproject.toml"]);
147 assert_eq!(finder.projects().len(), 0);
148 }
149
150 #[tokio::test]
151 async fn test_python_project_finder_visit_package() {
152 let temp_dir = TempDir::new().unwrap();
153 let pyproject_toml = temp_dir.path().join("pyproject.toml");
154 fs::write(
155 &pyproject_toml,
156 r#"[project]
157name = "test-package"
158version = "1.0.0"
159"#,
160 )
161 .unwrap();
162
163 let mut finder = PythonProjectFinder::new();
164 finder
165 .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
166 .await
167 .unwrap();
168
169 let projects = finder.projects();
170 assert_eq!(projects.len(), 1);
171 match projects[0] {
172 Project::Package(pkg) => {
173 assert_eq!(pkg.name(), Some("test-package"));
174 assert_eq!(pkg.version(), Some("1.0.0"));
175 }
176 _ => panic!("Expected Package"),
177 }
178
179 temp_dir.close().unwrap();
180 }
181
182 #[tokio::test]
183 async fn test_python_project_finder_visit_workspace() {
184 let temp_dir = TempDir::new().unwrap();
185 let pyproject_toml = temp_dir.path().join("pyproject.toml");
186 fs::write(
187 &pyproject_toml,
188 r#"[tool.uv.workspace]
189members = ["packages/*"]
190
191[project]
192name = "test-workspace"
193version = "1.0.0"
194"#,
195 )
196 .unwrap();
197
198 let mut finder = PythonProjectFinder::new();
199 finder
200 .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
201 .await
202 .unwrap();
203
204 let projects = finder.projects();
205 assert_eq!(projects.len(), 1);
206 match projects[0] {
207 Project::Workspace(ws) => {
208 assert_eq!(ws.name(), Some("test-workspace"));
209 assert_eq!(ws.version(), Some("1.0.0"));
210 }
211 _ => panic!("Expected Workspace"),
212 }
213
214 temp_dir.close().unwrap();
215 }
216
217 #[tokio::test]
218 async fn test_python_project_finder_visit_workspace_without_version() {
219 let temp_dir = TempDir::new().unwrap();
220 let pyproject_toml = temp_dir.path().join("pyproject.toml");
221 fs::write(
222 &pyproject_toml,
223 r#"[tool.uv.workspace]
224members = ["packages/*"]
225
226[project]
227name = "test-workspace"
228"#,
229 )
230 .unwrap();
231
232 let mut finder = PythonProjectFinder::new();
233 finder
234 .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
235 .await
236 .unwrap();
237
238 let projects = finder.projects();
239 assert_eq!(projects.len(), 1);
240 match projects[0] {
241 Project::Workspace(ws) => {
242 assert_eq!(ws.name(), Some("test-workspace"));
243 assert_eq!(ws.version(), None);
244 }
245 _ => panic!("Expected Workspace"),
246 }
247
248 temp_dir.close().unwrap();
249 }
250
251 #[tokio::test]
252 async fn test_python_project_finder_visit_non_pyproject_file() {
253 let temp_dir = TempDir::new().unwrap();
254 let other_file = temp_dir.path().join("other.txt");
255 fs::write(&other_file, "some content").unwrap();
256
257 let mut finder = PythonProjectFinder::new();
258 finder
259 .visit(&other_file, &PathBuf::from("other.txt"))
260 .await
261 .unwrap();
262
263 assert_eq!(finder.projects().len(), 0);
264
265 temp_dir.close().unwrap();
266 }
267
268 #[tokio::test]
269 async fn test_python_project_finder_visit_directory() {
270 let temp_dir = TempDir::new().unwrap();
271 let pyproject_toml = temp_dir.path().join("pyproject.toml");
272 fs::write(
273 &pyproject_toml,
274 r#"[project]
275name = "test-package"
276version = "1.0.0"
277"#,
278 )
279 .unwrap();
280
281 let mut finder = PythonProjectFinder::new();
282 finder
284 .visit(temp_dir.path(), &PathBuf::from("."))
285 .await
286 .unwrap();
287
288 assert_eq!(finder.projects().len(), 0);
289
290 temp_dir.close().unwrap();
291 }
292
293 #[tokio::test]
294 async fn test_python_project_finder_visit_duplicate() {
295 let temp_dir = TempDir::new().unwrap();
296 let pyproject_toml = temp_dir.path().join("pyproject.toml");
297 fs::write(
298 &pyproject_toml,
299 r#"[project]
300name = "test-package"
301version = "1.0.0"
302"#,
303 )
304 .unwrap();
305
306 let mut finder = PythonProjectFinder::new();
307 finder
308 .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
309 .await
310 .unwrap();
311
312 assert_eq!(finder.projects().len(), 1);
313
314 finder
316 .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
317 .await
318 .unwrap();
319
320 assert_eq!(finder.projects().len(), 1);
321
322 temp_dir.close().unwrap();
323 }
324
325 #[tokio::test]
326 async fn test_python_project_finder_visit_multiple_packages() {
327 let temp_dir = TempDir::new().unwrap();
328 let pyproject_toml1 = temp_dir.path().join("package1").join("pyproject.toml");
329 fs::create_dir_all(pyproject_toml1.parent().unwrap()).unwrap();
330 fs::write(
331 &pyproject_toml1,
332 r#"[project]
333name = "package1"
334version = "1.0.0"
335"#,
336 )
337 .unwrap();
338
339 let pyproject_toml2 = temp_dir.path().join("package2").join("pyproject.toml");
340 fs::create_dir_all(pyproject_toml2.parent().unwrap()).unwrap();
341 fs::write(
342 &pyproject_toml2,
343 r#"[project]
344name = "package2"
345version = "2.0.0"
346"#,
347 )
348 .unwrap();
349
350 let mut finder = PythonProjectFinder::new();
351 finder
352 .visit(&pyproject_toml1, &PathBuf::from("package1/pyproject.toml"))
353 .await
354 .unwrap();
355 finder
356 .visit(&pyproject_toml2, &PathBuf::from("package2/pyproject.toml"))
357 .await
358 .unwrap();
359
360 let projects = finder.projects();
361 assert_eq!(projects.len(), 2);
362
363 temp_dir.close().unwrap();
364 }
365
366 #[tokio::test]
367 async fn test_python_project_finder_projects_mut() {
368 let temp_dir = TempDir::new().unwrap();
369 let pyproject_toml = temp_dir.path().join("pyproject.toml");
370 fs::write(
371 &pyproject_toml,
372 r#"[project]
373name = "test-package"
374version = "1.0.0"
375"#,
376 )
377 .unwrap();
378
379 let mut finder = PythonProjectFinder::new();
380 finder
381 .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
382 .await
383 .unwrap();
384
385 let mut_projects = finder.projects_mut();
386 assert_eq!(mut_projects.len(), 1);
387
388 temp_dir.close().unwrap();
389 }
390
391 #[tokio::test]
392 async fn test_python_project_finder_visit_package_without_project_section() {
393 let temp_dir = TempDir::new().unwrap();
394 let pyproject_toml = temp_dir.path().join("pyproject.toml");
395 fs::write(
396 &pyproject_toml,
397 r#"[build-system]
398requires = ["setuptools"]
399"#,
400 )
401 .unwrap();
402
403 let mut finder = PythonProjectFinder::new();
404 let result = finder
405 .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
406 .await;
407
408 assert!(result.is_err());
409 assert_eq!(finder.projects().len(), 0);
410
411 temp_dir.close().unwrap();
412 }
413}