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