Skip to main content

dev_prune/adapters/
pnpm.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// PNPM adapter implementation.
5
6use super::{
7    BloatDir, EnforcePolicy, PackageManager, dir_size_with_hardlinks, enforce_two_tier,
8    run_command_with_timeout,
9};
10use anyhow::Result;
11use std::path::Path;
12
13/// PNPM package manager adapter.
14pub struct Pnpm;
15
16impl PackageManager for Pnpm {
17    /// Returns the name of the package manager.
18    fn name(&self) -> &'static str {
19        "pnpm"
20    }
21
22    /// Detects if the project uses pnpm by checking for `pnpm-lock.yaml`.
23    fn detect(&self, project_dir: &Path) -> bool {
24        project_dir.join("pnpm-lock.yaml").exists()
25    }
26
27    /// Returns the bloat directories for pnpm (node_modules).
28    ///
29    /// pnpm does not copy packages into `node_modules` — it hardlinks them out of its
30    /// content-addressable store whenever the store and the project sit on the same
31    /// volume (NTFS included; Windows hardlinks work fine). Deleting such a tree frees
32    /// only pnpm's own metadata and any genuinely copied files: the store keeps every
33    /// linked byte. Counting apparent size here would promise gigabytes and deliver
34    /// megabytes, so the split is measured per file via the link count.
35    fn bloat_dirs(&self, project_dir: &Path) -> Vec<BloatDir> {
36        let node_modules = project_dir.join("node_modules");
37        if node_modules.exists() {
38            let size = dir_size_with_hardlinks(&node_modules);
39            vec![BloatDir {
40                name: "node_modules".to_string(),
41                path: node_modules,
42                size_bytes: size.freed_bytes,
43                shared_bytes: size.shared_bytes,
44            }]
45        } else {
46            vec![]
47        }
48    }
49
50    /// Enforces the lockfile without installing anything, and without writing.
51    ///
52    /// `--lockfile-only` resolves `package.json` and touches no `node_modules`, but on
53    /// its own it *writes* the resolution back to `pnpm-lock.yaml`. `--frozen-lockfile`
54    /// turns that write into a failure, which is the answer we actually want: a lockfile
55    /// that no longer matches the manifest cannot rebuild the tree we are about to
56    /// delete, so the prune should be refused rather than the file quietly fixed.
57    fn enforce_lockfile(&self, project_dir: &Path, policy: EnforcePolicy) -> Result<()> {
58        let lockfile = project_dir.join("pnpm-lock.yaml");
59        enforce_two_tier(
60            &lockfile,
61            "pnpm",
62            &["install", "--lockfile-only", "--frozen-lockfile"],
63            &["install", "--lockfile-only"],
64            project_dir,
65            policy,
66        )
67    }
68
69    /// Restores the dependencies using the lockfile.
70    fn restore(&self, project_dir: &Path, timeout: std::time::Duration) -> Result<()> {
71        run_command_with_timeout(
72            "pnpm",
73            &["install", "--frozen-lockfile"],
74            project_dir,
75            timeout,
76        )
77    }
78
79    fn lockfiles(&self) -> &'static [&'static str] {
80        &["pnpm-lock.yaml"]
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use std::fs;
88    use tempfile::tempdir;
89
90    #[test]
91    fn test_name() {
92        assert_eq!(Pnpm.name(), "pnpm");
93    }
94
95    #[test]
96    fn test_detect_positive() {
97        let dir = tempdir().unwrap();
98        fs::File::create(dir.path().join("pnpm-lock.yaml")).unwrap();
99        assert!(Pnpm.detect(dir.path()));
100    }
101
102    #[test]
103    fn test_detect_negative() {
104        let dir = tempdir().unwrap();
105        assert!(!Pnpm.detect(dir.path()));
106    }
107
108    #[test]
109    fn test_bloat_dirs_present() {
110        let dir = tempdir().unwrap();
111        fs::create_dir(dir.path().join("node_modules")).unwrap();
112        let bloat = Pnpm.bloat_dirs(dir.path());
113        assert_eq!(bloat.len(), 1);
114        assert_eq!(bloat[0].path, dir.path().join("node_modules"));
115    }
116
117    #[test]
118    fn test_bloat_dirs_absent() {
119        let dir = tempdir().unwrap();
120        let bloat = Pnpm.bloat_dirs(dir.path());
121        assert!(bloat.is_empty());
122    }
123
124    #[test]
125    fn test_bloat_dirs_excludes_store_hardlinks() {
126        // A miniature pnpm layout: one file hardlinked from a "store" outside
127        // node_modules, one file pnpm wrote outright. Only the second is freed by
128        // deleting the tree.
129        let dir = tempdir().unwrap();
130        let store = dir.path().join("store");
131        let node_modules = dir.path().join("node_modules");
132        fs::create_dir(&store).unwrap();
133        fs::create_dir(&node_modules).unwrap();
134        fs::write(store.join("pkg.js"), "0123456789").unwrap();
135        fs::hard_link(store.join("pkg.js"), node_modules.join("pkg.js")).unwrap();
136        fs::write(node_modules.join(".modules.yaml"), "y").unwrap();
137        let bloat = Pnpm.bloat_dirs(dir.path());
138        assert_eq!(bloat.len(), 1);
139        assert_eq!(bloat[0].size_bytes, 1);
140        assert_eq!(bloat[0].shared_bytes, 10);
141    }
142}