Skip to main content

dev_prune/adapters/
npm.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// NPM adapter implementation.
5
6use super::{BloatDir, EnforcePolicy, PackageManager, dir_size, enforce_two_tier, run_command};
7use anyhow::Result;
8use std::path::Path;
9
10/// NPM package manager adapter.
11pub struct Npm;
12
13impl PackageManager for Npm {
14    /// Returns the name of the package manager.
15    fn name(&self) -> &'static str {
16        "npm"
17    }
18
19    /// Detects if the project uses npm by checking for `package-lock.json`.
20    fn detect(&self, project_dir: &Path) -> bool {
21        project_dir.join("package-lock.json").exists()
22    }
23
24    /// Returns the bloat directories for npm (node_modules).
25    fn bloat_dirs(&self, project_dir: &Path) -> Vec<BloatDir> {
26        let node_modules = project_dir.join("node_modules");
27        if node_modules.exists() {
28            let size = dir_size(&node_modules);
29            vec![BloatDir {
30                name: "node_modules".to_string(),
31                path: node_modules,
32                size_bytes: size,
33            }]
34        } else {
35            vec![]
36        }
37    }
38
39    /// Enforces the lockfile without running install scripts, and without writing.
40    ///
41    /// `npm ci --dry-run` is the read-only check: it builds the tree the lockfile
42    /// describes, fails outright when `package-lock.json` and `package.json` disagree,
43    /// and — with `--dry-run` — neither installs nor writes. `--package-lock-only` is
44    /// the writing form, kept for the no-lockfile case where there is nothing to
45    /// preserve, and for the user who opted into rewriting.
46    fn enforce_lockfile(&self, project_dir: &Path, policy: EnforcePolicy) -> Result<()> {
47        let lockfile = project_dir.join("package-lock.json");
48        enforce_two_tier(
49            &lockfile,
50            "npm",
51            &["ci", "--dry-run", "--ignore-scripts"],
52            &["install", "--package-lock-only", "--ignore-scripts"],
53            project_dir,
54            policy,
55        )
56    }
57
58    /// Restores the dependencies using the lockfile.
59    fn restore(&self, project_dir: &Path) -> Result<()> {
60        run_command("npm", &["ci"], project_dir)
61    }
62
63    fn lockfiles(&self) -> &'static [&'static str] {
64        &["package-lock.json"]
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71    use std::fs;
72    use tempfile::tempdir;
73
74    #[test]
75    fn test_name() {
76        assert_eq!(Npm.name(), "npm");
77    }
78
79    /// npm used to run `npm install --package-lock-only` here, which *fixes* a lockfile
80    /// that has drifted from `package.json` by rewriting it — during a pass that may
81    /// have been started by the scheduler. Verification must now refuse instead.
82    ///
83    /// Skipped rather than failed when `npm` is absent from `PATH`.
84    #[test]
85    fn a_default_pass_never_rewrites_a_stale_lockfile() {
86        if !super::super::binary_available("npm") {
87            return;
88        }
89        let dir = tempdir().unwrap();
90        fs::write(
91            dir.path().join("package.json"),
92            r#"{"name":"stale","version":"1.0.0","dependencies":{"left-pad":"^1.3.0"}}"#,
93        )
94        .unwrap();
95
96        // A lockfile that never heard of `left-pad`, so it cannot rebuild the tree.
97        let stale = r#"{"name":"stale","version":"1.0.0","lockfileVersion":3,"requires":true,"packages":{"":{"name":"stale","version":"1.0.0"}}}"#;
98        fs::write(dir.path().join("package-lock.json"), stale).unwrap();
99
100        let result = Npm.enforce_lockfile(dir.path(), EnforcePolicy::default());
101
102        assert!(
103            result.is_err(),
104            "a lockfile out of sync with package.json must not pass verification"
105        );
106        assert_eq!(
107            fs::read_to_string(dir.path().join("package-lock.json")).unwrap(),
108            stale,
109            "the read-only verification rewrote package-lock.json"
110        );
111    }
112
113    #[test]
114    fn test_detect_positive() {
115        let dir = tempdir().unwrap();
116        fs::File::create(dir.path().join("package-lock.json")).unwrap();
117        assert!(Npm.detect(dir.path()));
118    }
119
120    #[test]
121    fn test_detect_negative() {
122        let dir = tempdir().unwrap();
123        assert!(!Npm.detect(dir.path()));
124    }
125
126    #[test]
127    fn test_bloat_dirs_present() {
128        let dir = tempdir().unwrap();
129        fs::create_dir(dir.path().join("node_modules")).unwrap();
130        let bloat = Npm.bloat_dirs(dir.path());
131        assert_eq!(bloat.len(), 1);
132        assert_eq!(bloat[0].path, dir.path().join("node_modules"));
133    }
134
135    #[test]
136    fn test_bloat_dirs_absent() {
137        let dir = tempdir().unwrap();
138        let bloat = Npm.bloat_dirs(dir.path());
139        assert!(bloat.is_empty());
140    }
141}