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            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        // Create pnpm-workspace.yaml
217        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        // Pass directory instead of file
305        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        // Visit again - should not add duplicate
338        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}