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