Skip to main content

dev_prune/adapters/
yarn.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Yarn adapter implementation.
5
6use super::{
7    BloatDir, EnforcePolicy, PackageManager, dir_size, enforce_two_tier, run_command_with_timeout,
8};
9use anyhow::Result;
10use std::path::Path;
11
12/// Yarn package manager adapter.
13pub struct Yarn;
14
15/// Whether the project is on Yarn Berry (2+) rather than Classic (1.x).
16///
17/// Berry projects carry a `.yarnrc.yml` or a `.yarn/` directory, and their lockfiles
18/// open with a `__metadata:` block that Classic's `# yarn lockfile v1` format never
19/// contains. Checked from the project's files rather than `yarn --version`, because the
20/// globally installed yarn is routinely Classic while the project pins Berry through
21/// Corepack.
22fn is_berry_project(project_dir: &Path) -> bool {
23    if project_dir.join(".yarnrc.yml").exists() || project_dir.join(".yarn").is_dir() {
24        return true;
25    }
26    std::fs::read_to_string(project_dir.join("yarn.lock"))
27        .map(|c| c.lines().take(30).any(|l| l.starts_with("__metadata:")))
28        .unwrap_or(false)
29}
30
31impl PackageManager for Yarn {
32    /// Returns the name of the package manager.
33    fn name(&self) -> &'static str {
34        "yarn"
35    }
36
37    /// Detects if the project uses yarn by checking for `yarn.lock`.
38    fn detect(&self, project_dir: &Path) -> bool {
39        project_dir.join("yarn.lock").exists()
40    }
41
42    /// Returns the bloat directories for yarn (node_modules).
43    fn bloat_dirs(&self, project_dir: &Path) -> Vec<BloatDir> {
44        let node_modules = project_dir.join("node_modules");
45        if node_modules.exists() {
46            let size = dir_size(&node_modules);
47            vec![BloatDir {
48                name: "node_modules".to_string(),
49                path: node_modules,
50                size_bytes: size,
51                shared_bytes: 0,
52            }]
53        } else {
54            vec![]
55        }
56    }
57
58    /// Enforces the lockfile without installing anything.
59    ///
60    /// `--mode update-lockfile` resolves `package.json` and writes only `yarn.lock`.
61    /// It is a Yarn Berry (2+) flag; Yarn Classic rejects it. Classic offers no
62    /// resolve-only mode at all — its nearest equivalent, `yarn install
63    /// --frozen-lockfile`, performs a full install and runs every dependency's
64    /// lifecycle scripts, which is not something to do as a precondition for deleting
65    /// that same tree. So on Classic an existing `yarn.lock` is itself the proof that
66    /// `node_modules` is rebuildable, and that is what we require.
67    ///
68    /// On Berry, `--immutable` is what keeps the resolution read-only: it fails when
69    /// `yarn.lock` would change rather than writing the change out — and that failure
70    /// must reach the caller. An earlier version of this method decided Classic-vs-Berry
71    /// by whether the Berry invocation errored, which swallowed every genuine Berry
72    /// verification failure and made this the one adapter that could never say no.
73    fn enforce_lockfile(&self, project_dir: &Path, policy: EnforcePolicy) -> Result<()> {
74        // detect() required yarn.lock to exist, so on Classic the lockfile-as-proof
75        // tier is already satisfied.
76        if !is_berry_project(project_dir) {
77            return Ok(());
78        }
79        enforce_two_tier(
80            &project_dir.join("yarn.lock"),
81            "yarn",
82            &["install", "--immutable", "--mode", "update-lockfile"],
83            &["install", "--mode", "update-lockfile"],
84            project_dir,
85            policy,
86        )
87    }
88
89    /// Restores the dependencies using the lockfile. The two lines of yarn spell
90    /// "install exactly what the lockfile says" differently, and each rejects the
91    /// other's flag.
92    fn restore(&self, project_dir: &Path, timeout: std::time::Duration) -> Result<()> {
93        if is_berry_project(project_dir) {
94            run_command_with_timeout("yarn", &["install", "--immutable"], project_dir, timeout)
95        } else {
96            run_command_with_timeout(
97                "yarn",
98                &["install", "--frozen-lockfile"],
99                project_dir,
100                timeout,
101            )
102        }
103    }
104
105    fn lockfiles(&self) -> &'static [&'static str] {
106        &["yarn.lock"]
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use std::fs;
114    use tempfile::tempdir;
115
116    #[test]
117    fn test_name() {
118        assert_eq!(Yarn.name(), "yarn");
119    }
120
121    #[test]
122    fn test_detect_positive() {
123        let dir = tempdir().unwrap();
124        fs::File::create(dir.path().join("yarn.lock")).unwrap();
125        assert!(Yarn.detect(dir.path()));
126    }
127
128    #[test]
129    fn test_detect_negative() {
130        let dir = tempdir().unwrap();
131        assert!(!Yarn.detect(dir.path()));
132    }
133
134    #[test]
135    fn test_bloat_dirs_present() {
136        let dir = tempdir().unwrap();
137        fs::create_dir(dir.path().join("node_modules")).unwrap();
138        let bloat = Yarn.bloat_dirs(dir.path());
139        assert_eq!(bloat.len(), 1);
140        assert_eq!(bloat[0].path, dir.path().join("node_modules"));
141    }
142
143    #[test]
144    fn test_bloat_dirs_absent() {
145        let dir = tempdir().unwrap();
146        let bloat = Yarn.bloat_dirs(dir.path());
147        assert!(bloat.is_empty());
148    }
149
150    #[test]
151    fn a_classic_lockfile_alone_is_not_berry() {
152        let dir = tempdir().unwrap();
153        fs::write(dir.path().join("yarn.lock"), "# yarn lockfile v1\n").unwrap();
154        assert!(!is_berry_project(dir.path()));
155    }
156
157    #[test]
158    fn a_yarnrc_yml_marks_the_project_as_berry() {
159        let dir = tempdir().unwrap();
160        fs::write(dir.path().join("yarn.lock"), "# yarn lockfile v1\n").unwrap();
161        fs::File::create(dir.path().join(".yarnrc.yml")).unwrap();
162        assert!(is_berry_project(dir.path()));
163    }
164
165    #[test]
166    fn a_dot_yarn_directory_marks_the_project_as_berry() {
167        let dir = tempdir().unwrap();
168        fs::write(dir.path().join("yarn.lock"), "# yarn lockfile v1\n").unwrap();
169        fs::create_dir(dir.path().join(".yarn")).unwrap();
170        assert!(is_berry_project(dir.path()));
171    }
172
173    #[test]
174    fn a_metadata_block_in_the_lockfile_marks_the_project_as_berry() {
175        let dir = tempdir().unwrap();
176        fs::write(dir.path().join("yarn.lock"), "__metadata:\n  version: 8\n").unwrap();
177        assert!(is_berry_project(dir.path()));
178    }
179
180    // On Classic the lockfile's existence is the whole proof — no yarn binary runs, so
181    // this passes on a machine with no yarn at all. Berry is the branch that shells
182    // out, and the one whose failures must reach the caller (the old version swallowed
183    // them by treating any Berry error as "must be Classic then").
184    #[test]
185    fn enforce_on_classic_needs_no_yarn_binary() {
186        let dir = tempdir().unwrap();
187        fs::write(
188            dir.path().join("yarn.lock"),
189            "# yarn lockfile v1\n\nleft-pad@^1.3.0:\n  version \"1.3.0\"\n",
190        )
191        .unwrap();
192        assert!(
193            Yarn.enforce_lockfile(dir.path(), EnforcePolicy::default())
194                .is_ok()
195        );
196    }
197}