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