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