Skip to main content

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}