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