changepacks_node/
finder.rs

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        // glob all the package.json in the root without .gitignore
48        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            // read package.json
61            let package_json = read_to_string(path).await?;
62            let package_json: serde_json::Value = serde_json::from_str(&package_json)?;
63            // if workspaces
64            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        // Create pnpm-workspace.yaml
210        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        // Pass directory instead of file
298        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        // Visit again - should not add duplicate
331        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}