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"].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        // Create pnpm-workspace.yaml
204        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        // Pass directory instead of file
292        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        // Visit again - should not add duplicate
325        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}