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            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                self.projects.insert(
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                    .context(format!("Version not found - {}", path.display()))?;
95                let name = project
96                    .get("name")
97                    .and_then(|v| v.as_str())
98                    .map(|v| v.to_string())
99                    .context(format!("Name not found - {}", path.display()))?;
100                self.projects.insert(
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        Ok(())
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use changepacks_core::Project;
119    use std::fs;
120    use tempfile::TempDir;
121
122    #[test]
123    fn test_python_project_finder_new() {
124        let finder = PythonProjectFinder::new();
125        assert_eq!(finder.project_files(), &["pyproject.toml"]);
126        assert_eq!(finder.projects().len(), 0);
127    }
128
129    #[test]
130    fn test_python_project_finder_default() {
131        let finder = PythonProjectFinder::default();
132        assert_eq!(finder.project_files(), &["pyproject.toml"]);
133        assert_eq!(finder.projects().len(), 0);
134    }
135
136    #[tokio::test]
137    async fn test_python_project_finder_visit_package() {
138        let temp_dir = TempDir::new().unwrap();
139        let pyproject_toml = temp_dir.path().join("pyproject.toml");
140        fs::write(
141            &pyproject_toml,
142            r#"[project]
143name = "test-package"
144version = "1.0.0"
145"#,
146        )
147        .unwrap();
148
149        let mut finder = PythonProjectFinder::new();
150        finder
151            .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
152            .await
153            .unwrap();
154
155        let projects = finder.projects();
156        assert_eq!(projects.len(), 1);
157        match projects[0] {
158            Project::Package(pkg) => {
159                assert_eq!(pkg.name(), "test-package");
160                assert_eq!(pkg.version(), "1.0.0");
161            }
162            _ => panic!("Expected Package"),
163        }
164
165        temp_dir.close().unwrap();
166    }
167
168    #[tokio::test]
169    async fn test_python_project_finder_visit_workspace() {
170        let temp_dir = TempDir::new().unwrap();
171        let pyproject_toml = temp_dir.path().join("pyproject.toml");
172        fs::write(
173            &pyproject_toml,
174            r#"[tool.uv.workspace]
175members = ["packages/*"]
176
177[project]
178name = "test-workspace"
179version = "1.0.0"
180"#,
181        )
182        .unwrap();
183
184        let mut finder = PythonProjectFinder::new();
185        finder
186            .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
187            .await
188            .unwrap();
189
190        let projects = finder.projects();
191        assert_eq!(projects.len(), 1);
192        match projects[0] {
193            Project::Workspace(ws) => {
194                assert_eq!(ws.name(), Some("test-workspace"));
195                assert_eq!(ws.version(), Some("1.0.0"));
196            }
197            _ => panic!("Expected Workspace"),
198        }
199
200        temp_dir.close().unwrap();
201    }
202
203    #[tokio::test]
204    async fn test_python_project_finder_visit_workspace_without_version() {
205        let temp_dir = TempDir::new().unwrap();
206        let pyproject_toml = temp_dir.path().join("pyproject.toml");
207        fs::write(
208            &pyproject_toml,
209            r#"[tool.uv.workspace]
210members = ["packages/*"]
211
212[project]
213name = "test-workspace"
214"#,
215        )
216        .unwrap();
217
218        let mut finder = PythonProjectFinder::new();
219        finder
220            .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
221            .await
222            .unwrap();
223
224        let projects = finder.projects();
225        assert_eq!(projects.len(), 1);
226        match projects[0] {
227            Project::Workspace(ws) => {
228                assert_eq!(ws.name(), Some("test-workspace"));
229                assert_eq!(ws.version(), None);
230            }
231            _ => panic!("Expected Workspace"),
232        }
233
234        temp_dir.close().unwrap();
235    }
236
237    #[tokio::test]
238    async fn test_python_project_finder_visit_non_pyproject_file() {
239        let temp_dir = TempDir::new().unwrap();
240        let other_file = temp_dir.path().join("other.txt");
241        fs::write(&other_file, "some content").unwrap();
242
243        let mut finder = PythonProjectFinder::new();
244        finder
245            .visit(&other_file, &PathBuf::from("other.txt"))
246            .await
247            .unwrap();
248
249        assert_eq!(finder.projects().len(), 0);
250
251        temp_dir.close().unwrap();
252    }
253
254    #[tokio::test]
255    async fn test_python_project_finder_visit_directory() {
256        let temp_dir = TempDir::new().unwrap();
257        let pyproject_toml = temp_dir.path().join("pyproject.toml");
258        fs::write(
259            &pyproject_toml,
260            r#"[project]
261name = "test-package"
262version = "1.0.0"
263"#,
264        )
265        .unwrap();
266
267        let mut finder = PythonProjectFinder::new();
268        // Pass directory instead of file
269        finder
270            .visit(temp_dir.path(), &PathBuf::from("."))
271            .await
272            .unwrap();
273
274        assert_eq!(finder.projects().len(), 0);
275
276        temp_dir.close().unwrap();
277    }
278
279    #[tokio::test]
280    async fn test_python_project_finder_visit_duplicate() {
281        let temp_dir = TempDir::new().unwrap();
282        let pyproject_toml = temp_dir.path().join("pyproject.toml");
283        fs::write(
284            &pyproject_toml,
285            r#"[project]
286name = "test-package"
287version = "1.0.0"
288"#,
289        )
290        .unwrap();
291
292        let mut finder = PythonProjectFinder::new();
293        finder
294            .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
295            .await
296            .unwrap();
297
298        assert_eq!(finder.projects().len(), 1);
299
300        // Visit again - should not add duplicate
301        finder
302            .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
303            .await
304            .unwrap();
305
306        assert_eq!(finder.projects().len(), 1);
307
308        temp_dir.close().unwrap();
309    }
310
311    #[tokio::test]
312    async fn test_python_project_finder_visit_multiple_packages() {
313        let temp_dir = TempDir::new().unwrap();
314        let pyproject_toml1 = temp_dir.path().join("package1").join("pyproject.toml");
315        fs::create_dir_all(pyproject_toml1.parent().unwrap()).unwrap();
316        fs::write(
317            &pyproject_toml1,
318            r#"[project]
319name = "package1"
320version = "1.0.0"
321"#,
322        )
323        .unwrap();
324
325        let pyproject_toml2 = temp_dir.path().join("package2").join("pyproject.toml");
326        fs::create_dir_all(pyproject_toml2.parent().unwrap()).unwrap();
327        fs::write(
328            &pyproject_toml2,
329            r#"[project]
330name = "package2"
331version = "2.0.0"
332"#,
333        )
334        .unwrap();
335
336        let mut finder = PythonProjectFinder::new();
337        finder
338            .visit(&pyproject_toml1, &PathBuf::from("package1/pyproject.toml"))
339            .await
340            .unwrap();
341        finder
342            .visit(&pyproject_toml2, &PathBuf::from("package2/pyproject.toml"))
343            .await
344            .unwrap();
345
346        let projects = finder.projects();
347        assert_eq!(projects.len(), 2);
348
349        temp_dir.close().unwrap();
350    }
351
352    #[tokio::test]
353    async fn test_python_project_finder_projects_mut() {
354        let temp_dir = TempDir::new().unwrap();
355        let pyproject_toml = temp_dir.path().join("pyproject.toml");
356        fs::write(
357            &pyproject_toml,
358            r#"[project]
359name = "test-package"
360version = "1.0.0"
361"#,
362        )
363        .unwrap();
364
365        let mut finder = PythonProjectFinder::new();
366        finder
367            .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
368            .await
369            .unwrap();
370
371        let mut_projects = finder.projects_mut();
372        assert_eq!(mut_projects.len(), 1);
373
374        temp_dir.close().unwrap();
375    }
376
377    #[tokio::test]
378    async fn test_python_project_finder_visit_package_without_project_section() {
379        let temp_dir = TempDir::new().unwrap();
380        let pyproject_toml = temp_dir.path().join("pyproject.toml");
381        fs::write(
382            &pyproject_toml,
383            r#"[build-system]
384requires = ["setuptools"]
385"#,
386        )
387        .unwrap();
388
389        let mut finder = PythonProjectFinder::new();
390        let result = finder
391            .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
392            .await;
393
394        assert!(result.is_err());
395        assert_eq!(finder.projects().len(), 0);
396
397        temp_dir.close().unwrap();
398    }
399}