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