changepacks_python/
finder.rs

1use 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            // read pyproject.toml
60            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            // if workspace
67            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            // read tool.uv.sources section
111            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        // Pass directory instead of file
283        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        // Visit again - should not add duplicate
315        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}