Skip to main content

dev_prune/adapters/
deno.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Deno adapter.
5//
6// For most of its history a Deno project had nothing local to prune: everything Deno
7// downloaded went into one machine-wide directory (`DENO_DIR`) and the project directory
8// held source and nothing else. npm interoperability changed that. A project with a
9// `package.json`, or with `"nodeModulesDir": "auto"` in its config, gets a real
10// `node_modules/` beside the source; a project with `"vendor": true` gets a `vendor/`
11// tree holding a copy of every remote module it resolved. Both are rebuilt by `deno
12// install`, and on anything using the npm ecosystem both routinely outweigh the source
13// they sit next to.
14//
15// Detection is on `deno.lock` and nothing else. A `deno.json` without a lockfile is a
16// project whose versions are decided by whatever the next resolve happens to find, and a
17// `node_modules` only a fresh resolve can rebuild is not recoverable in the sense this
18// tool promises.
19
20use super::{
21    BloatDir, EnforcePolicy, PackageManager, dir_size_with_hardlinks, refuse_if_manifest_stale,
22    run_command_with_timeout,
23};
24use anyhow::{Result, anyhow};
25use std::fs;
26use std::path::Path;
27
28/// Deno adapter.
29pub struct Deno;
30
31/// The config files Deno reads, in the order it looks for them.
32const CONFIGS: [&str; 3] = ["deno.json", "deno.jsonc", "package.json"];
33
34impl Deno {
35    /// Whether this project has asked Deno to write a `vendor/` directory.
36    ///
37    /// `vendor/` is claimed only when the config says so, rather than whenever the
38    /// directory happens to exist. The name is not Deno's: Go and Composer both use a
39    /// `vendor/` at the project root for something else entirely, and a repository that
40    /// carried a `deno.lock` alongside either of those would otherwise have dev-prune
41    /// delete Composer's dependency tree and offer `deno install` to put it back.
42    ///
43    /// The config is parsed rather than searched. Looking for the text `"vendor": true`
44    /// finds it inside a comment as readily as in the setting, and `deno.jsonc` is a
45    /// format whose whole point is that it may carry comments — so a line somebody
46    /// commented out would have been read as a live instruction to delete. A config this
47    /// cannot parse is a config that claims nothing: under-claiming leaves a directory
48    /// on disk, and over-claiming deletes one Deno will not put back.
49    fn vendors(path: &Path) -> bool {
50        ["deno.json", "deno.jsonc"].iter().any(|name| {
51            fs::read_to_string(path.join(name))
52                .ok()
53                .and_then(|config| serde_json::from_str::<serde_json::Value>(&config).ok())
54                .and_then(|config| config.get("vendor").and_then(serde_json::Value::as_bool))
55                .unwrap_or(false)
56        })
57    }
58}
59
60impl PackageManager for Deno {
61    fn name(&self) -> &'static str {
62        "deno"
63    }
64
65    fn detect(&self, path: &Path) -> bool {
66        path.join("deno.lock").exists()
67    }
68
69    fn bloat_dirs(&self, path: &Path) -> Vec<BloatDir> {
70        let mut dirs = Vec::new();
71        // Deno hardlinks out of `DENO_DIR` when it materialises `node_modules`, exactly
72        // as bun does out of its own cache, so the shared/freed split is the honest
73        // measurement rather than the whole tree.
74        let node_modules = path.join("node_modules");
75        if node_modules.is_dir() {
76            let size = dir_size_with_hardlinks(&node_modules);
77            dirs.push(BloatDir {
78                name: "node_modules".to_string(),
79                path: node_modules,
80                size_bytes: size.freed_bytes,
81                shared_bytes: size.shared_bytes,
82            });
83        }
84        let vendor = path.join("vendor");
85        if vendor.is_dir() && Self::vendors(path) {
86            let size = dir_size_with_hardlinks(&vendor);
87            dirs.push(BloatDir {
88                name: "vendor".to_string(),
89                path: vendor,
90                size_bytes: size.freed_bytes,
91                shared_bytes: size.shared_bytes,
92            });
93        }
94        dirs
95    }
96
97    fn enforce_lockfile(&self, path: &Path, _policy: EnforcePolicy) -> Result<()> {
98        let lock = path.join("deno.lock");
99        let content = fs::read_to_string(&lock).map_err(|e| {
100            anyhow!(
101                "`deno.lock` could not be read ({e}) — without it `deno install` \
102                 resolves afresh instead of restoring the versions being deleted."
103            )
104        })?;
105        // Every Deno lockfile is a JSON object carrying a `version`. A file without one
106        // is a fragment or a merge conflict, and `deno install` would silently start
107        // over from the config rather than fail.
108        let parsed: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
109            anyhow!("`deno.lock` is not valid JSON ({e}) — it cannot be a complete lockfile.")
110        })?;
111        if parsed.get("version").is_none() {
112            return Err(anyhow!(
113                "`deno.lock` has no `version` field — it is not a complete Deno \
114                 lockfile, so the dependency tree cannot be proven rebuildable from it."
115            ));
116        }
117        // `deno install` resolves and writes rather than reporting, and `--frozen` still
118        // performs the whole install before it can disagree — a download, and then a
119        // write, in the middle of a delete pass. The timestamps are the only offline
120        // evidence there is, the same as for CocoaPods, Mix and pub.
121        for name in CONFIGS {
122            let manifest = path.join(name);
123            if manifest.exists() {
124                refuse_if_manifest_stale(&manifest, &lock, "deno install")?;
125            }
126        }
127        Ok(())
128    }
129
130    fn restore(&self, path: &Path, timeout: std::time::Duration) -> Result<()> {
131        run_command_with_timeout("deno", &["install"], path, timeout)
132    }
133
134    fn lockfiles(&self) -> &'static [&'static str] {
135        &["deno.lock"]
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use tempfile::tempdir;
143
144    fn lockfile(dir: &Path) {
145        fs::write(dir.join("deno.lock"), r#"{"version":"5","specifiers":{}}"#).unwrap();
146    }
147
148    #[test]
149    fn detects_on_the_lockfile_and_not_the_config() {
150        let dir = tempdir().unwrap();
151        fs::write(dir.path().join("deno.json"), "{}").unwrap();
152        assert!(!Deno.detect(dir.path()));
153        lockfile(dir.path());
154        assert!(Deno.detect(dir.path()));
155    }
156
157    #[test]
158    fn claims_node_modules() {
159        let dir = tempdir().unwrap();
160        fs::create_dir(dir.path().join("node_modules")).unwrap();
161        let names: Vec<String> = Deno
162            .bloat_dirs(dir.path())
163            .into_iter()
164            .map(|b| b.name)
165            .collect();
166        assert_eq!(names, vec!["node_modules"]);
167    }
168
169    #[test]
170    fn a_vendor_directory_is_claimed_only_when_the_config_asked_for_one() {
171        let dir = tempdir().unwrap();
172        fs::create_dir(dir.path().join("vendor")).unwrap();
173        assert!(Deno.bloat_dirs(dir.path()).is_empty());
174
175        // The setting, commented out. Read as text this says `"vendor":true`; read as
176        // configuration it says nothing at all, and Composer's `vendor/` in a repository
177        // that happens to also hold a `deno.lock` depends on the difference.
178        fs::write(
179            dir.path().join("deno.jsonc"),
180            "{\n  // \"vendor\": true\n}\n",
181        )
182        .unwrap();
183        assert!(Deno.bloat_dirs(dir.path()).is_empty());
184
185        fs::write(dir.path().join("deno.json"), "{ \"vendor\": true }").unwrap();
186        let names: Vec<String> = Deno
187            .bloat_dirs(dir.path())
188            .into_iter()
189            .map(|b| b.name)
190            .collect();
191        assert_eq!(names, vec!["vendor"]);
192    }
193
194    #[test]
195    fn a_missing_or_malformed_lockfile_is_refused() {
196        let dir = tempdir().unwrap();
197        assert!(
198            Deno.enforce_lockfile(dir.path(), EnforcePolicy::default())
199                .is_err()
200        );
201        fs::write(dir.path().join("deno.lock"), "<<<<<<< HEAD\n").unwrap();
202        assert!(
203            Deno.enforce_lockfile(dir.path(), EnforcePolicy::default())
204                .is_err()
205        );
206        fs::write(dir.path().join("deno.lock"), r#"{"specifiers":{}}"#).unwrap();
207        assert!(
208            Deno.enforce_lockfile(dir.path(), EnforcePolicy::default())
209                .is_err()
210        );
211        lockfile(dir.path());
212        assert!(
213            Deno.enforce_lockfile(dir.path(), EnforcePolicy::default())
214                .is_ok()
215        );
216    }
217
218    #[test]
219    fn is_not_opt_in_because_none_of_it_is_compiler_output() {
220        // `node_modules` is not opt-in the way a compiler-output directory is: `deno
221        // install` puts it back from the lockfile without recompiling anything.
222        assert!(!Deno.opt_in());
223    }
224}