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 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 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 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 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 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}