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 let cargo_toml = read_to_string(path).await?;
61 let cargo_toml: toml::Value = toml::from_str(&cargo_toml)?;
62 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 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 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 assert!(!deps.contains("external"));
412 }
413 _ => panic!("Expected Package"),
414 }
415
416 temp_dir.close().unwrap();
417 }
418}