Skip to main content

dev_prune/adapters/
bun.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Bun adapter implementation.
5
6use super::{
7    BloatDir, EnforcePolicy, PackageManager, dir_size_with_hardlinks,
8    lock_sync_or_verify_with_timeout, run_command_with_timeout,
9};
10use anyhow::Result;
11use std::path::Path;
12
13/// Bun package manager adapter.
14pub struct Bun;
15
16impl PackageManager for Bun {
17    /// Returns the name of the package manager.
18    fn name(&self) -> &'static str {
19        "bun"
20    }
21
22    /// Detects if the project uses bun by checking for `bun.lockb` or `bun.lock`.
23    fn detect(&self, project_dir: &Path) -> bool {
24        project_dir.join("bun.lockb").exists() || project_dir.join("bun.lock").exists()
25    }
26
27    /// Returns the bloat directories for bun (node_modules).
28    ///
29    /// bun hardlinks packages out of `~/.bun/install/cache` on Linux and Windows, so
30    /// deleting `node_modules` does not free those bytes — the cache keeps them. On
31    /// macOS bun uses clonefile instead, which leaves the link count at 1 and is
32    /// invisible to this measurement; APFS clones genuinely are freed block-by-block
33    /// as the cache copy diverges, so counting them as freed is the honest reading.
34    fn bloat_dirs(&self, project_dir: &Path) -> Vec<BloatDir> {
35        let node_modules = project_dir.join("node_modules");
36        if node_modules.exists() {
37            let size = dir_size_with_hardlinks(&node_modules);
38            vec![BloatDir {
39                name: "node_modules".to_string(),
40                path: node_modules,
41                size_bytes: size.freed_bytes,
42                shared_bytes: size.shared_bytes,
43            }]
44        } else {
45            vec![]
46        }
47    }
48
49    /// Verifies the lockfile resolves cleanly, without installing anything.
50    ///
51    /// `--dry-run` is essential, not cosmetic. A plain `bun install --frozen-lockfile`
52    /// is a real install: it downloads every dependency and runs their lifecycle
53    /// scripts — third-party code executed as a precondition for *deleting* the very
54    /// tree it just built. With `--dry-run`, bun still resolves `package.json` against
55    /// the lockfile and fails when they disagree, but writes nothing.
56    ///
57    /// bun is the one manager whose natural check was already read-only, so there is no
58    /// writing form to opt into and `allow_rewrite` changes nothing here.
59    fn enforce_lockfile(&self, project_dir: &Path, policy: EnforcePolicy) -> Result<()> {
60        let lockfile = if project_dir.join("bun.lockb").exists() {
61            project_dir.join("bun.lockb")
62        } else {
63            project_dir.join("bun.lock")
64        };
65        lock_sync_or_verify_with_timeout(
66            &lockfile,
67            "bun",
68            &[
69                "install",
70                "--frozen-lockfile",
71                "--dry-run",
72                "--ignore-scripts",
73            ],
74            project_dir,
75            policy.timeout,
76        )
77    }
78
79    /// Restores the dependencies using the lockfile.
80    fn restore(&self, project_dir: &Path, timeout: std::time::Duration) -> Result<()> {
81        run_command_with_timeout(
82            "bun",
83            &["install", "--frozen-lockfile"],
84            project_dir,
85            timeout,
86        )
87    }
88
89    fn lockfiles(&self) -> &'static [&'static str] {
90        &["bun.lockb", "bun.lock"]
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use std::fs;
98    use tempfile::tempdir;
99
100    #[test]
101    fn test_name() {
102        assert_eq!(Bun.name(), "bun");
103    }
104
105    #[test]
106    fn test_detect_positive_lockb() {
107        let dir = tempdir().unwrap();
108        fs::File::create(dir.path().join("bun.lockb")).unwrap();
109        assert!(Bun.detect(dir.path()));
110    }
111
112    #[test]
113    fn test_detect_positive_lock() {
114        let dir = tempdir().unwrap();
115        fs::File::create(dir.path().join("bun.lock")).unwrap();
116        assert!(Bun.detect(dir.path()));
117    }
118
119    #[test]
120    fn test_detect_negative() {
121        let dir = tempdir().unwrap();
122        assert!(!Bun.detect(dir.path()));
123    }
124
125    #[test]
126    fn test_bloat_dirs_present() {
127        let dir = tempdir().unwrap();
128        fs::create_dir(dir.path().join("node_modules")).unwrap();
129        let bloat = Bun.bloat_dirs(dir.path());
130        assert_eq!(bloat.len(), 1);
131        assert_eq!(bloat[0].path, dir.path().join("node_modules"));
132    }
133
134    #[test]
135    fn test_bloat_dirs_absent() {
136        let dir = tempdir().unwrap();
137        let bloat = Bun.bloat_dirs(dir.path());
138        assert!(bloat.is_empty());
139    }
140}