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()
76 || project_root.join(".pnp.loader.mjs").is_file()
77 {
78 return NpmPkgManager::YarnBerryPnP;
79 }
80
81 // 2. bun — `bun.lock` (text, current default in v1.2+) or
82 // `bun.lockb` (binary, legacy). Like the yarn-classic check
83 // below, we require `node_modules/` to actually exist —
84 // a bare lockfile without an install is a fresh checkout.
85 let node_modules = project_root.join("node_modules");
86 if (project_root.join("bun.lock").is_file()
87 || project_root.join("bun.lockb").is_file())
88 && node_modules.is_dir()
89 {
90 return NpmPkgManager::Bun;
91 }
92
93 // 3. pnpm — markers live inside node_modules/.
94 if node_modules.join(".modules.yaml").is_file()
95 || node_modules.join(".pnpm").is_dir()
96 {
97 return NpmPkgManager::Pnpm;
98 }
99
100 // 4. yarn classic — yarn.lock + node_modules. We only return
101 // YarnClassic if node_modules actually exists, because a bare
102 // yarn.lock without node_modules is a fresh checkout where
103 // nothing has been installed yet.
104 if project_root.join("yarn.lock").is_file() && node_modules.is_dir() {
105 return NpmPkgManager::YarnClassic;
106 }
107
108 // 5. npm — any node_modules/ at all.
109 if node_modules.is_dir() {
110 return NpmPkgManager::Npm;
111 }
112
113 NpmPkgManager::Unknown
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119
120 #[test]
121 fn unknown_for_empty_dir() {
122 let d = tempfile::tempdir().unwrap();
123 assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Unknown);
124 }
125
126 #[test]
127 fn npm_for_bare_node_modules() {
128 let d = tempfile::tempdir().unwrap();
129 std::fs::create_dir_all(d.path().join("node_modules")).unwrap();
130 assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Npm);
131 }
132
133 #[test]
134 fn pnpm_via_modules_yaml() {
135 let d = tempfile::tempdir().unwrap();
136 std::fs::create_dir_all(d.path().join("node_modules")).unwrap();
137 std::fs::write(d.path().join("node_modules/.modules.yaml"), "").unwrap();
138 assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Pnpm);
139 }
140
141 #[test]
142 fn pnpm_via_pnpm_dir() {
143 let d = tempfile::tempdir().unwrap();
144 std::fs::create_dir_all(d.path().join("node_modules/.pnpm")).unwrap();
145 assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Pnpm);
146 }
147
148 #[test]
149 fn yarn_classic_via_lockfile() {
150 let d = tempfile::tempdir().unwrap();
151 std::fs::create_dir_all(d.path().join("node_modules")).unwrap();
152 std::fs::write(d.path().join("yarn.lock"), "").unwrap();
153 assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::YarnClassic);
154 }
155
156 /// yarn.lock without an installed node_modules is "fresh
157 /// checkout, nothing installed yet" — don't claim yarn classic.
158 #[test]
159 fn yarn_classic_requires_installed_node_modules() {
160 let d = tempfile::tempdir().unwrap();
161 std::fs::write(d.path().join("yarn.lock"), "").unwrap();
162 assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Unknown);
163 }
164
165 #[test]
166 fn yarn_berry_pnp_via_pnp_cjs() {
167 let d = tempfile::tempdir().unwrap();
168 std::fs::write(d.path().join(".pnp.cjs"), "").unwrap();
169 assert_eq!(
170 detect_npm_pkg_manager(d.path()),
171 NpmPkgManager::YarnBerryPnP
172 );
173 }
174
175 /// yarn-berry takes priority over pnpm even if both sets of
176 /// markers exist (defensive — shouldn't happen in real projects).
177 #[test]
178 fn yarn_berry_pnp_priority_over_pnpm() {
179 let d = tempfile::tempdir().unwrap();
180 std::fs::write(d.path().join(".pnp.cjs"), "").unwrap();
181 std::fs::create_dir_all(d.path().join("node_modules/.pnpm")).unwrap();
182 assert_eq!(
183 detect_npm_pkg_manager(d.path()),
184 NpmPkgManager::YarnBerryPnP
185 );
186 }
187
188 #[test]
189 fn bun_via_text_lockfile() {
190 let d = tempfile::tempdir().unwrap();
191 std::fs::create_dir_all(d.path().join("node_modules")).unwrap();
192 std::fs::write(d.path().join("bun.lock"), "").unwrap();
193 assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Bun);
194 }
195
196 #[test]
197 fn bun_via_binary_lockfile() {
198 let d = tempfile::tempdir().unwrap();
199 std::fs::create_dir_all(d.path().join("node_modules")).unwrap();
200 std::fs::write(d.path().join("bun.lockb"), b"").unwrap();
201 assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Bun);
202 }
203
204 /// `bun.lock` without an installed `node_modules/` is a fresh
205 /// checkout — same pattern as `yarn.lock` alone.
206 #[test]
207 fn bun_requires_installed_node_modules() {
208 let d = tempfile::tempdir().unwrap();
209 std::fs::write(d.path().join("bun.lock"), "").unwrap();
210 assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Unknown);
211 }
212
213 /// Bun's isolated linker (v1.3.2+ default) creates
214 /// `node_modules/.bun/` which superficially resembles pnpm's
215 /// `.pnpm/`. The lockfile filename disambiguates — `bun.lock`
216 /// wins over the `.pnpm/` heuristic.
217 #[test]
218 fn bun_priority_over_pnpm_when_both_markers_present() {
219 let d = tempfile::tempdir().unwrap();
220 std::fs::create_dir_all(d.path().join("node_modules/.pnpm")).unwrap();
221 std::fs::write(d.path().join("bun.lock"), "").unwrap();
222 assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Bun);
223 }
224
225 /// yarn-berry beats bun (PnP is a structural override of
226 /// everything — packages aren't on disk).
227 #[test]
228 fn yarn_berry_pnp_priority_over_bun() {
229 let d = tempfile::tempdir().unwrap();
230 std::fs::write(d.path().join(".pnp.cjs"), "").unwrap();
231 std::fs::write(d.path().join("bun.lock"), "").unwrap();
232 std::fs::create_dir_all(d.path().join("node_modules")).unwrap();
233 assert_eq!(
234 detect_npm_pkg_manager(d.path()),
235 NpmPkgManager::YarnBerryPnP
236 );
237 }
238}