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