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()
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}