Skip to main content

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    #[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            // read pyproject.toml
61            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            // if workspace
68            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            // read tool.uv.sources section
112            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        // Pass directory instead of file
284        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        // Visit again - should not add duplicate
316        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}