socket_patch_core/crawlers/pkg_managers.rs
1//! Detect which Node.js package manager produced the layout in a
2//! project root (`npm`, `pnpm`, `yarn` classic, or yarn-berry PnP).
3//!
4//! The apply pipeline cares about this for two reasons:
5//!
6//! 1. **pnpm**: `node_modules/<pkg>` is typically a symlink into the
7//! content-addressed global store. Patching the link target would
8//! corrupt every other project on the machine that points at the
9//! same store entry. The CoW guard in
10//! [`crate::patch::cow::break_hardlink_if_needed`] is what
11//! actually fixes this; this detector just lets the CLI surface a
12//! one-line "we detected pnpm, applied with CoW" notice so users
13//! understand the layout was handled.
14//!
15//! 2. **yarn-berry / Plug'n'Play**: packages do not live on disk at
16//! all — they're inside `.yarn/cache/<pkg>.zip` and resolved via
17//! a custom Node loader (`.pnp.cjs`). The npm crawler can't reach
18//! them, and rewriting bytes inside a zip is a totally different
19//! operation than rewriting bytes in `node_modules/`. The right
20//! move is to refuse with a clear error and point the user at
21//! `yarn patch <pkg>`.
22//!
23//! Classic yarn (`yarn.lock` + a real `node_modules/`) behaves like
24//! npm at the filesystem level, so no special handling is needed.
25
26use std::path::Path;
27
28/// Identified Node.js package manager / layout flavor.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum NpmPkgManager {
31 /// `node_modules/` present, no other markers. Default assumption.
32 Npm,
33 /// pnpm content-store layout (`node_modules/.modules.yaml` or
34 /// `node_modules/.pnpm/`). Patching is safe via CoW; the operator
35 /// gets a heads-up event.
36 Pnpm,
37 /// yarn classic — `yarn.lock` present, real `node_modules/`, no
38 /// PnP loader. Behaves like npm at the FS level.
39 YarnClassic,
40 /// yarn-berry with Plug'n'Play (`.pnp.cjs` present). Packages
41 /// live inside `.yarn/cache/*.zip`. Apply must refuse.
42 YarnBerryPnP,
43 /// bun-managed project — `bun.lock` (text, current default) or
44 /// `bun.lockb` (binary, legacy) at the project root. Bun
45 /// hard-links from `~/.bun/install/cache/` into `node_modules/`
46 /// by default on Linux/macOS, so apply must CoW the link before
47 /// rewriting (handled generically by `break_hardlink_if_needed`).
48 /// The operator gets a heads-up event so it's clear which package
49 /// manager the patch landed against.
50 Bun,
51 /// No discernible package manager — empty or non-Node project.
52 Unknown,
53}
54
55/// Detect the package manager that produced the layout under
56/// `project_root`. Inspection is purely path-based — no shell-outs,
57/// no parsing — so the detector is fast and side-effect-free.
58///
59/// Precedence (first match wins):
60///
61/// 1. `.pnp.cjs` or `.pnp.loader.mjs` → yarn-berry PnP.
62/// 2. `bun.lock` or `bun.lockb` (+ `node_modules/`) → bun.
63/// 3. `node_modules/.modules.yaml` or `node_modules/.pnpm/` → pnpm.
64/// 4. `yarn.lock` (without PnP markers) + `node_modules/` → yarn classic.
65/// 5. `node_modules/` exists → npm.
66/// 6. Otherwise → unknown.
67///
68/// Bun comes before pnpm in the precedence because bun's isolated
69/// linker (v1.3.2+ default) populates `node_modules/.bun/` which
70/// superficially resembles pnpm's `.pnpm/` content store. The
71/// lockfile filename disambiguates cleanly.
72pub fn detect_npm_pkg_manager(project_root: &Path) -> NpmPkgManager {
73 // 1. yarn-berry PnP — highest priority because it determines
74 // whether the npm crawler can find anything at all.
75 if project_root.join(".pnp.cjs").is_file() || project_root.join(".pnp.loader.mjs").is_file() {
76 return NpmPkgManager::YarnBerryPnP;
77 }
78
79 // 2. bun — `bun.lock` (text, current default in v1.2+) or
80 // `bun.lockb` (binary, legacy). Like the yarn-classic check
81 // below, we require `node_modules/` to actually exist —
82 // a bare lockfile without an install is a fresh checkout.
83 let node_modules = project_root.join("node_modules");
84 if (project_root.join("bun.lock").is_file() || project_root.join("bun.lockb").is_file())
85 && node_modules.is_dir()
86 {
87 return NpmPkgManager::Bun;
88 }
89
90 // 3. pnpm — markers live inside node_modules/.
91 if node_modules.join(".modules.yaml").is_file() || node_modules.join(".pnpm").is_dir() {
92 return NpmPkgManager::Pnpm;
93 }
94
95 // 4. yarn classic — yarn.lock + node_modules. We only return
96 // YarnClassic if node_modules actually exists, because a bare
97 // yarn.lock without node_modules is a fresh checkout where
98 // nothing has been installed yet.
99 if project_root.join("yarn.lock").is_file() && node_modules.is_dir() {
100 return NpmPkgManager::YarnClassic;
101 }
102
103 // 5. npm — any node_modules/ at all.
104 if node_modules.is_dir() {
105 return NpmPkgManager::Npm;
106 }
107
108 NpmPkgManager::Unknown
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 #[test]
116 fn unknown_for_empty_dir() {
117 let d = tempfile::tempdir().unwrap();
118 assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Unknown);
119 }
120
121 #[test]
122 fn npm_for_bare_node_modules() {
123 let d = tempfile::tempdir().unwrap();
124 std::fs::create_dir_all(d.path().join("node_modules")).unwrap();
125 assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Npm);
126 }
127
128 #[test]
129 fn pnpm_via_modules_yaml() {
130 let d = tempfile::tempdir().unwrap();
131 std::fs::create_dir_all(d.path().join("node_modules")).unwrap();
132 std::fs::write(d.path().join("node_modules/.modules.yaml"), "").unwrap();
133 assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Pnpm);
134 }
135
136 #[test]
137 fn pnpm_via_pnpm_dir() {
138 let d = tempfile::tempdir().unwrap();
139 std::fs::create_dir_all(d.path().join("node_modules/.pnpm")).unwrap();
140 assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Pnpm);
141 }
142
143 #[test]
144 fn yarn_classic_via_lockfile() {
145 let d = tempfile::tempdir().unwrap();
146 std::fs::create_dir_all(d.path().join("node_modules")).unwrap();
147 std::fs::write(d.path().join("yarn.lock"), "").unwrap();
148 assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::YarnClassic);
149 }
150
151 /// yarn.lock without an installed node_modules is "fresh
152 /// checkout, nothing installed yet" — don't claim yarn classic.
153 #[test]
154 fn yarn_classic_requires_installed_node_modules() {
155 let d = tempfile::tempdir().unwrap();
156 std::fs::write(d.path().join("yarn.lock"), "").unwrap();
157 assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Unknown);
158 }
159
160 #[test]
161 fn yarn_berry_pnp_via_pnp_cjs() {
162 let d = tempfile::tempdir().unwrap();
163 std::fs::write(d.path().join(".pnp.cjs"), "").unwrap();
164 assert_eq!(
165 detect_npm_pkg_manager(d.path()),
166 NpmPkgManager::YarnBerryPnP
167 );
168 }
169
170 /// yarn-berry takes priority over pnpm even if both sets of
171 /// markers exist (defensive — shouldn't happen in real projects).
172 #[test]
173 fn yarn_berry_pnp_priority_over_pnpm() {
174 let d = tempfile::tempdir().unwrap();
175 std::fs::write(d.path().join(".pnp.cjs"), "").unwrap();
176 std::fs::create_dir_all(d.path().join("node_modules/.pnpm")).unwrap();
177 assert_eq!(
178 detect_npm_pkg_manager(d.path()),
179 NpmPkgManager::YarnBerryPnP
180 );
181 }
182
183 #[test]
184 fn bun_via_text_lockfile() {
185 let d = tempfile::tempdir().unwrap();
186 std::fs::create_dir_all(d.path().join("node_modules")).unwrap();
187 std::fs::write(d.path().join("bun.lock"), "").unwrap();
188 assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Bun);
189 }
190
191 #[test]
192 fn bun_via_binary_lockfile() {
193 let d = tempfile::tempdir().unwrap();
194 std::fs::create_dir_all(d.path().join("node_modules")).unwrap();
195 std::fs::write(d.path().join("bun.lockb"), b"").unwrap();
196 assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Bun);
197 }
198
199 /// `bun.lock` without an installed `node_modules/` is a fresh
200 /// checkout — same pattern as `yarn.lock` alone.
201 #[test]
202 fn bun_requires_installed_node_modules() {
203 let d = tempfile::tempdir().unwrap();
204 std::fs::write(d.path().join("bun.lock"), "").unwrap();
205 assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Unknown);
206 }
207
208 /// Bun's isolated linker (v1.3.2+ default) creates
209 /// `node_modules/.bun/` which superficially resembles pnpm's
210 /// `.pnpm/`. The lockfile filename disambiguates — `bun.lock`
211 /// wins over the `.pnpm/` heuristic.
212 #[test]
213 fn bun_priority_over_pnpm_when_both_markers_present() {
214 let d = tempfile::tempdir().unwrap();
215 std::fs::create_dir_all(d.path().join("node_modules/.pnpm")).unwrap();
216 std::fs::write(d.path().join("bun.lock"), "").unwrap();
217 assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Bun);
218 }
219
220 /// yarn-berry beats bun (PnP is a structural override of
221 /// everything — packages aren't on disk).
222 #[test]
223 fn yarn_berry_pnp_priority_over_bun() {
224 let d = tempfile::tempdir().unwrap();
225 std::fs::write(d.path().join(".pnp.cjs"), "").unwrap();
226 std::fs::write(d.path().join("bun.lock"), "").unwrap();
227 std::fs::create_dir_all(d.path().join("node_modules")).unwrap();
228 assert_eq!(
229 detect_npm_pkg_manager(d.path()),
230 NpmPkgManager::YarnBerryPnP
231 );
232 }
233
234 /// The ESM PnP loader variant (`.pnp.loader.mjs`) is sufficient on
235 /// its own — newer yarn-berry installs ship it instead of (or
236 /// alongside) `.pnp.cjs`. The end-to-end refusal test pins this at
237 /// the CLI layer; pin it here at the detector layer too so a unit
238 /// regression is caught without standing up the whole apply path.
239 #[test]
240 fn yarn_berry_pnp_via_loader_mjs() {
241 let d = tempfile::tempdir().unwrap();
242 std::fs::write(d.path().join(".pnp.loader.mjs"), "").unwrap();
243 assert_eq!(
244 detect_npm_pkg_manager(d.path()),
245 NpmPkgManager::YarnBerryPnP
246 );
247 }
248
249 /// PnP wins even when a real `node_modules/` is also present (a
250 /// yarn-berry checkout can carry both an installed tree and the
251 /// loader). The refusal is the safety-critical branch — it must not
252 /// be masked by the npm fallthrough.
253 #[test]
254 fn yarn_berry_pnp_priority_over_node_modules() {
255 let d = tempfile::tempdir().unwrap();
256 std::fs::write(d.path().join(".pnp.cjs"), "").unwrap();
257 std::fs::create_dir_all(d.path().join("node_modules")).unwrap();
258 assert_eq!(
259 detect_npm_pkg_manager(d.path()),
260 NpmPkgManager::YarnBerryPnP
261 );
262 }
263
264 /// pnpm is checked before yarn-classic: a project with both a
265 /// `yarn.lock` and pnpm's `.pnpm/` store (e.g. a repo migrating
266 /// package managers without a clean reinstall) classifies as pnpm,
267 /// matching the documented precedence table.
268 #[test]
269 fn pnpm_priority_over_yarn_classic() {
270 let d = tempfile::tempdir().unwrap();
271 std::fs::create_dir_all(d.path().join("node_modules/.pnpm")).unwrap();
272 std::fs::write(d.path().join("yarn.lock"), "").unwrap();
273 assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Pnpm);
274 }
275
276 /// bun is checked before yarn-classic too: a `bun.lock` plus a
277 /// stray `yarn.lock` (multi-PM repo) classifies as bun.
278 #[test]
279 fn bun_priority_over_yarn_classic() {
280 let d = tempfile::tempdir().unwrap();
281 std::fs::create_dir_all(d.path().join("node_modules")).unwrap();
282 std::fs::write(d.path().join("bun.lock"), "").unwrap();
283 std::fs::write(d.path().join("yarn.lock"), "").unwrap();
284 assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Bun);
285 }
286
287 /// Robustness: a malformed layout where `node_modules` is a regular
288 /// *file* rather than a directory must not be misclassified. Every
289 /// non-PnP branch gates on `node_modules.is_dir()` (directly or via
290 /// a child `join`), so a bun lockfile next to a `node_modules` file
291 /// falls through to Unknown rather than claiming bun.
292 #[test]
293 fn node_modules_as_file_is_not_misclassified() {
294 let d = tempfile::tempdir().unwrap();
295 std::fs::write(d.path().join("node_modules"), "not a dir").unwrap();
296 std::fs::write(d.path().join("bun.lock"), "").unwrap();
297 assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Unknown);
298 }
299}