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::NodePackage, workspace::NodeWorkspace};
11
12#[derive(Debug)]
13pub struct NodeProjectFinder {
14 projects: HashMap<PathBuf, Project>,
15 project_files: Vec<&'static str>,
16}
17
18impl Default for NodeProjectFinder {
19 fn default() -> Self {
20 Self::new()
21 }
22}
23
24impl NodeProjectFinder {
25 #[must_use]
26 pub fn new() -> Self {
27 Self {
28 projects: HashMap::new(),
29 project_files: vec!["package.json"],
30 }
31 }
32}
33
34#[async_trait]
35impl ProjectFinder for NodeProjectFinder {
36 fn projects(&self) -> Vec<&Project> {
37 self.projects.values().collect::<Vec<_>>()
38 }
39 fn projects_mut(&mut self) -> Vec<&mut Project> {
40 self.projects.values_mut().collect::<Vec<_>>()
41 }
42
43 fn project_files(&self) -> &[&str] {
44 &self.project_files
45 }
46
47 async fn visit(&mut self, path: &Path, relative_path: &Path) -> Result<()> {
48 if path.is_file()
50 && self.project_files().contains(
51 &path
52 .file_name()
53 .context(format!("File name not found - {}", path.display()))?
54 .to_str()
55 .context(format!("File name not found - {}", path.display()))?,
56 )
57 {
58 if self.projects.contains_key(path) {
59 return Ok(());
60 }
61 let package_json = read_to_string(path).await?;
63 let package_json: serde_json::Value = serde_json::from_str(&package_json)?;
64 let (path, mut project) = if package_json.get("workspaces").is_some()
66 || path
67 .parent()
68 .context(format!("Parent not found - {}", path.display()))?
69 .join("pnpm-workspace.yaml")
70 .is_file()
71 {
72 let version = package_json["version"]
73 .as_str()
74 .map(std::string::ToString::to_string);
75 let name = package_json["name"]
76 .as_str()
77 .map(std::string::ToString::to_string);
78 (
79 path.to_path_buf(),
80 Project::Workspace(Box::new(NodeWorkspace::new(
81 name,
82 version,
83 path.to_path_buf(),
84 relative_path.to_path_buf(),
85 ))),
86 )
87 } else {
88 let version = package_json["version"]
89 .as_str()
90 .map(std::string::ToString::to_string);
91 let name = package_json["name"]
92 .as_str()
93 .map(std::string::ToString::to_string);
94 (
95 path.to_path_buf(),
96 Project::Package(Box::new(NodePackage::new(
97 name,
98 version,
99 path.to_path_buf(),
100 relative_path.to_path_buf(),
101 ))),
102 )
103 };
104
105 if let Some(deps) = package_json.get("dependencies").and_then(|d| d.as_object()) {
106 for (dep_name, value) in deps {
107 if value.as_str() == Some("workspace:*") {
110 project.add_dependency(dep_name);
111 }
112 }
113 }
114
115 self.projects.insert(path, project);
116 }
117 Ok(())
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use changepacks_core::Project;
125 use std::fs;
126 use tempfile::TempDir;
127
128 #[test]
129 fn test_node_project_finder_new() {
130 let finder = NodeProjectFinder::new();
131 assert_eq!(finder.project_files(), &["package.json"]);
132 assert_eq!(finder.projects().len(), 0);
133 }
134
135 #[test]
136 fn test_node_project_finder_default() {
137 let finder = NodeProjectFinder::default();
138 assert_eq!(finder.project_files(), &["package.json"]);
139 assert_eq!(finder.projects().len(), 0);
140 }
141
142 #[tokio::test]
143 async fn test_node_project_finder_visit_package() {
144 let temp_dir = TempDir::new().unwrap();
145 let package_json = temp_dir.path().join("package.json");
146 fs::write(
147 &package_json,
148 r#"{
149 "name": "test-package",
150 "version": "1.0.0"
151}
152"#,
153 )
154 .unwrap();
155
156 let mut finder = NodeProjectFinder::new();
157 finder
158 .visit(&package_json, &PathBuf::from("package.json"))
159 .await
160 .unwrap();
161
162 let projects = finder.projects();
163 assert_eq!(projects.len(), 1);
164 match projects[0] {
165 Project::Package(pkg) => {
166 assert_eq!(pkg.name(), Some("test-package"));
167 assert_eq!(pkg.version(), Some("1.0.0"));
168 }
169 _ => panic!("Expected Package"),
170 }
171
172 temp_dir.close().unwrap();
173 }
174
175 #[tokio::test]
176 async fn test_node_project_finder_visit_workspace_with_workspaces() {
177 let temp_dir = TempDir::new().unwrap();
178 let package_json = temp_dir.path().join("package.json");
179 fs::write(
180 &package_json,
181 r#"{
182 "name": "test-workspace",
183 "version": "1.0.0",
184 "workspaces": ["packages/*"]
185}
186"#,
187 )
188 .unwrap();
189
190 let mut finder = NodeProjectFinder::new();
191 finder
192 .visit(&package_json, &PathBuf::from("package.json"))
193 .await
194 .unwrap();
195
196 let projects = finder.projects();
197 assert_eq!(projects.len(), 1);
198 match projects[0] {
199 Project::Workspace(ws) => {
200 assert_eq!(ws.name(), Some("test-workspace"));
201 assert_eq!(ws.version(), Some("1.0.0"));
202 }
203 _ => panic!("Expected Workspace"),
204 }
205
206 temp_dir.close().unwrap();
207 }
208
209 #[tokio::test]
210 async fn test_node_project_finder_visit_workspace_with_pnpm_workspace() {
211 let temp_dir = TempDir::new().unwrap();
212 let package_json = temp_dir.path().join("package.json");
213 fs::write(
214 &package_json,
215 r#"{
216 "name": "test-workspace",
217 "version": "1.0.0"
218}
219"#,
220 )
221 .unwrap();
222
223 let pnpm_workspace = temp_dir.path().join("pnpm-workspace.yaml");
225 fs::write(&pnpm_workspace, "packages:\n - 'packages/*'\n").unwrap();
226
227 let mut finder = NodeProjectFinder::new();
228 finder
229 .visit(&package_json, &PathBuf::from("package.json"))
230 .await
231 .unwrap();
232
233 let projects = finder.projects();
234 assert_eq!(projects.len(), 1);
235 match projects[0] {
236 Project::Workspace(ws) => {
237 assert_eq!(ws.name(), Some("test-workspace"));
238 assert_eq!(ws.version(), Some("1.0.0"));
239 }
240 _ => panic!("Expected Workspace"),
241 }
242
243 temp_dir.close().unwrap();
244 }
245
246 #[tokio::test]
247 async fn test_node_project_finder_visit_workspace_without_version() {
248 let temp_dir = TempDir::new().unwrap();
249 let package_json = temp_dir.path().join("package.json");
250 fs::write(
251 &package_json,
252 r#"{
253 "name": "test-workspace",
254 "workspaces": ["packages/*"]
255}
256"#,
257 )
258 .unwrap();
259
260 let mut finder = NodeProjectFinder::new();
261 finder
262 .visit(&package_json, &PathBuf::from("package.json"))
263 .await
264 .unwrap();
265
266 let projects = finder.projects();
267 assert_eq!(projects.len(), 1);
268 match projects[0] {
269 Project::Workspace(ws) => {
270 assert_eq!(ws.name(), Some("test-workspace"));
271 assert_eq!(ws.version(), None);
272 }
273 _ => panic!("Expected Workspace"),
274 }
275
276 temp_dir.close().unwrap();
277 }
278
279 #[tokio::test]
280 async fn test_node_project_finder_visit_non_package_file() {
281 let temp_dir = TempDir::new().unwrap();
282 let other_file = temp_dir.path().join("other.txt");
283 fs::write(&other_file, "some content").unwrap();
284
285 let mut finder = NodeProjectFinder::new();
286 finder
287 .visit(&other_file, &PathBuf::from("other.txt"))
288 .await
289 .unwrap();
290
291 assert_eq!(finder.projects().len(), 0);
292
293 temp_dir.close().unwrap();
294 }
295
296 #[tokio::test]
297 async fn test_node_project_finder_visit_directory() {
298 let temp_dir = TempDir::new().unwrap();
299 let package_json = temp_dir.path().join("package.json");
300 fs::write(
301 &package_json,
302 r#"{
303 "name": "test-package",
304 "version": "1.0.0"
305}
306"#,
307 )
308 .unwrap();
309
310 let mut finder = NodeProjectFinder::new();
311 finder
313 .visit(temp_dir.path(), &PathBuf::from("."))
314 .await
315 .unwrap();
316
317 assert_eq!(finder.projects().len(), 0);
318
319 temp_dir.close().unwrap();
320 }
321
322 #[tokio::test]
323 async fn test_node_project_finder_visit_duplicate() {
324 let temp_dir = TempDir::new().unwrap();
325 let package_json = temp_dir.path().join("package.json");
326 fs::write(
327 &package_json,
328 r#"{
329 "name": "test-package",
330 "version": "1.0.0"
331}
332"#,
333 )
334 .unwrap();
335
336 let mut finder = NodeProjectFinder::new();
337 finder
338 .visit(&package_json, &PathBuf::from("package.json"))
339 .await
340 .unwrap();
341
342 assert_eq!(finder.projects().len(), 1);
343
344 finder
346 .visit(&package_json, &PathBuf::from("package.json"))
347 .await
348 .unwrap();
349
350 assert_eq!(finder.projects().len(), 1);
351
352 temp_dir.close().unwrap();
353 }
354
355 #[tokio::test]
356 async fn test_node_project_finder_visit_multiple_packages() {
357 let temp_dir = TempDir::new().unwrap();
358 let package_json1 = temp_dir.path().join("package1").join("package.json");
359 fs::create_dir_all(package_json1.parent().unwrap()).unwrap();
360 fs::write(
361 &package_json1,
362 r#"{
363 "name": "package1",
364 "version": "1.0.0"
365}
366"#,
367 )
368 .unwrap();
369
370 let package_json2 = temp_dir.path().join("package2").join("package.json");
371 fs::create_dir_all(package_json2.parent().unwrap()).unwrap();
372 fs::write(
373 &package_json2,
374 r#"{
375 "name": "package2",
376 "version": "2.0.0"
377}
378"#,
379 )
380 .unwrap();
381
382 let mut finder = NodeProjectFinder::new();
383 finder
384 .visit(&package_json1, &PathBuf::from("package1/package.json"))
385 .await
386 .unwrap();
387 finder
388 .visit(&package_json2, &PathBuf::from("package2/package.json"))
389 .await
390 .unwrap();
391
392 let projects = finder.projects();
393 assert_eq!(projects.len(), 2);
394
395 temp_dir.close().unwrap();
396 }
397
398 #[tokio::test]
399 async fn test_node_project_finder_projects_mut() {
400 let temp_dir = TempDir::new().unwrap();
401 let package_json = temp_dir.path().join("package.json");
402 fs::write(
403 &package_json,
404 r#"{
405 "name": "test-package",
406 "version": "1.0.0"
407}
408"#,
409 )
410 .unwrap();
411
412 let mut finder = NodeProjectFinder::new();
413 finder
414 .visit(&package_json, &PathBuf::from("package.json"))
415 .await
416 .unwrap();
417
418 let mut_projects = finder.projects_mut();
419 assert_eq!(mut_projects.len(), 1);
420
421 temp_dir.close().unwrap();
422 }
423
424 #[tokio::test]
425 async fn test_node_project_finder_visit_package_with_workspace_dependencies() {
426 let temp_dir = TempDir::new().unwrap();
427 let package_json = temp_dir.path().join("package.json");
428 fs::write(
429 &package_json,
430 r#"{
431 "name": "test-package",
432 "version": "1.0.0",
433 "dependencies": {
434 "core": "workspace:*",
435 "utils": "workspace:^",
436 "external": "^1.0.0"
437 }
438}
439"#,
440 )
441 .unwrap();
442
443 let mut finder = NodeProjectFinder::new();
444 finder
445 .visit(&package_json, &PathBuf::from("package.json"))
446 .await
447 .unwrap();
448
449 let projects = finder.projects();
450 assert_eq!(projects.len(), 1);
451
452 let project = projects.first().unwrap();
453 let deps = project.dependencies();
454 assert_eq!(deps.len(), 1);
456 assert!(deps.contains("core"));
457 assert!(!deps.contains("utils"));
459 assert!(!deps.contains("external"));
460
461 temp_dir.close().unwrap();
462 }
463}