Skip to main content

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    #[must_use]
26    pub fn new() -> Self {
27        Self {
28            projects: HashMap::new(),
29            project_files: vec!["package.json"],
30        }
31    }
32}
33
34#[async_trait]
35impl ProjectFinder for NodeProjectFinder {
36    fn projects(&self) -> Vec<&Project> {
37        self.projects.values().collect::<Vec<_>>()
38    }
39    fn projects_mut(&mut self) -> Vec<&mut Project> {
40        self.projects.values_mut().collect::<Vec<_>>()
41    }
42
43    fn project_files(&self) -> &[&str] {
44        &self.project_files
45    }
46
47    async fn visit(&mut self, path: &Path, relative_path: &Path) -> Result<()> {
48        // glob all the package.json in the root without .gitignore
49        if path.is_file()
50            && self.project_files().contains(
51                &path
52                    .file_name()
53                    .context(format!("File name not found - {}", path.display()))?
54                    .to_str()
55                    .context(format!("File name not found - {}", path.display()))?,
56            )
57        {
58            if self.projects.contains_key(path) {
59                return Ok(());
60            }
61            // read package.json
62            let package_json = read_to_string(path).await?;
63            let package_json: serde_json::Value = serde_json::from_str(&package_json)?;
64            // if workspaces
65            let (path, mut project) = if package_json.get("workspaces").is_some()
66                || path
67                    .parent()
68                    .context(format!("Parent not found - {}", path.display()))?
69                    .join("pnpm-workspace.yaml")
70                    .is_file()
71            {
72                let version = package_json["version"]
73                    .as_str()
74                    .map(std::string::ToString::to_string);
75                let name = package_json["name"]
76                    .as_str()
77                    .map(std::string::ToString::to_string);
78                (
79                    path.to_path_buf(),
80                    Project::Workspace(Box::new(NodeWorkspace::new(
81                        name,
82                        version,
83                        path.to_path_buf(),
84                        relative_path.to_path_buf(),
85                    ))),
86                )
87            } else {
88                let version = package_json["version"]
89                    .as_str()
90                    .map(std::string::ToString::to_string);
91                let name = package_json["name"]
92                    .as_str()
93                    .map(std::string::ToString::to_string);
94                (
95                    path.to_path_buf(),
96                    Project::Package(Box::new(NodePackage::new(
97                        name,
98                        version,
99                        path.to_path_buf(),
100                        relative_path.to_path_buf(),
101                    ))),
102                )
103            };
104
105            if let Some(deps) = package_json.get("dependencies").and_then(|d| d.as_object()) {
106                for (dep_name, value) in deps {
107                    // Only track workspace:* dependencies (exact version sync)
108                    // workspace:^ uses semver ranges so doesn't need forced updates
109                    if value.as_str() == Some("workspace:*") {
110                        project.add_dependency(dep_name);
111                    }
112                }
113            }
114
115            self.projects.insert(path, project);
116        }
117        Ok(())
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use changepacks_core::Project;
125    use std::fs;
126    use tempfile::TempDir;
127
128    #[test]
129    fn test_node_project_finder_new() {
130        let finder = NodeProjectFinder::new();
131        assert_eq!(finder.project_files(), &["package.json"]);
132        assert_eq!(finder.projects().len(), 0);
133    }
134
135    #[test]
136    fn test_node_project_finder_default() {
137        let finder = NodeProjectFinder::default();
138        assert_eq!(finder.project_files(), &["package.json"]);
139        assert_eq!(finder.projects().len(), 0);
140    }
141
142    #[tokio::test]
143    async fn test_node_project_finder_visit_package() {
144        let temp_dir = TempDir::new().unwrap();
145        let package_json = temp_dir.path().join("package.json");
146        fs::write(
147            &package_json,
148            r#"{
149  "name": "test-package",
150  "version": "1.0.0"
151}
152"#,
153        )
154        .unwrap();
155
156        let mut finder = NodeProjectFinder::new();
157        finder
158            .visit(&package_json, &PathBuf::from("package.json"))
159            .await
160            .unwrap();
161
162        let projects = finder.projects();
163        assert_eq!(projects.len(), 1);
164        match projects[0] {
165            Project::Package(pkg) => {
166                assert_eq!(pkg.name(), Some("test-package"));
167                assert_eq!(pkg.version(), Some("1.0.0"));
168            }
169            _ => panic!("Expected Package"),
170        }
171
172        temp_dir.close().unwrap();
173    }
174
175    #[tokio::test]
176    async fn test_node_project_finder_visit_workspace_with_workspaces() {
177        let temp_dir = TempDir::new().unwrap();
178        let package_json = temp_dir.path().join("package.json");
179        fs::write(
180            &package_json,
181            r#"{
182  "name": "test-workspace",
183  "version": "1.0.0",
184  "workspaces": ["packages/*"]
185}
186"#,
187        )
188        .unwrap();
189
190        let mut finder = NodeProjectFinder::new();
191        finder
192            .visit(&package_json, &PathBuf::from("package.json"))
193            .await
194            .unwrap();
195
196        let projects = finder.projects();
197        assert_eq!(projects.len(), 1);
198        match projects[0] {
199            Project::Workspace(ws) => {
200                assert_eq!(ws.name(), Some("test-workspace"));
201                assert_eq!(ws.version(), Some("1.0.0"));
202            }
203            _ => panic!("Expected Workspace"),
204        }
205
206        temp_dir.close().unwrap();
207    }
208
209    #[tokio::test]
210    async fn test_node_project_finder_visit_workspace_with_pnpm_workspace() {
211        let temp_dir = TempDir::new().unwrap();
212        let package_json = temp_dir.path().join("package.json");
213        fs::write(
214            &package_json,
215            r#"{
216  "name": "test-workspace",
217  "version": "1.0.0"
218}
219"#,
220        )
221        .unwrap();
222
223        // Create pnpm-workspace.yaml
224        let pnpm_workspace = temp_dir.path().join("pnpm-workspace.yaml");
225        fs::write(&pnpm_workspace, "packages:\n  - 'packages/*'\n").unwrap();
226
227        let mut finder = NodeProjectFinder::new();
228        finder
229            .visit(&package_json, &PathBuf::from("package.json"))
230            .await
231            .unwrap();
232
233        let projects = finder.projects();
234        assert_eq!(projects.len(), 1);
235        match projects[0] {
236            Project::Workspace(ws) => {
237                assert_eq!(ws.name(), Some("test-workspace"));
238                assert_eq!(ws.version(), Some("1.0.0"));
239            }
240            _ => panic!("Expected Workspace"),
241        }
242
243        temp_dir.close().unwrap();
244    }
245
246    #[tokio::test]
247    async fn test_node_project_finder_visit_workspace_without_version() {
248        let temp_dir = TempDir::new().unwrap();
249        let package_json = temp_dir.path().join("package.json");
250        fs::write(
251            &package_json,
252            r#"{
253  "name": "test-workspace",
254  "workspaces": ["packages/*"]
255}
256"#,
257        )
258        .unwrap();
259
260        let mut finder = NodeProjectFinder::new();
261        finder
262            .visit(&package_json, &PathBuf::from("package.json"))
263            .await
264            .unwrap();
265
266        let projects = finder.projects();
267        assert_eq!(projects.len(), 1);
268        match projects[0] {
269            Project::Workspace(ws) => {
270                assert_eq!(ws.name(), Some("test-workspace"));
271                assert_eq!(ws.version(), None);
272            }
273            _ => panic!("Expected Workspace"),
274        }
275
276        temp_dir.close().unwrap();
277    }
278
279    #[tokio::test]
280    async fn test_node_project_finder_visit_non_package_file() {
281        let temp_dir = TempDir::new().unwrap();
282        let other_file = temp_dir.path().join("other.txt");
283        fs::write(&other_file, "some content").unwrap();
284
285        let mut finder = NodeProjectFinder::new();
286        finder
287            .visit(&other_file, &PathBuf::from("other.txt"))
288            .await
289            .unwrap();
290
291        assert_eq!(finder.projects().len(), 0);
292
293        temp_dir.close().unwrap();
294    }
295
296    #[tokio::test]
297    async fn test_node_project_finder_visit_directory() {
298        let temp_dir = TempDir::new().unwrap();
299        let package_json = temp_dir.path().join("package.json");
300        fs::write(
301            &package_json,
302            r#"{
303  "name": "test-package",
304  "version": "1.0.0"
305}
306"#,
307        )
308        .unwrap();
309
310        let mut finder = NodeProjectFinder::new();
311        // Pass directory instead of file
312        finder
313            .visit(temp_dir.path(), &PathBuf::from("."))
314            .await
315            .unwrap();
316
317        assert_eq!(finder.projects().len(), 0);
318
319        temp_dir.close().unwrap();
320    }
321
322    #[tokio::test]
323    async fn test_node_project_finder_visit_duplicate() {
324        let temp_dir = TempDir::new().unwrap();
325        let package_json = temp_dir.path().join("package.json");
326        fs::write(
327            &package_json,
328            r#"{
329  "name": "test-package",
330  "version": "1.0.0"
331}
332"#,
333        )
334        .unwrap();
335
336        let mut finder = NodeProjectFinder::new();
337        finder
338            .visit(&package_json, &PathBuf::from("package.json"))
339            .await
340            .unwrap();
341
342        assert_eq!(finder.projects().len(), 1);
343
344        // Visit again - should not add duplicate
345        finder
346            .visit(&package_json, &PathBuf::from("package.json"))
347            .await
348            .unwrap();
349
350        assert_eq!(finder.projects().len(), 1);
351
352        temp_dir.close().unwrap();
353    }
354
355    #[tokio::test]
356    async fn test_node_project_finder_visit_multiple_packages() {
357        let temp_dir = TempDir::new().unwrap();
358        let package_json1 = temp_dir.path().join("package1").join("package.json");
359        fs::create_dir_all(package_json1.parent().unwrap()).unwrap();
360        fs::write(
361            &package_json1,
362            r#"{
363  "name": "package1",
364  "version": "1.0.0"
365}
366"#,
367        )
368        .unwrap();
369
370        let package_json2 = temp_dir.path().join("package2").join("package.json");
371        fs::create_dir_all(package_json2.parent().unwrap()).unwrap();
372        fs::write(
373            &package_json2,
374            r#"{
375  "name": "package2",
376  "version": "2.0.0"
377}
378"#,
379        )
380        .unwrap();
381
382        let mut finder = NodeProjectFinder::new();
383        finder
384            .visit(&package_json1, &PathBuf::from("package1/package.json"))
385            .await
386            .unwrap();
387        finder
388            .visit(&package_json2, &PathBuf::from("package2/package.json"))
389            .await
390            .unwrap();
391
392        let projects = finder.projects();
393        assert_eq!(projects.len(), 2);
394
395        temp_dir.close().unwrap();
396    }
397
398    #[tokio::test]
399    async fn test_node_project_finder_projects_mut() {
400        let temp_dir = TempDir::new().unwrap();
401        let package_json = temp_dir.path().join("package.json");
402        fs::write(
403            &package_json,
404            r#"{
405  "name": "test-package",
406  "version": "1.0.0"
407}
408"#,
409        )
410        .unwrap();
411
412        let mut finder = NodeProjectFinder::new();
413        finder
414            .visit(&package_json, &PathBuf::from("package.json"))
415            .await
416            .unwrap();
417
418        let mut_projects = finder.projects_mut();
419        assert_eq!(mut_projects.len(), 1);
420
421        temp_dir.close().unwrap();
422    }
423
424    #[tokio::test]
425    async fn test_node_project_finder_visit_package_with_workspace_dependencies() {
426        let temp_dir = TempDir::new().unwrap();
427        let package_json = temp_dir.path().join("package.json");
428        fs::write(
429            &package_json,
430            r#"{
431  "name": "test-package",
432  "version": "1.0.0",
433  "dependencies": {
434    "core": "workspace:*",
435    "utils": "workspace:^",
436    "external": "^1.0.0"
437  }
438}
439"#,
440        )
441        .unwrap();
442
443        let mut finder = NodeProjectFinder::new();
444        finder
445            .visit(&package_json, &PathBuf::from("package.json"))
446            .await
447            .unwrap();
448
449        let projects = finder.projects();
450        assert_eq!(projects.len(), 1);
451
452        let project = projects.first().unwrap();
453        let deps = project.dependencies();
454        // Only workspace:* dependencies should be tracked
455        assert_eq!(deps.len(), 1);
456        assert!(deps.contains("core"));
457        // workspace:^ and external deps should not be tracked
458        assert!(!deps.contains("utils"));
459        assert!(!deps.contains("external"));
460
461        temp_dir.close().unwrap();
462    }
463}