Skip to main content

affected_core/resolvers/
npm.rs

1use anyhow::{Context, Result};
2use serde::Deserialize;
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use crate::resolvers::{file_to_package, Resolver};
7use crate::types::{Ecosystem, Package, PackageId, ProjectGraph};
8
9pub struct NpmResolver;
10impl super::sealed::Sealed for NpmResolver {}
11
12#[derive(Deserialize)]
13struct RootPackageJson {
14    workspaces: Option<WorkspacesField>,
15}
16
17#[derive(Deserialize)]
18#[serde(untagged)]
19enum WorkspacesField {
20    Array(Vec<String>),
21    Object { packages: Vec<String> },
22}
23
24#[derive(Deserialize)]
25struct PackageJson {
26    name: Option<String>,
27    version: Option<String>,
28    dependencies: Option<HashMap<String, String>>,
29    #[serde(rename = "devDependencies")]
30    dev_dependencies: Option<HashMap<String, String>>,
31}
32
33impl Resolver for NpmResolver {
34    fn ecosystem(&self) -> Ecosystem {
35        Ecosystem::Npm
36    }
37
38    fn detect(&self, root: &Path) -> bool {
39        if root.join("pnpm-workspace.yaml").exists() {
40            return true;
41        }
42        let pkg = root.join("package.json");
43        if !pkg.exists() {
44            return false;
45        }
46        std::fs::read_to_string(&pkg)
47            .map(|c| c.contains("\"workspaces\""))
48            .unwrap_or(false)
49    }
50
51    fn resolve(&self, root: &Path) -> Result<ProjectGraph> {
52        let workspace_globs = self.find_workspace_globs(root)?;
53        let pkg_dirs = self.expand_globs(root, &workspace_globs)?;
54
55        // Parse all workspace packages
56        let mut packages = HashMap::new();
57        let mut name_to_id = HashMap::new();
58
59        for dir in &pkg_dirs {
60            let pkg_json_path = dir.join("package.json");
61            if !pkg_json_path.exists() {
62                continue;
63            }
64
65            let content = std::fs::read_to_string(&pkg_json_path)
66                .with_context(|| format!("Failed to read {}", pkg_json_path.display()))?;
67            let pkg: PackageJson = serde_json::from_str(&content)
68                .with_context(|| format!("Failed to parse {}", pkg_json_path.display()))?;
69
70            let name = match &pkg.name {
71                Some(n) => n.clone(),
72                None => continue,
73            };
74
75            let pkg_id = PackageId(name.clone());
76            name_to_id.insert(name.clone(), pkg_id.clone());
77            packages.insert(
78                pkg_id.clone(),
79                Package {
80                    id: pkg_id,
81                    name: name.clone(),
82                    version: pkg.version.clone(),
83                    path: dir.clone(),
84                    manifest_path: pkg_json_path,
85                },
86            );
87        }
88
89        // Build dependency edges
90        let mut edges = Vec::new();
91        let workspace_names: std::collections::HashSet<&str> =
92            name_to_id.keys().map(|s| s.as_str()).collect();
93
94        for dir in &pkg_dirs {
95            let pkg_json_path = dir.join("package.json");
96            if !pkg_json_path.exists() {
97                continue;
98            }
99
100            let content = std::fs::read_to_string(&pkg_json_path)?;
101            let pkg: PackageJson = serde_json::from_str(&content)?;
102
103            let from_name = match &pkg.name {
104                Some(n) => n.clone(),
105                None => continue,
106            };
107
108            // Check both dependencies and devDependencies
109            let all_deps: Vec<&str> = pkg
110                .dependencies
111                .iter()
112                .flat_map(|d| d.keys())
113                .chain(pkg.dev_dependencies.iter().flat_map(|d| d.keys()))
114                .map(|s| s.as_str())
115                .collect();
116
117            for dep_name in all_deps {
118                if workspace_names.contains(dep_name) {
119                    edges.push((
120                        PackageId(from_name.clone()),
121                        PackageId(dep_name.to_string()),
122                    ));
123                }
124            }
125        }
126
127        Ok(ProjectGraph {
128            packages,
129            edges,
130            root: root.to_path_buf(),
131        })
132    }
133
134    fn package_for_file(&self, graph: &ProjectGraph, file: &Path) -> Option<PackageId> {
135        file_to_package(graph, file)
136    }
137
138    fn test_command(&self, package_id: &PackageId) -> Vec<String> {
139        // Default to npm; users can override via .affected.toml
140        vec![
141            "npm".into(),
142            "test".into(),
143            "--workspace".into(),
144            package_id.0.clone(),
145        ]
146    }
147}
148
149impl NpmResolver {
150    fn find_workspace_globs(&self, root: &Path) -> Result<Vec<String>> {
151        // Try pnpm-workspace.yaml first
152        let pnpm_path = root.join("pnpm-workspace.yaml");
153        if pnpm_path.exists() {
154            let content = std::fs::read_to_string(&pnpm_path)?;
155            // Simple YAML parsing for the packages field
156            // pnpm-workspace.yaml is typically:
157            //   packages:
158            //     - 'packages/*'
159            //     - 'apps/*'
160            let mut globs = Vec::new();
161            let mut in_packages = false;
162            for line in content.lines() {
163                let trimmed = line.trim();
164                if trimmed == "packages:" {
165                    in_packages = true;
166                    continue;
167                }
168                if in_packages {
169                    if trimmed.starts_with("- ") {
170                        let glob = trimmed
171                            .trim_start_matches("- ")
172                            .trim_matches('\'')
173                            .trim_matches('"')
174                            .to_string();
175                        globs.push(glob);
176                    } else if !trimmed.is_empty() {
177                        break;
178                    }
179                }
180            }
181            if !globs.is_empty() {
182                return Ok(globs);
183            }
184        }
185
186        // Fall back to package.json workspaces
187        let pkg_path = root.join("package.json");
188        let content = std::fs::read_to_string(&pkg_path).context("No package.json found")?;
189        let root_pkg: RootPackageJson =
190            serde_json::from_str(&content).context("Failed to parse root package.json")?;
191
192        match root_pkg.workspaces {
193            Some(WorkspacesField::Array(globs)) => Ok(globs),
194            Some(WorkspacesField::Object { packages }) => Ok(packages),
195            None => anyhow::bail!("No workspaces field found in package.json"),
196        }
197    }
198
199    pub fn expand_globs(&self, root: &Path, globs: &[String]) -> Result<Vec<PathBuf>> {
200        let mut dirs = Vec::new();
201
202        for pattern in globs {
203            let full_pattern = root.join(pattern).join("package.json");
204            let pattern_str = full_pattern.to_str().unwrap_or("");
205
206            match glob::glob(pattern_str) {
207                Ok(paths) => {
208                    for entry in paths.filter_map(|p| p.ok()) {
209                        if let Some(parent) = entry.parent() {
210                            dirs.push(parent.to_path_buf());
211                        }
212                    }
213                }
214                Err(_) => continue,
215            }
216        }
217
218        Ok(dirs)
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    fn create_npm_workspace(dir: &std::path::Path) {
227        // Root package.json with workspaces
228        std::fs::write(
229            dir.join("package.json"),
230            r#"{"name": "root", "workspaces": ["packages/*"]}"#,
231        )
232        .unwrap();
233
234        // Package A
235        std::fs::create_dir_all(dir.join("packages/pkg-a")).unwrap();
236        std::fs::write(
237            dir.join("packages/pkg-a/package.json"),
238            r#"{"name": "@scope/pkg-a", "version": "1.0.0", "dependencies": {"@scope/pkg-b": "workspace:*"}}"#,
239        )
240        .unwrap();
241
242        // Package B (no deps)
243        std::fs::create_dir_all(dir.join("packages/pkg-b")).unwrap();
244        std::fs::write(
245            dir.join("packages/pkg-b/package.json"),
246            r#"{"name": "@scope/pkg-b", "version": "1.0.0"}"#,
247        )
248        .unwrap();
249    }
250
251    #[test]
252    fn test_detect_npm_workspace() {
253        let dir = tempfile::tempdir().unwrap();
254        create_npm_workspace(dir.path());
255        assert!(NpmResolver.detect(dir.path()));
256    }
257
258    #[test]
259    fn test_detect_pnpm_workspace() {
260        let dir = tempfile::tempdir().unwrap();
261        std::fs::write(
262            dir.path().join("pnpm-workspace.yaml"),
263            "packages:\n  - 'packages/*'\n",
264        )
265        .unwrap();
266        assert!(NpmResolver.detect(dir.path()));
267    }
268
269    #[test]
270    fn test_detect_no_workspaces() {
271        let dir = tempfile::tempdir().unwrap();
272        std::fs::write(dir.path().join("package.json"), r#"{"name": "solo"}"#).unwrap();
273        assert!(!NpmResolver.detect(dir.path()));
274    }
275
276    #[test]
277    fn test_detect_empty_dir() {
278        let dir = tempfile::tempdir().unwrap();
279        assert!(!NpmResolver.detect(dir.path()));
280    }
281
282    #[test]
283    fn test_resolve_npm_workspace() {
284        let dir = tempfile::tempdir().unwrap();
285        create_npm_workspace(dir.path());
286
287        let graph = NpmResolver.resolve(dir.path()).unwrap();
288        assert_eq!(graph.packages.len(), 2);
289        assert!(graph
290            .packages
291            .contains_key(&PackageId("@scope/pkg-a".into())));
292        assert!(graph
293            .packages
294            .contains_key(&PackageId("@scope/pkg-b".into())));
295
296        // pkg-a depends on pkg-b
297        assert!(graph.edges.contains(&(
298            PackageId("@scope/pkg-a".into()),
299            PackageId("@scope/pkg-b".into()),
300        )));
301    }
302
303    #[test]
304    fn test_resolve_pnpm_workspace() {
305        let dir = tempfile::tempdir().unwrap();
306
307        std::fs::write(
308            dir.path().join("pnpm-workspace.yaml"),
309            "packages:\n  - 'packages/*'\n",
310        )
311        .unwrap();
312
313        std::fs::create_dir_all(dir.path().join("packages/foo")).unwrap();
314        std::fs::write(
315            dir.path().join("packages/foo/package.json"),
316            r#"{"name": "foo", "version": "1.0.0"}"#,
317        )
318        .unwrap();
319
320        std::fs::create_dir_all(dir.path().join("packages/bar")).unwrap();
321        std::fs::write(
322            dir.path().join("packages/bar/package.json"),
323            r#"{"name": "bar", "version": "1.0.0", "dependencies": {"foo": "workspace:*"}}"#,
324        )
325        .unwrap();
326
327        let graph = NpmResolver.resolve(dir.path()).unwrap();
328        assert_eq!(graph.packages.len(), 2);
329        assert!(graph
330            .edges
331            .contains(&(PackageId("bar".into()), PackageId("foo".into()),)));
332    }
333
334    #[test]
335    fn test_expand_globs() {
336        let dir = tempfile::tempdir().unwrap();
337        std::fs::create_dir_all(dir.path().join("packages/a")).unwrap();
338        std::fs::write(dir.path().join("packages/a/package.json"), "{}").unwrap();
339        std::fs::create_dir_all(dir.path().join("packages/b")).unwrap();
340        std::fs::write(dir.path().join("packages/b/package.json"), "{}").unwrap();
341
342        let globs = vec!["packages/*".to_string()];
343        let dirs = NpmResolver.expand_globs(dir.path(), &globs).unwrap();
344        assert_eq!(dirs.len(), 2);
345    }
346
347    #[test]
348    fn test_test_command() {
349        let cmd = NpmResolver.test_command(&PackageId("my-pkg".into()));
350        assert_eq!(cmd, vec!["npm", "test", "--workspace", "my-pkg"]);
351    }
352
353    #[test]
354    fn test_dev_dependencies_create_edges() {
355        let dir = tempfile::tempdir().unwrap();
356        std::fs::write(
357            dir.path().join("package.json"),
358            r#"{"name": "root", "workspaces": ["packages/*"]}"#,
359        )
360        .unwrap();
361
362        std::fs::create_dir_all(dir.path().join("packages/lib")).unwrap();
363        std::fs::write(
364            dir.path().join("packages/lib/package.json"),
365            r#"{"name": "lib", "version": "1.0.0"}"#,
366        )
367        .unwrap();
368
369        std::fs::create_dir_all(dir.path().join("packages/app")).unwrap();
370        std::fs::write(
371            dir.path().join("packages/app/package.json"),
372            r#"{"name": "app", "version": "1.0.0", "devDependencies": {"lib": "workspace:*"}}"#,
373        )
374        .unwrap();
375
376        let graph = NpmResolver.resolve(dir.path()).unwrap();
377        assert!(graph
378            .edges
379            .contains(&(PackageId("app".into()), PackageId("lib".into()),)));
380    }
381}