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