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 pub fn new() -> Self {
26 Self {
27 projects: HashMap::new(),
28 project_files: vec!["package.json"],
29 }
30 }
31}
32
33#[async_trait]
34impl ProjectFinder for NodeProjectFinder {
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()
49 && self.project_files().contains(
50 &path
51 .file_name()
52 .context(format!("File name not found - {}", path.display()))?
53 .to_str()
54 .context(format!("File name not found - {}", path.display()))?,
55 )
56 {
57 if self.projects.contains_key(path) {
58 return Ok(());
59 }
60 let package_json = read_to_string(path).await?;
62 let package_json: serde_json::Value = serde_json::from_str(&package_json)?;
63 if package_json.get("workspaces").is_some()
65 || path
66 .parent()
67 .context(format!("Parent not found - {}", path.display()))?
68 .join("pnpm-workspace.yaml")
69 .is_file()
70 {
71 let version = package_json["version"].as_str().map(|v| v.to_string());
72 let name = package_json["name"].as_str().map(|v| v.to_string());
73 self.projects.insert(
74 path.to_path_buf(),
75 Project::Workspace(Box::new(NodeWorkspace::new(
76 name,
77 version,
78 path.to_path_buf(),
79 relative_path.to_path_buf(),
80 ))),
81 );
82 } else {
83 let version = package_json["version"]
84 .as_str()
85 .context(format!("Version not found - {}", path.display()))?
86 .to_string();
87 let name = package_json["name"]
88 .as_str()
89 .context(format!("Name not found - {}", path.display()))?
90 .to_string();
91
92 self.projects.insert(
93 path.to_path_buf(),
94 Project::Package(Box::new(NodePackage::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_node_project_finder_new() {
116 let finder = NodeProjectFinder::new();
117 assert_eq!(finder.project_files(), &["package.json"]);
118 assert_eq!(finder.projects().len(), 0);
119 }
120
121 #[test]
122 fn test_node_project_finder_default() {
123 let finder = NodeProjectFinder::default();
124 assert_eq!(finder.project_files(), &["package.json"]);
125 assert_eq!(finder.projects().len(), 0);
126 }
127
128 #[tokio::test]
129 async fn test_node_project_finder_visit_package() {
130 let temp_dir = TempDir::new().unwrap();
131 let package_json = temp_dir.path().join("package.json");
132 fs::write(
133 &package_json,
134 r#"{
135 "name": "test-package",
136 "version": "1.0.0"
137}
138"#,
139 )
140 .unwrap();
141
142 let mut finder = NodeProjectFinder::new();
143 finder
144 .visit(&package_json, &PathBuf::from("package.json"))
145 .await
146 .unwrap();
147
148 let projects = finder.projects();
149 assert_eq!(projects.len(), 1);
150 match projects[0] {
151 Project::Package(pkg) => {
152 assert_eq!(pkg.name(), "test-package");
153 assert_eq!(pkg.version(), "1.0.0");
154 }
155 _ => panic!("Expected Package"),
156 }
157
158 temp_dir.close().unwrap();
159 }
160
161 #[tokio::test]
162 async fn test_node_project_finder_visit_workspace_with_workspaces() {
163 let temp_dir = TempDir::new().unwrap();
164 let package_json = temp_dir.path().join("package.json");
165 fs::write(
166 &package_json,
167 r#"{
168 "name": "test-workspace",
169 "version": "1.0.0",
170 "workspaces": ["packages/*"]
171}
172"#,
173 )
174 .unwrap();
175
176 let mut finder = NodeProjectFinder::new();
177 finder
178 .visit(&package_json, &PathBuf::from("package.json"))
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_node_project_finder_visit_workspace_with_pnpm_workspace() {
197 let temp_dir = TempDir::new().unwrap();
198 let package_json = temp_dir.path().join("package.json");
199 fs::write(
200 &package_json,
201 r#"{
202 "name": "test-workspace",
203 "version": "1.0.0"
204}
205"#,
206 )
207 .unwrap();
208
209 let pnpm_workspace = temp_dir.path().join("pnpm-workspace.yaml");
211 fs::write(&pnpm_workspace, "packages:\n - 'packages/*'\n").unwrap();
212
213 let mut finder = NodeProjectFinder::new();
214 finder
215 .visit(&package_json, &PathBuf::from("package.json"))
216 .await
217 .unwrap();
218
219 let projects = finder.projects();
220 assert_eq!(projects.len(), 1);
221 match projects[0] {
222 Project::Workspace(ws) => {
223 assert_eq!(ws.name(), Some("test-workspace"));
224 assert_eq!(ws.version(), Some("1.0.0"));
225 }
226 _ => panic!("Expected Workspace"),
227 }
228
229 temp_dir.close().unwrap();
230 }
231
232 #[tokio::test]
233 async fn test_node_project_finder_visit_workspace_without_version() {
234 let temp_dir = TempDir::new().unwrap();
235 let package_json = temp_dir.path().join("package.json");
236 fs::write(
237 &package_json,
238 r#"{
239 "name": "test-workspace",
240 "workspaces": ["packages/*"]
241}
242"#,
243 )
244 .unwrap();
245
246 let mut finder = NodeProjectFinder::new();
247 finder
248 .visit(&package_json, &PathBuf::from("package.json"))
249 .await
250 .unwrap();
251
252 let projects = finder.projects();
253 assert_eq!(projects.len(), 1);
254 match projects[0] {
255 Project::Workspace(ws) => {
256 assert_eq!(ws.name(), Some("test-workspace"));
257 assert_eq!(ws.version(), None);
258 }
259 _ => panic!("Expected Workspace"),
260 }
261
262 temp_dir.close().unwrap();
263 }
264
265 #[tokio::test]
266 async fn test_node_project_finder_visit_non_package_file() {
267 let temp_dir = TempDir::new().unwrap();
268 let other_file = temp_dir.path().join("other.txt");
269 fs::write(&other_file, "some content").unwrap();
270
271 let mut finder = NodeProjectFinder::new();
272 finder
273 .visit(&other_file, &PathBuf::from("other.txt"))
274 .await
275 .unwrap();
276
277 assert_eq!(finder.projects().len(), 0);
278
279 temp_dir.close().unwrap();
280 }
281
282 #[tokio::test]
283 async fn test_node_project_finder_visit_directory() {
284 let temp_dir = TempDir::new().unwrap();
285 let package_json = temp_dir.path().join("package.json");
286 fs::write(
287 &package_json,
288 r#"{
289 "name": "test-package",
290 "version": "1.0.0"
291}
292"#,
293 )
294 .unwrap();
295
296 let mut finder = NodeProjectFinder::new();
297 finder
299 .visit(temp_dir.path(), &PathBuf::from("."))
300 .await
301 .unwrap();
302
303 assert_eq!(finder.projects().len(), 0);
304
305 temp_dir.close().unwrap();
306 }
307
308 #[tokio::test]
309 async fn test_node_project_finder_visit_duplicate() {
310 let temp_dir = TempDir::new().unwrap();
311 let package_json = temp_dir.path().join("package.json");
312 fs::write(
313 &package_json,
314 r#"{
315 "name": "test-package",
316 "version": "1.0.0"
317}
318"#,
319 )
320 .unwrap();
321
322 let mut finder = NodeProjectFinder::new();
323 finder
324 .visit(&package_json, &PathBuf::from("package.json"))
325 .await
326 .unwrap();
327
328 assert_eq!(finder.projects().len(), 1);
329
330 finder
332 .visit(&package_json, &PathBuf::from("package.json"))
333 .await
334 .unwrap();
335
336 assert_eq!(finder.projects().len(), 1);
337
338 temp_dir.close().unwrap();
339 }
340
341 #[tokio::test]
342 async fn test_node_project_finder_visit_multiple_packages() {
343 let temp_dir = TempDir::new().unwrap();
344 let package_json1 = temp_dir.path().join("package1").join("package.json");
345 fs::create_dir_all(package_json1.parent().unwrap()).unwrap();
346 fs::write(
347 &package_json1,
348 r#"{
349 "name": "package1",
350 "version": "1.0.0"
351}
352"#,
353 )
354 .unwrap();
355
356 let package_json2 = temp_dir.path().join("package2").join("package.json");
357 fs::create_dir_all(package_json2.parent().unwrap()).unwrap();
358 fs::write(
359 &package_json2,
360 r#"{
361 "name": "package2",
362 "version": "2.0.0"
363}
364"#,
365 )
366 .unwrap();
367
368 let mut finder = NodeProjectFinder::new();
369 finder
370 .visit(&package_json1, &PathBuf::from("package1/package.json"))
371 .await
372 .unwrap();
373 finder
374 .visit(&package_json2, &PathBuf::from("package2/package.json"))
375 .await
376 .unwrap();
377
378 let projects = finder.projects();
379 assert_eq!(projects.len(), 2);
380
381 temp_dir.close().unwrap();
382 }
383
384 #[tokio::test]
385 async fn test_node_project_finder_projects_mut() {
386 let temp_dir = TempDir::new().unwrap();
387 let package_json = temp_dir.path().join("package.json");
388 fs::write(
389 &package_json,
390 r#"{
391 "name": "test-package",
392 "version": "1.0.0"
393}
394"#,
395 )
396 .unwrap();
397
398 let mut finder = NodeProjectFinder::new();
399 finder
400 .visit(&package_json, &PathBuf::from("package.json"))
401 .await
402 .unwrap();
403
404 let mut_projects = finder.projects_mut();
405 assert_eq!(mut_projects.len(), 1);
406
407 temp_dir.close().unwrap();
408 }
409
410 #[tokio::test]
411 async fn test_node_project_finder_visit_package_without_version() {
412 let temp_dir = TempDir::new().unwrap();
413 let package_json = temp_dir.path().join("package.json");
414 fs::write(
415 &package_json,
416 r#"{
417 "name": "test-package"
418}
419"#,
420 )
421 .unwrap();
422
423 let mut finder = NodeProjectFinder::new();
424 let result = finder
425 .visit(&package_json, &PathBuf::from("package.json"))
426 .await;
427
428 assert!(result.is_err());
429 assert_eq!(finder.projects().len(), 0);
430
431 temp_dir.close().unwrap();
432 }
433
434 #[tokio::test]
435 async fn test_node_project_finder_visit_package_without_name() {
436 let temp_dir = TempDir::new().unwrap();
437 let package_json = temp_dir.path().join("package.json");
438 fs::write(
439 &package_json,
440 r#"{
441 "version": "1.0.0"
442}
443"#,
444 )
445 .unwrap();
446
447 let mut finder = NodeProjectFinder::new();
448 let result = finder
449 .visit(&package_json, &PathBuf::from("package.json"))
450 .await;
451
452 assert!(result.is_err());
453 assert_eq!(finder.projects().len(), 0);
454
455 temp_dir.close().unwrap();
456 }
457}