changepacks_rust/
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::RustPackage, workspace::RustWorkspace};
11
12#[derive(Debug)]
13pub struct RustProjectFinder {
14    projects: HashMap<PathBuf, Project>,
15    project_files: Vec<&'static str>,
16}
17
18impl Default for RustProjectFinder {
19    fn default() -> Self {
20        Self::new()
21    }
22}
23
24impl RustProjectFinder {
25    pub fn new() -> Self {
26        Self {
27            projects: HashMap::new(),
28            project_files: vec!["Cargo.toml"],
29        }
30    }
31}
32
33#[async_trait]
34impl ProjectFinder for RustProjectFinder {
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 Cargo.toml
60            let cargo_toml = read_to_string(path).await?;
61            let cargo_toml: toml::Value = toml::from_str(&cargo_toml)?;
62            // if workspace
63            let (path, mut project) = if cargo_toml.get("workspace").is_some() {
64                let version = cargo_toml
65                    .get("package")
66                    .and_then(|p| p.get("version"))
67                    .and_then(|v| v.as_str())
68                    .map(|v| v.to_string());
69                let name = cargo_toml
70                    .get("package")
71                    .and_then(|p| p.get("name"))
72                    .and_then(|v| v.as_str())
73                    .map(|v| v.to_string());
74                (
75                    path.to_path_buf(),
76                    Project::Workspace(Box::new(RustWorkspace::new(
77                        name,
78                        version,
79                        path.to_path_buf(),
80                        relative_path.to_path_buf(),
81                    ))),
82                )
83            } else {
84                let version = cargo_toml["package"]["version"]
85                    .as_str()
86                    .map(|v| v.to_string());
87                let name = cargo_toml["package"]["name"]
88                    .as_str()
89                    .map(|v| v.to_string());
90                (
91                    path.to_path_buf(),
92                    Project::Package(Box::new(RustPackage::new(
93                        name,
94                        version,
95                        path.to_path_buf(),
96                        relative_path.to_path_buf(),
97                    ))),
98                )
99            };
100
101            if let Some(deps) = cargo_toml.get("dependencies").and_then(|d| d.as_table()) {
102                for (dep_name, value) in deps {
103                    if let Some(dep) = value.as_table()
104                        && let Some(workspace) = dep.get("workspace")
105                        && workspace.as_bool().unwrap_or(false)
106                    {
107                        project.add_dependency(dep_name);
108                    }
109                }
110            }
111
112            self.projects.insert(path, project);
113        }
114        Ok(())
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use changepacks_core::Project;
122    use std::fs;
123    use tempfile::TempDir;
124
125    #[test]
126    fn test_rust_project_finder_new() {
127        let finder = RustProjectFinder::new();
128        assert_eq!(finder.project_files(), &["Cargo.toml"]);
129        assert_eq!(finder.projects().len(), 0);
130    }
131
132    #[test]
133    fn test_rust_project_finder_default() {
134        let finder = RustProjectFinder::default();
135        assert_eq!(finder.project_files(), &["Cargo.toml"]);
136        assert_eq!(finder.projects().len(), 0);
137    }
138
139    #[tokio::test]
140    async fn test_rust_project_finder_visit_package() {
141        let temp_dir = TempDir::new().unwrap();
142        let cargo_toml = temp_dir.path().join("Cargo.toml");
143        fs::write(
144            &cargo_toml,
145            r#"[package]
146name = "test-package"
147version = "1.0.0"
148"#,
149        )
150        .unwrap();
151
152        let mut finder = RustProjectFinder::new();
153        finder
154            .visit(&cargo_toml, &PathBuf::from("Cargo.toml"))
155            .await
156            .unwrap();
157
158        let projects = finder.projects();
159        assert_eq!(projects.len(), 1);
160        match projects[0] {
161            Project::Package(pkg) => {
162                assert_eq!(pkg.name(), Some("test-package"));
163                assert_eq!(pkg.version(), Some("1.0.0"));
164            }
165            _ => panic!("Expected Package"),
166        }
167
168        temp_dir.close().unwrap();
169    }
170
171    #[tokio::test]
172    async fn test_rust_project_finder_visit_workspace() {
173        let temp_dir = TempDir::new().unwrap();
174        let cargo_toml = temp_dir.path().join("Cargo.toml");
175        fs::write(
176            &cargo_toml,
177            r#"[workspace]
178members = ["crates/*"]
179
180[package]
181name = "test-workspace"
182version = "1.0.0"
183"#,
184        )
185        .unwrap();
186
187        let mut finder = RustProjectFinder::new();
188        finder
189            .visit(&cargo_toml, &PathBuf::from("Cargo.toml"))
190            .await
191            .unwrap();
192
193        let projects = finder.projects();
194        assert_eq!(projects.len(), 1);
195        match projects[0] {
196            Project::Workspace(ws) => {
197                assert_eq!(ws.name(), Some("test-workspace"));
198                assert_eq!(ws.version(), Some("1.0.0"));
199            }
200            _ => panic!("Expected Workspace"),
201        }
202
203        temp_dir.close().unwrap();
204    }
205
206    #[tokio::test]
207    async fn test_rust_project_finder_visit_workspace_without_package() {
208        let temp_dir = TempDir::new().unwrap();
209        let cargo_toml = temp_dir.path().join("Cargo.toml");
210        fs::write(
211            &cargo_toml,
212            r#"[workspace]
213members = ["crates/*"]
214"#,
215        )
216        .unwrap();
217
218        let mut finder = RustProjectFinder::new();
219        finder
220            .visit(&cargo_toml, &PathBuf::from("Cargo.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(), None);
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_rust_project_finder_visit_non_cargo_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 = RustProjectFinder::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_rust_project_finder_visit_directory() {
256        let temp_dir = TempDir::new().unwrap();
257        let cargo_toml = temp_dir.path().join("Cargo.toml");
258        fs::write(
259            &cargo_toml,
260            r#"[package]
261name = "test-package"
262version = "1.0.0"
263"#,
264        )
265        .unwrap();
266
267        let mut finder = RustProjectFinder::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_rust_project_finder_visit_duplicate() {
281        let temp_dir = TempDir::new().unwrap();
282        let cargo_toml = temp_dir.path().join("Cargo.toml");
283        fs::write(
284            &cargo_toml,
285            r#"[package]
286name = "test-package"
287version = "1.0.0"
288"#,
289        )
290        .unwrap();
291
292        let mut finder = RustProjectFinder::new();
293        finder
294            .visit(&cargo_toml, &PathBuf::from("Cargo.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(&cargo_toml, &PathBuf::from("Cargo.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_rust_project_finder_visit_multiple_packages() {
313        let temp_dir = TempDir::new().unwrap();
314        let cargo_toml1 = temp_dir.path().join("package1").join("Cargo.toml");
315        fs::create_dir_all(cargo_toml1.parent().unwrap()).unwrap();
316        fs::write(
317            &cargo_toml1,
318            r#"[package]
319name = "package1"
320version = "1.0.0"
321"#,
322        )
323        .unwrap();
324
325        let cargo_toml2 = temp_dir.path().join("package2").join("Cargo.toml");
326        fs::create_dir_all(cargo_toml2.parent().unwrap()).unwrap();
327        fs::write(
328            &cargo_toml2,
329            r#"[package]
330name = "package2"
331version = "2.0.0"
332"#,
333        )
334        .unwrap();
335
336        let mut finder = RustProjectFinder::new();
337        finder
338            .visit(&cargo_toml1, &PathBuf::from("package1/Cargo.toml"))
339            .await
340            .unwrap();
341        finder
342            .visit(&cargo_toml2, &PathBuf::from("package2/Cargo.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_rust_project_finder_projects_mut() {
354        let temp_dir = TempDir::new().unwrap();
355        let cargo_toml = temp_dir.path().join("Cargo.toml");
356        fs::write(
357            &cargo_toml,
358            r#"[package]
359name = "test-package"
360version = "1.0.0"
361"#,
362        )
363        .unwrap();
364
365        let mut finder = RustProjectFinder::new();
366        finder
367            .visit(&cargo_toml, &PathBuf::from("Cargo.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_rust_project_finder_visit_package_with_workspace_dependencies() {
379        let temp_dir = TempDir::new().unwrap();
380        let cargo_toml = temp_dir.path().join("Cargo.toml");
381        fs::write(
382            &cargo_toml,
383            r#"[package]
384name = "test-package"
385version = "1.0.0"
386
387[dependencies]
388core = { workspace = true }
389utils = { workspace = true }
390external = "1.0"
391"#,
392        )
393        .unwrap();
394
395        let mut finder = RustProjectFinder::new();
396        finder
397            .visit(&cargo_toml, &PathBuf::from("Cargo.toml"))
398            .await
399            .unwrap();
400
401        let projects = finder.projects();
402        assert_eq!(projects.len(), 1);
403        match projects[0] {
404            Project::Package(pkg) => {
405                assert_eq!(pkg.name(), Some("test-package"));
406                let deps = pkg.dependencies();
407                assert_eq!(deps.len(), 2);
408                assert!(deps.contains("core"));
409                assert!(deps.contains("utils"));
410                // external is not a workspace dependency
411                assert!(!deps.contains("external"));
412            }
413            _ => panic!("Expected Package"),
414        }
415
416        temp_dir.close().unwrap();
417    }
418}