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            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                self.projects.insert(
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                self.projects.insert(
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        Ok(())
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use changepacks_core::Project;
109    use std::fs;
110    use tempfile::TempDir;
111
112    #[test]
113    fn test_rust_project_finder_new() {
114        let finder = RustProjectFinder::new();
115        assert_eq!(finder.project_files(), &["Cargo.toml"]);
116        assert_eq!(finder.projects().len(), 0);
117    }
118
119    #[test]
120    fn test_rust_project_finder_default() {
121        let finder = RustProjectFinder::default();
122        assert_eq!(finder.project_files(), &["Cargo.toml"]);
123        assert_eq!(finder.projects().len(), 0);
124    }
125
126    #[tokio::test]
127    async fn test_rust_project_finder_visit_package() {
128        let temp_dir = TempDir::new().unwrap();
129        let cargo_toml = temp_dir.path().join("Cargo.toml");
130        fs::write(
131            &cargo_toml,
132            r#"[package]
133name = "test-package"
134version = "1.0.0"
135"#,
136        )
137        .unwrap();
138
139        let mut finder = RustProjectFinder::new();
140        finder
141            .visit(&cargo_toml, &PathBuf::from("Cargo.toml"))
142            .await
143            .unwrap();
144
145        let projects = finder.projects();
146        assert_eq!(projects.len(), 1);
147        match projects[0] {
148            Project::Package(pkg) => {
149                assert_eq!(pkg.name(), Some("test-package"));
150                assert_eq!(pkg.version(), Some("1.0.0"));
151            }
152            _ => panic!("Expected Package"),
153        }
154
155        temp_dir.close().unwrap();
156    }
157
158    #[tokio::test]
159    async fn test_rust_project_finder_visit_workspace() {
160        let temp_dir = TempDir::new().unwrap();
161        let cargo_toml = temp_dir.path().join("Cargo.toml");
162        fs::write(
163            &cargo_toml,
164            r#"[workspace]
165members = ["crates/*"]
166
167[package]
168name = "test-workspace"
169version = "1.0.0"
170"#,
171        )
172        .unwrap();
173
174        let mut finder = RustProjectFinder::new();
175        finder
176            .visit(&cargo_toml, &PathBuf::from("Cargo.toml"))
177            .await
178            .unwrap();
179
180        let projects = finder.projects();
181        assert_eq!(projects.len(), 1);
182        match projects[0] {
183            Project::Workspace(ws) => {
184                assert_eq!(ws.name(), Some("test-workspace"));
185                assert_eq!(ws.version(), Some("1.0.0"));
186            }
187            _ => panic!("Expected Workspace"),
188        }
189
190        temp_dir.close().unwrap();
191    }
192
193    #[tokio::test]
194    async fn test_rust_project_finder_visit_workspace_without_package() {
195        let temp_dir = TempDir::new().unwrap();
196        let cargo_toml = temp_dir.path().join("Cargo.toml");
197        fs::write(
198            &cargo_toml,
199            r#"[workspace]
200members = ["crates/*"]
201"#,
202        )
203        .unwrap();
204
205        let mut finder = RustProjectFinder::new();
206        finder
207            .visit(&cargo_toml, &PathBuf::from("Cargo.toml"))
208            .await
209            .unwrap();
210
211        let projects = finder.projects();
212        assert_eq!(projects.len(), 1);
213        match projects[0] {
214            Project::Workspace(ws) => {
215                assert_eq!(ws.name(), None);
216                assert_eq!(ws.version(), None);
217            }
218            _ => panic!("Expected Workspace"),
219        }
220
221        temp_dir.close().unwrap();
222    }
223
224    #[tokio::test]
225    async fn test_rust_project_finder_visit_non_cargo_file() {
226        let temp_dir = TempDir::new().unwrap();
227        let other_file = temp_dir.path().join("other.txt");
228        fs::write(&other_file, "some content").unwrap();
229
230        let mut finder = RustProjectFinder::new();
231        finder
232            .visit(&other_file, &PathBuf::from("other.txt"))
233            .await
234            .unwrap();
235
236        assert_eq!(finder.projects().len(), 0);
237
238        temp_dir.close().unwrap();
239    }
240
241    #[tokio::test]
242    async fn test_rust_project_finder_visit_directory() {
243        let temp_dir = TempDir::new().unwrap();
244        let cargo_toml = temp_dir.path().join("Cargo.toml");
245        fs::write(
246            &cargo_toml,
247            r#"[package]
248name = "test-package"
249version = "1.0.0"
250"#,
251        )
252        .unwrap();
253
254        let mut finder = RustProjectFinder::new();
255        // Pass directory instead of file
256        finder
257            .visit(temp_dir.path(), &PathBuf::from("."))
258            .await
259            .unwrap();
260
261        assert_eq!(finder.projects().len(), 0);
262
263        temp_dir.close().unwrap();
264    }
265
266    #[tokio::test]
267    async fn test_rust_project_finder_visit_duplicate() {
268        let temp_dir = TempDir::new().unwrap();
269        let cargo_toml = temp_dir.path().join("Cargo.toml");
270        fs::write(
271            &cargo_toml,
272            r#"[package]
273name = "test-package"
274version = "1.0.0"
275"#,
276        )
277        .unwrap();
278
279        let mut finder = RustProjectFinder::new();
280        finder
281            .visit(&cargo_toml, &PathBuf::from("Cargo.toml"))
282            .await
283            .unwrap();
284
285        assert_eq!(finder.projects().len(), 1);
286
287        // Visit again - should not add duplicate
288        finder
289            .visit(&cargo_toml, &PathBuf::from("Cargo.toml"))
290            .await
291            .unwrap();
292
293        assert_eq!(finder.projects().len(), 1);
294
295        temp_dir.close().unwrap();
296    }
297
298    #[tokio::test]
299    async fn test_rust_project_finder_visit_multiple_packages() {
300        let temp_dir = TempDir::new().unwrap();
301        let cargo_toml1 = temp_dir.path().join("package1").join("Cargo.toml");
302        fs::create_dir_all(cargo_toml1.parent().unwrap()).unwrap();
303        fs::write(
304            &cargo_toml1,
305            r#"[package]
306name = "package1"
307version = "1.0.0"
308"#,
309        )
310        .unwrap();
311
312        let cargo_toml2 = temp_dir.path().join("package2").join("Cargo.toml");
313        fs::create_dir_all(cargo_toml2.parent().unwrap()).unwrap();
314        fs::write(
315            &cargo_toml2,
316            r#"[package]
317name = "package2"
318version = "2.0.0"
319"#,
320        )
321        .unwrap();
322
323        let mut finder = RustProjectFinder::new();
324        finder
325            .visit(&cargo_toml1, &PathBuf::from("package1/Cargo.toml"))
326            .await
327            .unwrap();
328        finder
329            .visit(&cargo_toml2, &PathBuf::from("package2/Cargo.toml"))
330            .await
331            .unwrap();
332
333        let projects = finder.projects();
334        assert_eq!(projects.len(), 2);
335
336        temp_dir.close().unwrap();
337    }
338
339    #[tokio::test]
340    async fn test_rust_project_finder_projects_mut() {
341        let temp_dir = TempDir::new().unwrap();
342        let cargo_toml = temp_dir.path().join("Cargo.toml");
343        fs::write(
344            &cargo_toml,
345            r#"[package]
346name = "test-package"
347version = "1.0.0"
348"#,
349        )
350        .unwrap();
351
352        let mut finder = RustProjectFinder::new();
353        finder
354            .visit(&cargo_toml, &PathBuf::from("Cargo.toml"))
355            .await
356            .unwrap();
357
358        let mut_projects = finder.projects_mut();
359        assert_eq!(mut_projects.len(), 1);
360
361        temp_dir.close().unwrap();
362    }
363}