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