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 let (path, mut project) = 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 (
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"].as_str().map(|v| v.to_string());
84 let name = package_json["name"].as_str().map(|v| v.to_string());
85 (
86 path.to_path_buf(),
87 Project::Package(Box::new(NodePackage::new(
88 name,
89 version,
90 path.to_path_buf(),
91 relative_path.to_path_buf(),
92 ))),
93 )
94 };
95
96 if let Some(deps) = package_json.get("dependencies").and_then(|d| d.as_object()) {
97 for (dep_name, value) in deps {
98 if value.as_str() == Some("workspace:*") {
101 project.add_dependency(dep_name);
102 }
103 }
104 }
105
106 self.projects.insert(path, project);
107 }
108 Ok(())
109 }
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115 use changepacks_core::Project;
116 use std::fs;
117 use tempfile::TempDir;
118
119 #[test]
120 fn test_node_project_finder_new() {
121 let finder = NodeProjectFinder::new();
122 assert_eq!(finder.project_files(), &["package.json"]);
123 assert_eq!(finder.projects().len(), 0);
124 }
125
126 #[test]
127 fn test_node_project_finder_default() {
128 let finder = NodeProjectFinder::default();
129 assert_eq!(finder.project_files(), &["package.json"]);
130 assert_eq!(finder.projects().len(), 0);
131 }
132
133 #[tokio::test]
134 async fn test_node_project_finder_visit_package() {
135 let temp_dir = TempDir::new().unwrap();
136 let package_json = temp_dir.path().join("package.json");
137 fs::write(
138 &package_json,
139 r#"{
140 "name": "test-package",
141 "version": "1.0.0"
142}
143"#,
144 )
145 .unwrap();
146
147 let mut finder = NodeProjectFinder::new();
148 finder
149 .visit(&package_json, &PathBuf::from("package.json"))
150 .await
151 .unwrap();
152
153 let projects = finder.projects();
154 assert_eq!(projects.len(), 1);
155 match projects[0] {
156 Project::Package(pkg) => {
157 assert_eq!(pkg.name(), Some("test-package"));
158 assert_eq!(pkg.version(), Some("1.0.0"));
159 }
160 _ => panic!("Expected Package"),
161 }
162
163 temp_dir.close().unwrap();
164 }
165
166 #[tokio::test]
167 async fn test_node_project_finder_visit_workspace_with_workspaces() {
168 let temp_dir = TempDir::new().unwrap();
169 let package_json = temp_dir.path().join("package.json");
170 fs::write(
171 &package_json,
172 r#"{
173 "name": "test-workspace",
174 "version": "1.0.0",
175 "workspaces": ["packages/*"]
176}
177"#,
178 )
179 .unwrap();
180
181 let mut finder = NodeProjectFinder::new();
182 finder
183 .visit(&package_json, &PathBuf::from("package.json"))
184 .await
185 .unwrap();
186
187 let projects = finder.projects();
188 assert_eq!(projects.len(), 1);
189 match projects[0] {
190 Project::Workspace(ws) => {
191 assert_eq!(ws.name(), Some("test-workspace"));
192 assert_eq!(ws.version(), Some("1.0.0"));
193 }
194 _ => panic!("Expected Workspace"),
195 }
196
197 temp_dir.close().unwrap();
198 }
199
200 #[tokio::test]
201 async fn test_node_project_finder_visit_workspace_with_pnpm_workspace() {
202 let temp_dir = TempDir::new().unwrap();
203 let package_json = temp_dir.path().join("package.json");
204 fs::write(
205 &package_json,
206 r#"{
207 "name": "test-workspace",
208 "version": "1.0.0"
209}
210"#,
211 )
212 .unwrap();
213
214 let pnpm_workspace = temp_dir.path().join("pnpm-workspace.yaml");
216 fs::write(&pnpm_workspace, "packages:\n - 'packages/*'\n").unwrap();
217
218 let mut finder = NodeProjectFinder::new();
219 finder
220 .visit(&package_json, &PathBuf::from("package.json"))
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(), Some("test-workspace"));
229 assert_eq!(ws.version(), Some("1.0.0"));
230 }
231 _ => panic!("Expected Workspace"),
232 }
233
234 temp_dir.close().unwrap();
235 }
236
237 #[tokio::test]
238 async fn test_node_project_finder_visit_workspace_without_version() {
239 let temp_dir = TempDir::new().unwrap();
240 let package_json = temp_dir.path().join("package.json");
241 fs::write(
242 &package_json,
243 r#"{
244 "name": "test-workspace",
245 "workspaces": ["packages/*"]
246}
247"#,
248 )
249 .unwrap();
250
251 let mut finder = NodeProjectFinder::new();
252 finder
253 .visit(&package_json, &PathBuf::from("package.json"))
254 .await
255 .unwrap();
256
257 let projects = finder.projects();
258 assert_eq!(projects.len(), 1);
259 match projects[0] {
260 Project::Workspace(ws) => {
261 assert_eq!(ws.name(), Some("test-workspace"));
262 assert_eq!(ws.version(), None);
263 }
264 _ => panic!("Expected Workspace"),
265 }
266
267 temp_dir.close().unwrap();
268 }
269
270 #[tokio::test]
271 async fn test_node_project_finder_visit_non_package_file() {
272 let temp_dir = TempDir::new().unwrap();
273 let other_file = temp_dir.path().join("other.txt");
274 fs::write(&other_file, "some content").unwrap();
275
276 let mut finder = NodeProjectFinder::new();
277 finder
278 .visit(&other_file, &PathBuf::from("other.txt"))
279 .await
280 .unwrap();
281
282 assert_eq!(finder.projects().len(), 0);
283
284 temp_dir.close().unwrap();
285 }
286
287 #[tokio::test]
288 async fn test_node_project_finder_visit_directory() {
289 let temp_dir = TempDir::new().unwrap();
290 let package_json = temp_dir.path().join("package.json");
291 fs::write(
292 &package_json,
293 r#"{
294 "name": "test-package",
295 "version": "1.0.0"
296}
297"#,
298 )
299 .unwrap();
300
301 let mut finder = NodeProjectFinder::new();
302 finder
304 .visit(temp_dir.path(), &PathBuf::from("."))
305 .await
306 .unwrap();
307
308 assert_eq!(finder.projects().len(), 0);
309
310 temp_dir.close().unwrap();
311 }
312
313 #[tokio::test]
314 async fn test_node_project_finder_visit_duplicate() {
315 let temp_dir = TempDir::new().unwrap();
316 let package_json = temp_dir.path().join("package.json");
317 fs::write(
318 &package_json,
319 r#"{
320 "name": "test-package",
321 "version": "1.0.0"
322}
323"#,
324 )
325 .unwrap();
326
327 let mut finder = NodeProjectFinder::new();
328 finder
329 .visit(&package_json, &PathBuf::from("package.json"))
330 .await
331 .unwrap();
332
333 assert_eq!(finder.projects().len(), 1);
334
335 finder
337 .visit(&package_json, &PathBuf::from("package.json"))
338 .await
339 .unwrap();
340
341 assert_eq!(finder.projects().len(), 1);
342
343 temp_dir.close().unwrap();
344 }
345
346 #[tokio::test]
347 async fn test_node_project_finder_visit_multiple_packages() {
348 let temp_dir = TempDir::new().unwrap();
349 let package_json1 = temp_dir.path().join("package1").join("package.json");
350 fs::create_dir_all(package_json1.parent().unwrap()).unwrap();
351 fs::write(
352 &package_json1,
353 r#"{
354 "name": "package1",
355 "version": "1.0.0"
356}
357"#,
358 )
359 .unwrap();
360
361 let package_json2 = temp_dir.path().join("package2").join("package.json");
362 fs::create_dir_all(package_json2.parent().unwrap()).unwrap();
363 fs::write(
364 &package_json2,
365 r#"{
366 "name": "package2",
367 "version": "2.0.0"
368}
369"#,
370 )
371 .unwrap();
372
373 let mut finder = NodeProjectFinder::new();
374 finder
375 .visit(&package_json1, &PathBuf::from("package1/package.json"))
376 .await
377 .unwrap();
378 finder
379 .visit(&package_json2, &PathBuf::from("package2/package.json"))
380 .await
381 .unwrap();
382
383 let projects = finder.projects();
384 assert_eq!(projects.len(), 2);
385
386 temp_dir.close().unwrap();
387 }
388
389 #[tokio::test]
390 async fn test_node_project_finder_projects_mut() {
391 let temp_dir = TempDir::new().unwrap();
392 let package_json = temp_dir.path().join("package.json");
393 fs::write(
394 &package_json,
395 r#"{
396 "name": "test-package",
397 "version": "1.0.0"
398}
399"#,
400 )
401 .unwrap();
402
403 let mut finder = NodeProjectFinder::new();
404 finder
405 .visit(&package_json, &PathBuf::from("package.json"))
406 .await
407 .unwrap();
408
409 let mut_projects = finder.projects_mut();
410 assert_eq!(mut_projects.len(), 1);
411
412 temp_dir.close().unwrap();
413 }
414
415 #[tokio::test]
416 async fn test_node_project_finder_visit_package_with_workspace_dependencies() {
417 let temp_dir = TempDir::new().unwrap();
418 let package_json = temp_dir.path().join("package.json");
419 fs::write(
420 &package_json,
421 r#"{
422 "name": "test-package",
423 "version": "1.0.0",
424 "dependencies": {
425 "core": "workspace:*",
426 "utils": "workspace:^",
427 "external": "^1.0.0"
428 }
429}
430"#,
431 )
432 .unwrap();
433
434 let mut finder = NodeProjectFinder::new();
435 finder
436 .visit(&package_json, &PathBuf::from("package.json"))
437 .await
438 .unwrap();
439
440 let projects = finder.projects();
441 assert_eq!(projects.len(), 1);
442
443 let project = projects.first().unwrap();
444 let deps = project.dependencies();
445 assert_eq!(deps.len(), 1);
447 assert!(deps.contains("core"));
448 assert!(!deps.contains("utils"));
450 assert!(!deps.contains("external"));
451
452 temp_dir.close().unwrap();
453 }
454}