dev_prune/channel.rs
1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4//! Which package manager delivered the binary that is running, and what that implies.
5//!
6//! Every lifecycle command needs this answer and each one used to work it out for
7//! itself. `update` had a private `Channel` enum, `uninstall` had a substring match
8//! returning an uninstall command, and `doctor` had a hand-written list of directories
9//! to search. Three classifiers, three sets of markers, and only one of them had ever
10//! heard of WinGet — so `devp update --install` overwrote a file WinGet owns, `devp
11//! uninstall` offered to delete it, and `devp doctor` never looked there at all.
12//!
13//! This module is the one answer. A channel knows its own name, its upgrade and
14//! uninstall commands, whether it owns the files it installed, and — the distinction
15//! that matters most — whether it *replaces its whole directory* on upgrade.
16//!
17//! The markers are path fragments rather than probes on purpose: classification happens
18//! on the startup path of every lifecycle command, so it must not spawn a process, touch
19//! the network, or depend on a manager being installed to recognise what it installed.
20
21use std::path::{Path, PathBuf};
22
23/// Path fragments that identify a channel, matched against the executable's path with
24/// separators normalised to `/` and folded to lower case.
25///
26/// Kept here rather than in `constants` because nothing outside this module refers to
27/// them — they are this classifier's private fingerprints, not names shared with the
28/// install scripts.
29mod marker {
30 pub const WINGET: &[&str] = &["/microsoft/winget/packages/", "/winget/links/"];
31 pub const SCOOP: &[&str] = &["/scoop/apps/", "/scoop/shims/"];
32 pub const HOMEBREW: &[&str] = &["/cellar/", "/homebrew/", "/linuxbrew/"];
33 pub const CARGO: &[&str] = &["/.cargo/"];
34 // The three npm-compatible clients, which have to be told apart from npm itself and
35 // from each other. All four end up with the executable inside a `node_modules` tree,
36 // so `NPM` matches every one of them and these have to be tried first.
37 pub const BUN: &[&str] = &["/.bun/"];
38 // `/pnpm/` and not `/pnpm/global/`, because the package lives under `global/` but the
39 // executable on PATH does not: pnpm puts its shim straight in `PNPM_HOME`
40 // (`~/.local/share/pnpm`, `%LOCALAPPDATA%\pnpm`), one level above. `uninstall`'s stray
41 // sweep looks in exactly that directory, so the narrower fragment made the one copy the
42 // sweep can actually find read as `Unknown` — and `Unknown` may be deleted directly,
43 // which removed pnpm's shim without ever running `pnpm remove -g`. Broad is safe here:
44 // `node_modules/.pnpm` is spelled with the dot and does not match.
45 pub const PNPM: &[&str] = &["/pnpm/", "/.pnpm-global/"];
46 pub const YARN: &[&str] = &[
47 "/yarn/global/",
48 "/yarn/data/global/",
49 "/.yarn/bin/",
50 "/yarn/bin/",
51 ];
52 pub const NPM: &[&str] = &["/node_modules/", "/_npx/"];
53 pub const UV_TOOL: &[&str] = &["/uv/tools/", "/uv-tool/"];
54 pub const PIPX: &[&str] = &["/pipx/"];
55
56 /// Trees that belong to a manager whose commands dev-prune does not know, paired
57 /// with the name to print. Each of these installs global executables and none of
58 /// them leaves a fragment any marker above matches, so before this list a copy in
59 /// one of them was indistinguishable from a loose file -- and got deleted.
60 ///
61 /// Detection only. There is deliberately no install or upgrade command for any of
62 /// them: none is installed on the machine this list was written on, so any command
63 /// here would be a guess, and a wrong upgrade command is worse than none.
64 pub const FOREIGN: &[(&str, &str)] = &[
65 ("/.deno/bin/", "Deno"),
66 ("/.volta/bin/", "Volta"),
67 // Both spellings of the tools tree: `~/.volta/tools/` on Unix, where the dot
68 // keeps the slash-anchored fragment below from matching, and
69 // `%LOCALAPPDATA%\Volta\tools\` on Windows, which has no dot to hide behind.
70 ("/.volta/tools/", "Volta"),
71 ("/volta/tools/", "Volta"),
72 ("/mise/shims/", "mise"),
73 ("/mise/installs/", "mise"),
74 ("/.asdf/shims/", "asdf"),
75 ("/nix/store/", "Nix"),
76 // Not `/usr/local/bin`, which is where a person putting a binary somewhere by
77 // hand puts it. `/usr/bin` is the distribution's, and on every distribution
78 // that packages anything, deleting out of it desynchronises the package
79 // database exactly the way deleting cargo's copy desynchronises `.crates.toml`.
80 ("/usr/bin/", "the system package manager"),
81 ];
82}
83
84/// The package manager that owns the running binary.
85///
86/// One channel owns one binary. A copy installed through uv is upgraded through uv,
87/// never through npm, because two managers writing the same PATH entry would fight over
88/// it forever.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum Channel {
91 /// `install.sh` / `install.ps1` put it under the managed `<config>/bin`.
92 Installer,
93 /// `cargo install` / `cargo binstall` put it under `~/.cargo/bin`.
94 Cargo,
95 /// `npm install -g` — the binary lives under a `node_modules` tree.
96 Npm,
97 /// `bun add -g` — under `~/.bun/install/global`.
98 Bun,
99 /// `pnpm add -g` — under pnpm's own global store.
100 Pnpm,
101 /// `yarn global add` (Yarn 1.x) — under `~/.config/yarn/global`, shimmed from
102 /// `~/.yarn/bin`.
103 Yarn,
104 /// `uv tool install` — under uv's tool environments.
105 UvTool,
106 /// `pipx install` — under a `pipx` venv.
107 Pipx,
108 /// `pip install` — a console script beside a Python interpreter, in the system
109 /// scripts directory or a virtualenv's.
110 Pip,
111 /// `winget install` — under `%LOCALAPPDATA%\Microsoft\WinGet\Packages`.
112 WinGet,
113 /// `scoop install` — under `~/scoop/apps`, shimmed from `~/scoop/shims`.
114 Scoop,
115 /// `brew install` — under the Cellar, symlinked into the prefix's `bin`.
116 Homebrew,
117 /// Anywhere else: a dev build, a hand-copied binary, a distro package.
118 Unknown,
119 /// A copy inside a tree that is recognisably some manager's, where dev-prune knows
120 /// the manager's name and not its commands.
121 ///
122 /// The distinction that matters is against [`Channel::Unknown`], not against the
123 /// named channels: `Unknown` means *nothing on this machine claims this file*, and
124 /// `devp uninstall` deletes those because the file is the whole install. This means
125 /// *something claims it and dev-prune cannot speak to it*, which is the one case
126 /// where the only safe move is to name the manager and stop.
127 Foreign(&'static str),
128}
129
130impl Channel {
131 /// Classify the running executable.
132 pub fn detect() -> Self {
133 let Ok(exe) = std::env::current_exe() else {
134 return Channel::Unknown;
135 };
136 let managed = crate::setup::managed_exe_path().ok();
137 Self::detect_at(&exe, managed.as_deref())
138 }
139
140 /// Classify `exe` by the directories in its path.
141 ///
142 /// Purely lexical: this must not touch the network or spawn anything, and each
143 /// channel's layout is stable enough that its marker directory is a reliable
144 /// fingerprint. `managed` is passed in rather than resolved here so tests can probe
145 /// the classification without a config directory on disk.
146 ///
147 /// The managed path is checked first and the three directory-owning managers next.
148 /// Order is load-bearing twice over. A Scoop install of a Rust toolchain can put
149 /// `.cargo` inside `~/scoop`, and misreading that as `Cargo` would send `devp update`
150 /// to run `cargo install` against a directory Scoop replaces wholesale. And bun,
151 /// pnpm and yarn all install npm packages into a `node_modules` tree of their own, so
152 /// each of them matches npm's marker as well as its own and has to be tried
153 /// before it.
154 pub fn detect_at(exe: &Path, managed: Option<&Path>) -> Self {
155 // The *directory*, not the file. `managed_exe_path` names `dev-prune`, and the
156 // install scripts put `devp` beside it — so comparing whole paths recognised the
157 // long name and classified the short one, which is the one the documentation
158 // tells people to type, as `Unknown`. The symptom was `devp update` answering "no
159 // package manager owns this copy" to someone who had installed with the install
160 // script two minutes earlier. `<config>/bin` holds dev-prune's own binaries and
161 // nothing else, so anything running from it is the installer's copy under one of
162 // its two names.
163 if let Some(managed) = managed
164 && let Some(managed_dir) = managed.parent()
165 && exe.parent() == Some(managed_dir)
166 {
167 return Channel::Installer;
168 }
169 let path = exe.to_string_lossy().replace('\\', "/").to_lowercase();
170 let any = |markers: &[&str]| markers.iter().any(|m| path.contains(m));
171
172 if any(marker::WINGET) {
173 Channel::WinGet
174 } else if any(marker::SCOOP) {
175 Channel::Scoop
176 } else if any(marker::HOMEBREW) {
177 Channel::Homebrew
178 } else if any(marker::CARGO) {
179 Channel::Cargo
180 } else if let Some((_, name)) = marker::FOREIGN.iter().find(|(m, _)| path.contains(m)) {
181 // Ahead of the npm and pip families, not just the two beside-file checks
182 // below. A Volta npm install runs from `~/.volta/tools/image/packages/…/
183 // lib/node_modules/…`, a mise npm backend from `…/mise/installs/…/lib/
184 // node_modules/…`, and a Nix-packaged npm tool from `/nix/store/…/lib/
185 // node_modules/…` — every one matches npm's `/node_modules/` fragment, and
186 // `Npm` here meant `devp update` writing release bytes into a tree Volta,
187 // mise or Nix owns. A tree that names its manager outranks a structure many
188 // managers share. The beside-file checks have the same problem one step
189 // later: `/usr/bin` holds a `python3` on every Linux, and mise and asdf
190 // keep a `python` shim beside every other shim, so all three read as pip
191 // installs — and `/usr/bin/dev-prune` would be handed `pip install
192 // --upgrade`, which is the distribution's copy and none of pip's business.
193 Channel::Foreign(name)
194 } else if any(marker::BUN) {
195 Channel::Bun
196 } else if any(marker::PNPM) {
197 Channel::Pnpm
198 } else if any(marker::YARN) {
199 Channel::Yarn
200 } else if any(marker::NPM) {
201 Channel::Npm
202 } else if any(marker::UV_TOOL) {
203 Channel::UvTool
204 } else if any(marker::PIPX) {
205 Channel::Pipx
206 } else if npm_shim_beside(exe) {
207 Channel::Npm
208 } else if pip_script_beside(exe) {
209 Channel::Pip
210 } else {
211 Channel::Unknown
212 }
213 }
214
215 /// How to name this channel in a sentence addressed to the user.
216 pub fn label(self) -> &'static str {
217 match self {
218 Channel::Installer => "the install script",
219 Channel::Cargo => "cargo",
220 Channel::Npm => "npm",
221 Channel::Bun => "bun",
222 Channel::Pnpm => "pnpm",
223 Channel::Yarn => "yarn",
224 Channel::UvTool => "uv",
225 Channel::Pipx => "pipx",
226 Channel::Pip => "pip",
227 Channel::WinGet => "WinGet",
228 Channel::Scoop => "Scoop",
229 Channel::Homebrew => "Homebrew",
230 Channel::Unknown => "an unrecognised location",
231 Channel::Foreign(name) => name,
232 }
233 }
234
235 /// How to name this channel beside the version number, where there is room for a
236 /// word and not a clause.
237 ///
238 /// Deliberately not [`Self::label`]. That one is written to drop into a sentence —
239 /// "installed with the install script", "this copy came from an unrecognised
240 /// location" — and both of those read as noise next to a version. The `Unknown` case
241 /// is the one worth spelling differently rather than shortening: `standalone` says
242 /// the file *is* the whole install, which is the fact behind every other thing
243 /// dev-prune says about that copy.
244 pub fn badge(self) -> &'static str {
245 match self {
246 Channel::Installer => "install script",
247 Channel::Unknown => "standalone",
248 named => named.label(),
249 }
250 }
251
252 /// The command that upgrades through this channel, as the user would type it.
253 ///
254 /// `None` for [`Channel::Unknown`] only: there is no command to name for a binary
255 /// somebody copied into place by hand.
256 pub fn upgrade_command(self) -> Option<String> {
257 self.upgrade_argv().map(|argv| self.typed_form(&argv))
258 }
259
260 /// The command that uninstalls through this channel.
261 ///
262 /// `None` where there is no manager to tell: the installer’s own copy is deleted by
263 /// `devp uninstall` itself, and an unrecognised copy is just a file.
264 pub fn uninstall_command(self) -> Option<String> {
265 self.uninstall_argv().map(|argv| self.typed_form(&argv))
266 }
267
268 /// The command that installs dev-prune fresh through this channel, as the user
269 /// would type it. Does not include [`Self::install_sources`].
270 pub fn install_command(self) -> Option<String> {
271 self.install_argv().map(|argv| self.typed_form(&argv))
272 }
273
274 /// Sources that must exist before [`Self::install_argv`] can resolve dev-prune.
275 ///
276 /// Homebrew and Scoop are the only reason this exists. The formula and the manifest
277 /// live in this project’s own tap and bucket rather than the default index, and
278 /// `brew install dev-prune` without the tap resolves against homebrew-core, where
279 /// dev-prune is not published. Adding a source that is already added reports
280 /// failure, so these steps are best-effort; the install itself is not.
281 pub fn install_sources(self) -> Vec<Vec<String>> {
282 match self {
283 Channel::Scoop => vec![owned(&[
284 "scoop",
285 "bucket",
286 "add",
287 crate::constants::SCOOP_BUCKET_NAME,
288 crate::constants::SCOOP_BUCKET_URL,
289 ])],
290 Channel::Homebrew => vec![owned(&["brew", "tap", crate::constants::HOMEBREW_TAP])],
291 _ => Vec::new(),
292 }
293 }
294
295 /// The command that installs dev-prune fresh through this channel, once
296 /// [`Self::install_sources`] has run.
297 ///
298 /// `None` for `Pip` and `Unknown`: a bare `pip install` of a CLI puts the console
299 /// script wherever the active interpreter happens to be, which is the ambiguity `uv
300 /// tool` and `pipx` exist to remove, and nothing installs *into* an unrecognised
301 /// location on purpose.
302 pub fn install_argv(self) -> Option<Vec<String>> {
303 Some(match self {
304 // Same preference as the upgrade: binstall fetches the prebuilt release,
305 // a plain `cargo install` compiles for minutes.
306 Channel::Cargo => {
307 if crate::adapters::binary_available("cargo-binstall") {
308 owned(&["cargo", "binstall", "dev-prune", "-y"])
309 } else {
310 owned(&["cargo", "install", "dev-prune"])
311 }
312 }
313 Channel::Npm => owned(&["npm", "install", "-g", "dev-prune"]),
314 Channel::Bun => owned(&["bun", "add", "-g", "dev-prune"]),
315 Channel::Pnpm => owned(&["pnpm", "add", "-g", "dev-prune"]),
316 Channel::Yarn => owned(&["yarn", "global", "add", "dev-prune"]),
317 // `@latest` because `uv tool install dev-prune` against an environment uv
318 // already has prints "already installed" and exits successfully without
319 // changing anything — which reads, from here, as a move that worked.
320 Channel::UvTool => owned(&["uv", "tool", "install", "dev-prune@latest"]),
321 Channel::Pipx => owned(&["pipx", "install", "dev-prune"]),
322 Channel::WinGet => vec![
323 "winget".to_string(),
324 "install".to_string(),
325 "--id".to_string(),
326 crate::constants::WINGET_PACKAGE_ID.to_string(),
327 "--accept-package-agreements".to_string(),
328 "--accept-source-agreements".to_string(),
329 ],
330 Channel::Scoop => owned(&["scoop", "install", "dev-prune"]),
331 Channel::Homebrew => owned(&["brew", "install", "dev-prune"]),
332 Channel::Installer => self.installer_argv(),
333 Channel::Pip | Channel::Unknown | Channel::Foreign(_) => return None,
334 })
335 }
336
337 /// The command that upgrades the copy this channel installed.
338 pub fn upgrade_argv(self) -> Option<Vec<String>> {
339 Some(match self {
340 Channel::Cargo => {
341 if crate::adapters::binary_available("cargo-binstall") {
342 owned(&["cargo", "binstall", "dev-prune", "--force", "-y"])
343 } else {
344 owned(&["cargo", "install", "dev-prune", "--force"])
345 }
346 }
347 // The four npm-compatible clients, each run through itself. `@latest` is
348 // load-bearing for the first three: given a bare name they resolve against a
349 // manifest they already have and report the installed version as current.
350 Channel::Npm => owned(&["npm", "install", "-g", "dev-prune@latest"]),
351 Channel::Bun => owned(&["bun", "add", "-g", "dev-prune@latest"]),
352 Channel::Pnpm => owned(&["pnpm", "add", "-g", "dev-prune@latest"]),
353 // Yarn 1.x, which is the only Yarn that has `yarn global` at all. Berry
354 // removed it and prints its own explanation of what to use instead — a
355 // better message than any guess this could make on its behalf.
356 Channel::Yarn => owned(&["yarn", "global", "upgrade", "dev-prune"]),
357 Channel::UvTool => owned(&["uv", "tool", "upgrade", "dev-prune"]),
358 Channel::Pipx => owned(&["pipx", "upgrade", "dev-prune"]),
359 Channel::Pip => owned(&["pip", "install", "--upgrade", "dev-prune"]),
360 // The three that own their whole package directory. Each is given its own
361 // command rather than the direct download, because replacing a file inside a
362 // versioned package directory desynchronises the manager from what is on
363 // disk — and the next `winget upgrade` or `brew upgrade` would put the old
364 // binary back.
365 Channel::WinGet => vec![
366 "winget".to_string(),
367 "upgrade".to_string(),
368 "--id".to_string(),
369 crate::constants::WINGET_PACKAGE_ID.to_string(),
370 "--accept-package-agreements".to_string(),
371 "--accept-source-agreements".to_string(),
372 ],
373 Channel::Scoop => owned(&["scoop", "update", "dev-prune"]),
374 Channel::Homebrew => owned(&["brew", "upgrade", "dev-prune"]),
375 Channel::Installer => self.installer_argv(),
376 Channel::Unknown | Channel::Foreign(_) => return None,
377 })
378 }
379
380 /// The command that removes the copy this channel installed *and* clears the record
381 /// the manager keeps of it.
382 ///
383 /// Running this is the only correct way to remove a manager-owned copy, and the
384 /// reason is not tidiness. Deleting the file behind cargo’s back leaves
385 /// `.crates.toml` naming a binary that is gone, and `cargo uninstall dev-prune` then
386 /// exits 101 with `corrupt metadata, ... does not exist when it should` — without
387 /// clearing the entry. The manager has to be told first, or it can never be told at
388 /// all.
389 ///
390 /// `None` where no manager holds a record: the installer’s own copy is deleted by
391 /// `devp uninstall` itself, and an unrecognised copy is just a file.
392 pub fn uninstall_argv(self) -> Option<Vec<String>> {
393 Some(match self {
394 Channel::Cargo => owned(&["cargo", "uninstall", "dev-prune"]),
395 Channel::Npm => owned(&["npm", "uninstall", "-g", "dev-prune"]),
396 Channel::Bun => owned(&["bun", "remove", "-g", "dev-prune"]),
397 Channel::Pnpm => owned(&["pnpm", "remove", "-g", "dev-prune"]),
398 Channel::Yarn => owned(&["yarn", "global", "remove", "dev-prune"]),
399 Channel::UvTool => owned(&["uv", "tool", "uninstall", "dev-prune"]),
400 Channel::Pipx => owned(&["pipx", "uninstall", "dev-prune"]),
401 // `-y`: pip asks on stdin, and whatever ran this has already asked.
402 Channel::Pip => owned(&["pip", "uninstall", "-y", "dev-prune"]),
403 Channel::WinGet => vec![
404 "winget".to_string(),
405 "uninstall".to_string(),
406 "--id".to_string(),
407 crate::constants::WINGET_PACKAGE_ID.to_string(),
408 ],
409 Channel::Scoop => owned(&["scoop", "uninstall", "dev-prune"]),
410 Channel::Homebrew => owned(&["brew", "uninstall", "dev-prune"]),
411 Channel::Installer | Channel::Unknown | Channel::Foreign(_) => return None,
412 })
413 }
414
415 /// The install one-liner, wrapped in the shell that runs it.
416 fn installer_argv(self) -> Vec<String> {
417 if cfg!(windows) {
418 vec![
419 "powershell".to_string(),
420 "-NoProfile".to_string(),
421 "-Command".to_string(),
422 format!("iwr -useb {} | iex", crate::constants::INSTALL_PS1_URL),
423 ]
424 } else {
425 vec![
426 "sh".to_string(),
427 "-c".to_string(),
428 format!("curl -fsSL {} | sh", crate::constants::INSTALL_SH_URL),
429 ]
430 }
431 }
432
433 /// An argv as a user would type it.
434 ///
435 /// Joining the arguments is right for every channel but one: the installer’s argv
436 /// wraps a shell one-liner in `powershell -Command` or `sh -c`, and printing the
437 /// wrapper would hand the reader something they cannot paste.
438 fn typed_form(self, argv: &[String]) -> String {
439 if self == Channel::Installer {
440 return argv.last().cloned().unwrap_or_default();
441 }
442 argv.join(" ")
443 }
444
445 /// Whether a package manager keeps a record of this install that deleting the file
446 /// would falsify.
447 ///
448 /// When true, `devp uninstall` names the manager's own command instead of quietly
449 /// removing the file: `pip list` still showing a package whose binary is gone, or
450 /// `cargo install` refusing to reinstall over its own bookkeeping, is worse than a
451 /// leftover binary the user was told about.
452 pub fn owns_its_files(self) -> bool {
453 !matches!(self, Channel::Installer | Channel::Unknown)
454 }
455
456 /// Whether `devp uninstall` may delete this copy with `fs::remove_file`.
457 ///
458 /// True in exactly two cases, and the two look identical from a path, which is why
459 /// this is asked as its own question. The installer keeps no record beyond the file
460 /// it wrote, and a copy in a location nothing claims is a file somebody moved there.
461 /// Everything else -- a manager with a command, and a manager without one -- is
462 /// removed by its manager or not at all: [`Self::uninstall_argv`] explains what
463 /// deleting the file first costs, and a [`Channel::Foreign`] copy costs the same
464 /// with no way to repair it afterwards.
465 pub fn may_delete_directly(self) -> bool {
466 matches!(self, Channel::Installer | Channel::Unknown)
467 }
468
469 /// Whether this channel replaces its install *directory* wholesale on upgrade.
470 ///
471 /// This is the distinction the old per-command classifiers did not have, and the one
472 /// that caused a real bug. WinGet, Scoop and Homebrew each version their package
473 /// directory and swap the whole thing — `…\WinGet\Packages\<id>\`, `~/scoop/apps/
474 /// <pkg>/<version>/`, `<prefix>/Cellar/<pkg>/<version>/`. Anything dev-prune writes
475 /// beside its own executable there is gone at the next upgrade, and anything
476 /// *pointing* at it — a scheduled task, a git hook — is left aimed at a path that no
477 /// longer exists.
478 ///
479 /// So nothing durable is ever written into one of these directories. The `devp`
480 /// twin goes to the managed `<config>/bin` instead, which this program owns and
481 /// which no package manager will replace underneath it.
482 pub fn replaces_its_directory(self) -> bool {
483 matches!(self, Channel::WinGet | Channel::Scoop | Channel::Homebrew)
484 }
485}
486
487/// A borrowed argv as an owned one.
488fn owned(v: &[&str]) -> Vec<String> {
489 v.iter().map(|s| s.to_string()).collect()
490}
491
492/// npm's global shims sit *beside* its `node_modules`, not inside it, so the path alone
493/// does not identify them.
494fn npm_shim_beside(exe: &Path) -> bool {
495 exe.parent()
496 .is_some_and(|dir| dir.join("node_modules").join("dev-prune").exists())
497}
498
499/// pip puts console scripts beside the interpreter that installed them — a system
500/// `Scripts`/`bin` directory or a virtualenv's — and there is no marker in the path to
501/// say so. The interpreter next door is the only evidence there is.
502///
503/// Checked last, after uv and pipx: both of those are pip installs underneath, and both
504/// have an interpreter beside the script. Their own markers must win, or `devp
505/// uninstall` would tell a pipx user to run `pip uninstall` inside a venv they do not
506/// know exists.
507fn pip_script_beside(exe: &Path) -> bool {
508 exe.parent().is_some_and(|dir| {
509 ["python.exe", "python", "python3"]
510 .iter()
511 .any(|interpreter| dir.join(interpreter).exists())
512 })
513}
514
515/// This binary, running from inside a project's own virtual environment.
516///
517/// The distinction that matters is not "was this installed by pip" — a machine-wide
518/// `pip install` is a perfectly good way to get the tool. It is "does this copy live
519/// inside one project's environment", because such a copy dies with the environment,
520/// and until it does it is a package that project's `requirements.txt` has to account
521/// for before the environment can ever be pruned.
522///
523/// `pyvenv.cfg` one directory above the script is what separates the two: every virtual
524/// environment has one and no system install does.
525pub struct ProjectVenvInstall {
526 /// The environment root — the directory holding `pyvenv.cfg`.
527 pub venv: PathBuf,
528 /// The directory the environment sits in, which is the project in every layout
529 /// anyone actually uses.
530 pub project: PathBuf,
531}
532
533/// Detect a [`ProjectVenvInstall`] for `exe`, or `None` if this copy lives anywhere else.
534///
535/// Takes the executable rather than reading `current_exe` so the detection can be tested
536/// against a directory tree instead of against whichever machine runs the suite.
537pub fn project_venv_install(exe: &Path) -> Option<ProjectVenvInstall> {
538 if !pip_script_beside(exe) {
539 return None;
540 }
541 let venv = exe.parent()?.parent()?;
542 if !venv.join("pyvenv.cfg").exists() {
543 return None;
544 }
545 Some(ProjectVenvInstall {
546 venv: venv.to_path_buf(),
547 project: venv.parent()?.to_path_buf(),
548 })
549}
550
551/// Every fixed directory a channel installs into, whether or not it is on `PATH`.
552///
553/// Shared by `devp doctor` (which reports copies running a different version) and `devp
554/// uninstall` (which offers to sweep them up). They looked in different places before
555/// this was one list, which meant doctor could report a stale copy that uninstall would
556/// then fail to find.
557///
558/// Non-existent entries are included; callers filter. `home` is passed in so the list
559/// can be tested without a home directory full of package managers.
560pub fn install_dirs(home: Option<&Path>) -> Vec<PathBuf> {
561 let mut dirs: Vec<PathBuf> = Vec::new();
562 let Some(home) = home else {
563 return dirs;
564 };
565 // `bin` on unix, `Scripts` on Windows — the same venv layout under both uv and
566 // pipx, and the reason a Windows uv copy is missed by a unix-shaped guess.
567 let scripts = if cfg!(windows) { "Scripts" } else { "bin" };
568
569 dirs.push(home.join(".cargo").join("bin"));
570 dirs.push(home.join(".local").join("bin"));
571 dirs.push(
572 home.join(".local")
573 .join("share")
574 .join("uv")
575 .join("tools")
576 .join("dev-prune")
577 .join(scripts),
578 );
579 dirs.push(
580 home.join(".local")
581 .join("pipx")
582 .join("venvs")
583 .join("dev-prune")
584 .join(scripts),
585 );
586 dirs.push(
587 home.join("pipx")
588 .join("venvs")
589 .join("dev-prune")
590 .join(scripts),
591 );
592
593 // bun keeps its global bin in the same place on every platform.
594 dirs.push(home.join(".bun").join("bin"));
595
596 if cfg!(windows) {
597 // uv keeps its tool environments under `%APPDATA%` on Windows, which is not
598 // under `.local` at all.
599 dirs.push(
600 home.join("AppData")
601 .join("Roaming")
602 .join("uv")
603 .join("tools")
604 .join("dev-prune")
605 .join(scripts),
606 );
607 dirs.push(home.join("AppData").join("Roaming").join("npm"));
608 dirs.push(
609 home.join("AppData")
610 .join("Local")
611 .join("Microsoft")
612 .join("WinGet")
613 .join("Links"),
614 );
615 dirs.push(home.join("scoop").join("shims"));
616 dirs.push(home.join("AppData").join("Local").join("pnpm"));
617 dirs.push(home.join("AppData").join("Local").join("Yarn").join("bin"));
618 } else {
619 dirs.push(home.join(".npm-global").join("bin"));
620 dirs.push(home.join(".local").join("share").join("pnpm"));
621 dirs.push(home.join(".yarn").join("bin"));
622 dirs.push(PathBuf::from("/opt/homebrew/bin"));
623 dirs.push(PathBuf::from("/usr/local/bin"));
624 dirs.push(PathBuf::from("/home/linuxbrew/.linuxbrew/bin"));
625 }
626 dirs
627}
628
629#[cfg(test)]
630mod tests {
631 use super::*;
632 use tempfile::TempDir;
633
634 #[test]
635 fn each_channel_is_recognised_by_its_marker_directory() {
636 let cases: &[(&str, Channel)] = &[
637 ("/home/k/.cargo/bin/dev-prune", Channel::Cargo),
638 (
639 "/usr/lib/node_modules/dev-prune/bin/dev-prune",
640 Channel::Npm,
641 ),
642 // The platform package, which is where the executable npm actually runs
643 // lives. `devp doctor` suppresses its missing-twin warning on the strength
644 // of this: npm ships the second name as a launcher of its own, so there is
645 // no file to look for beside this one.
646 (
647 "/usr/lib/node_modules/dev-prune-linux-x64/bin/dev-prune",
648 Channel::Npm,
649 ),
650 // The three npm-compatible clients, at the path a *global* install of
651 // dev-prune actually produces: the npm package is a dispatcher plus one
652 // platform package, so the executable is always inside a `node_modules`
653 // tree and every one of these used to read as `Channel::Npm`.
654 (
655 "/home/k/.bun/install/global/node_modules/@dev-prune/linux-x64/dev-prune",
656 Channel::Bun,
657 ),
658 (
659 "/home/k/.local/share/pnpm/global/5/node_modules/@dev-prune/linux-x64/dev-prune",
660 Channel::Pnpm,
661 ),
662 (
663 "/home/k/.config/yarn/global/node_modules/@dev-prune/linux-x64/dev-prune",
664 Channel::Yarn,
665 ),
666 (
667 r"C:\Users\k\AppData\Local\pnpm\global\5\node_modules\@dev-prune\win32-x64\dev-prune.exe",
668 Channel::Pnpm,
669 ),
670 (
671 r"C:\Users\k\AppData\Local\Yarn\Data\global\node_modules\@dev-prune\win32-x64\dev-prune.exe",
672 Channel::Yarn,
673 ),
674 (
675 "/home/k/.local/share/uv/tools/dev-prune/bin/dev-prune",
676 Channel::UvTool,
677 ),
678 (
679 "/home/k/.local/pipx/venvs/dev-prune/bin/dev-prune",
680 Channel::Pipx,
681 ),
682 (
683 r"C:\Users\k\AppData\Local\Microsoft\WinGet\Packages\VKrishna04.dev-prune_x\dev-prune.exe",
684 Channel::WinGet,
685 ),
686 (
687 r"C:\Users\k\scoop\apps\dev-prune\1.5.1\dev-prune.exe",
688 Channel::Scoop,
689 ),
690 (
691 "/opt/homebrew/Cellar/dev-prune/1.5.1/bin/dev-prune",
692 Channel::Homebrew,
693 ),
694 ("/opt/somewhere/dev-prune", Channel::Unknown),
695 ];
696 for (path, expected) in cases {
697 assert_eq!(
698 Channel::detect_at(Path::new(path), None),
699 *expected,
700 "{path}"
701 );
702 }
703 }
704
705 /// The paths above are where the pnpm *package* lands. The executable on PATH is the
706 /// shim one level up, straight in `PNPM_HOME` — and that is the only one of the two
707 /// `sweep_dirs` looks in, so it is the copy `devp uninstall` actually finds. While the
708 /// marker required `global/`, that shim read as `Unknown`, which
709 /// [`Channel::may_delete_directly`] permits deleting outright: the sweep removed the
710 /// file pnpm's own manifest still points at, without ever running `pnpm remove -g`.
711 #[test]
712 fn the_pnpm_shim_is_pnpm_and_not_an_unowned_file() {
713 for path in [
714 "/home/k/.local/share/pnpm/devp",
715 "/home/k/.local/share/pnpm/dev-prune",
716 r"C:\Users\k\AppData\Local\pnpm\devp.exe",
717 ] {
718 let channel = Channel::detect_at(Path::new(path), None);
719 assert_eq!(channel, Channel::Pnpm, "{path}");
720 assert!(!channel.may_delete_directly(), "{path}");
721 assert!(channel.uninstall_argv().is_some(), "{path}");
722 }
723 // The fragment is broad enough to catch the shim without catching pnpm's virtual
724 // store, which spells the directory with a leading dot.
725 assert_eq!(
726 Channel::detect_at(
727 Path::new(
728 "/w/app/node_modules/.pnpm/dev-prune@1.0.0/node_modules/dev-prune/bin/dev-prune"
729 ),
730 None
731 ),
732 Channel::Npm
733 );
734 }
735
736 /// Before `Channel::Foreign` these were `Unknown`, and `devp uninstall --yes`
737 /// deleted them. A Deno or Volta or mise install leaves no fragment any other
738 /// marker matches, so nothing distinguished one from a binary somebody copied.
739 #[test]
740 fn a_managed_tree_with_no_known_commands_is_foreign_rather_than_unknown() {
741 for (path, name) in [
742 ("/home/k/.deno/bin/dev-prune", "Deno"),
743 ("/home/k/.volta/bin/dev-prune", "Volta"),
744 ("/home/k/.local/share/mise/shims/dev-prune", "mise"),
745 ("/usr/bin/dev-prune", "the system package manager"),
746 ] {
747 assert_eq!(
748 Channel::detect_at(Path::new(path), None),
749 Channel::Foreign(name),
750 "{path} was not read as {name}'s"
751 );
752 }
753 }
754
755 /// The tree the binary is in outranks whatever else happens to be in it.
756 ///
757 /// `/usr/bin` holds a `python3` on every Linux, and mise and asdf keep a `python`
758 /// shim beside every other shim, so all three answered `pip_script_beside` and were
759 /// read as pip installs — `/usr/bin/dev-prune`, the distribution's own copy, would
760 /// have been handed `pip install --upgrade`.
761 #[test]
762 fn a_python_next_door_does_not_make_a_foreign_tree_pips() {
763 let tmp = TempDir::new().unwrap();
764 let shims = tmp.path().join(".asdf/shims");
765 std::fs::create_dir_all(&shims).unwrap();
766 std::fs::write(shims.join("python3"), "").unwrap();
767 std::fs::write(shims.join("python.exe"), "").unwrap();
768
769 let exe = shims.join("dev-prune");
770 std::fs::write(&exe, "").unwrap();
771 assert_eq!(Channel::detect_at(&exe, None), Channel::Foreign("asdf"));
772
773 // Same for the other inference: a `node_modules/dev-prune` beside it does not
774 // make the tree npm's either.
775 std::fs::create_dir_all(shims.join("node_modules/dev-prune")).unwrap();
776 assert_eq!(Channel::detect_at(&exe, None), Channel::Foreign("asdf"));
777
778 // And neither check is broken, only outranked: the same neighbours in a tree
779 // nothing claims still identify it.
780 let loose = tmp.path().join("bin");
781 std::fs::create_dir_all(loose.join("node_modules/dev-prune")).unwrap();
782 let exe = loose.join("dev-prune");
783 std::fs::write(&exe, "").unwrap();
784 assert_eq!(Channel::detect_at(&exe, None), Channel::Npm);
785 }
786
787 /// `/usr/local/bin` is where a person putting a binary somewhere by hand puts it,
788 /// and reading it as the distribution's would make the sweep refuse to clean up
789 /// after itself.
790 ///
791 /// Under a temp root rather than at the real path: `detect_at` reads the filesystem
792 /// for its last two checks, and on the macOS runner Homebrew keeps a `python3` in
793 /// the real `/usr/local/bin` — which makes that directory pip's on that machine, and
794 /// makes the literal path a question about the runner instead of about the marker.
795 #[test]
796 fn usr_local_bin_stays_unclaimed() {
797 let tmp = TempDir::new().unwrap();
798 let bin = tmp.path().join("usr/local/bin");
799 std::fs::create_dir_all(&bin).unwrap();
800 assert_eq!(
801 Channel::detect_at(&bin.join("dev-prune"), None),
802 Channel::Unknown
803 );
804 }
805
806 /// Three channels have no `uninstall_argv`, and only two of them may be deleted.
807 /// Conflating those was the bug: `Foreign` is a manager's file with no command to
808 /// repair it afterwards, which makes deleting it the one move with no way back.
809 #[test]
810 fn only_the_installer_and_an_unclaimed_copy_may_be_deleted_outright() {
811 for channel in [Channel::Installer, Channel::Unknown] {
812 assert!(channel.uninstall_argv().is_none());
813 assert!(channel.may_delete_directly(), "{channel:?}");
814 }
815 let foreign = Channel::Foreign("Deno");
816 assert!(foreign.uninstall_argv().is_none());
817 assert!(!foreign.may_delete_directly());
818 // Nor is a command guessed for it anywhere else.
819 assert!(foreign.install_argv().is_none());
820 assert!(foreign.upgrade_argv().is_none());
821 }
822
823 #[test]
824 fn the_managed_copy_is_the_installer_channel() {
825 // Even a managed directory that happens to live under `.cargo` is the
826 // installer's — the managed path is an identity, not a heuristic.
827 let managed = Path::new("/home/k/.cargo/odd/dev-prune/bin/dev-prune");
828 assert_eq!(
829 Channel::detect_at(managed, Some(managed)),
830 Channel::Installer
831 );
832 }
833
834 /// The install scripts write both names into `<config>/bin`, and `managed_exe_path`
835 /// can only name one of them. Matching on the file made `devp` — the name every page
836 /// of the documentation uses — come out as `Unknown`, so `devp update` told a user who
837 /// had just run `install.ps1` that no package manager owned their copy while
838 /// `dev-prune update`, the same binary under its other name, answered correctly.
839 #[test]
840 fn either_name_in_the_managed_directory_is_the_installer() {
841 // Spelled per-platform: a `C:\…` raw string is a single relative component on
842 // Unix, where `\` is an ordinary character — all three paths shared the same
843 // empty parent there, and the sibling-directory case below passed as Installer.
844 let (managed, twin, outside) = if cfg!(windows) {
845 (
846 r"C:\Users\k\AppData\Roaming\dev-prune\bin\dev-prune.exe",
847 r"C:\Users\k\AppData\Roaming\dev-prune\bin\devp.exe",
848 r"C:\Users\k\AppData\Roaming\dev-prune\bin2\devp.exe",
849 )
850 } else {
851 (
852 "/home/k/.config/dev-prune/bin/dev-prune",
853 "/home/k/.config/dev-prune/bin/devp",
854 "/home/k/.config/dev-prune/bin2/devp",
855 )
856 };
857 let managed = Path::new(managed);
858 for exe in [managed, Path::new(twin)] {
859 assert_eq!(
860 Channel::detect_at(exe, Some(managed)),
861 Channel::Installer,
862 "{}",
863 exe.display()
864 );
865 // The whole point of getting this right: the installer channel can name its
866 // own upgrade command, and `Unknown` cannot name anything.
867 assert!(
868 Channel::detect_at(exe, Some(managed))
869 .upgrade_command()
870 .is_some()
871 );
872 }
873 // A sibling directory is not the managed one, however similar the name.
874 assert_eq!(
875 Channel::detect_at(Path::new(outside), Some(managed)),
876 Channel::Unknown
877 );
878 }
879
880 /// A Rust toolchain installed through Scoop puts `.cargo` under `~/scoop`. Reading
881 /// that as `Cargo` would send an upgrade to `cargo install` against a directory
882 /// Scoop replaces wholesale, so the directory-owning managers are tested first.
883 #[test]
884 fn a_directory_owning_manager_wins_over_a_nested_marker() {
885 let path = Path::new(r"C:\Users\k\scoop\apps\rust\current\.cargo\bin\dev-prune.exe");
886 assert_eq!(Channel::detect_at(path, None), Channel::Scoop);
887 }
888
889 /// Volta, mise and Nix all install npm packages into a `node_modules` tree of their
890 /// own, so each of these paths matches npm's marker as well as its manager's. `Npm`
891 /// here meant `devp update` writing release bytes into a tree the real manager
892 /// versions — the foreign name has to win before the npm family is consulted.
893 #[test]
894 fn a_foreign_tree_wins_over_the_node_modules_inside_it() {
895 for (path, name) in [
896 (
897 "/home/k/.volta/tools/image/packages/dev-prune/lib/node_modules/dev-prune/bin/dev-prune",
898 "Volta",
899 ),
900 (
901 "/home/k/.local/share/mise/installs/npm-dev-prune/1.12.0/lib/node_modules/dev-prune/bin/dev-prune",
902 "mise",
903 ),
904 (
905 "/nix/store/abc123-dev-prune-1.12.0/lib/node_modules/dev-prune/bin/dev-prune",
906 "Nix",
907 ),
908 ] {
909 assert_eq!(
910 Channel::detect_at(Path::new(path), None),
911 Channel::Foreign(name),
912 "{path}"
913 );
914 }
915 }
916
917 /// The three managers that version their whole package directory are exactly the
918 /// three nothing durable may be written into. Asserted rather than assumed: adding a
919 /// channel without answering this question is how the orphaned-twin bug happened.
920 #[test]
921 fn exactly_the_versioned_directory_managers_replace_their_directory() {
922 let all = [
923 Channel::Installer,
924 Channel::Cargo,
925 Channel::Npm,
926 Channel::Bun,
927 Channel::Pnpm,
928 Channel::Yarn,
929 Channel::UvTool,
930 Channel::Pipx,
931 Channel::Pip,
932 Channel::WinGet,
933 Channel::Scoop,
934 Channel::Homebrew,
935 Channel::Unknown,
936 ];
937 let replacing: Vec<Channel> = all
938 .iter()
939 .copied()
940 .filter(|c| c.replaces_its_directory())
941 .collect();
942 assert_eq!(
943 replacing,
944 vec![Channel::WinGet, Channel::Scoop, Channel::Homebrew]
945 );
946 // Anything that replaces its directory is by definition manager-owned.
947 assert!(replacing.iter().all(|c| c.owns_its_files()));
948 }
949
950 /// The badge is printed one space after the version, so anything that reads as a
951 /// sentence fragment there is a bug in the banner rather than in the prose. A leading
952 /// article is how `label()` phrases itself for a sentence, and it is the thing that
953 /// looks wrong beside a version number.
954 #[test]
955 fn no_badge_reads_as_a_sentence_fragment() {
956 let all = [
957 Channel::Installer,
958 Channel::Cargo,
959 Channel::Npm,
960 Channel::Bun,
961 Channel::Pnpm,
962 Channel::Yarn,
963 Channel::UvTool,
964 Channel::Pipx,
965 Channel::Pip,
966 Channel::WinGet,
967 Channel::Scoop,
968 Channel::Homebrew,
969 Channel::Unknown,
970 Channel::Foreign("Nix"),
971 ];
972 for channel in all {
973 let badge = channel.badge();
974 assert!(!badge.is_empty(), "{channel:?} has no badge");
975 assert!(
976 !badge.starts_with("the ") && !badge.starts_with("an "),
977 "{channel:?} badges as {badge:?}, which is a clause"
978 );
979 assert!(
980 !badge.contains('\n'),
981 "{channel:?} badges across two lines: {badge:?}"
982 );
983 }
984 }
985
986 /// A hand-placed copy is the case the banner exists to name. `Unknown` is what
987 /// [`Channel::detect_at`] returns for a binary somebody downloaded from the releases
988 /// page and dropped somewhere, and every other thing dev-prune says about that copy —
989 /// that `devp update` has no manager to call, that `devp uninstall` may delete the
990 /// file outright — follows from it.
991 #[test]
992 fn a_downloaded_copy_badges_as_standalone() {
993 let dir = TempDir::new().unwrap();
994 let exe = dir.path().join("dev-prune.exe");
995 let channel = Channel::detect_at(&exe, None);
996 assert_eq!(channel, Channel::Unknown);
997 assert_eq!(channel.badge(), "standalone");
998 assert!(channel.upgrade_command().is_none());
999 assert!(channel.may_delete_directly());
1000 }
1001
1002 #[test]
1003 fn every_managed_channel_can_name_both_of_its_commands() {
1004 for channel in [
1005 Channel::Cargo,
1006 Channel::Npm,
1007 Channel::Bun,
1008 Channel::Pnpm,
1009 Channel::Yarn,
1010 Channel::UvTool,
1011 Channel::Pipx,
1012 Channel::Pip,
1013 Channel::WinGet,
1014 Channel::Scoop,
1015 Channel::Homebrew,
1016 ] {
1017 assert!(channel.upgrade_command().is_some(), "{channel:?}");
1018 assert!(channel.uninstall_command().is_some(), "{channel:?}");
1019 }
1020 // Each of the four npm-compatible clients has to name *its own* client. Getting
1021 // this wrong is not a cosmetic slip: it installs a second copy under a second
1022 // manager's prefix and leaves the first one stale and still on PATH.
1023 for (channel, client) in [
1024 (Channel::Npm, "npm"),
1025 (Channel::Bun, "bun"),
1026 (Channel::Pnpm, "pnpm"),
1027 (Channel::Yarn, "yarn"),
1028 ] {
1029 for command in [
1030 channel.upgrade_command().unwrap(),
1031 channel.uninstall_command().unwrap(),
1032 ] {
1033 assert!(
1034 command.starts_with(client),
1035 "{channel:?} names `{command}`, not {client}"
1036 );
1037 }
1038 }
1039 // The installer replaces its own copy and has no manager to uninstall through.
1040 assert!(Channel::Installer.upgrade_command().is_some());
1041 assert!(Channel::Installer.uninstall_command().is_none());
1042 assert!(Channel::Unknown.upgrade_command().is_none());
1043 assert!(Channel::Unknown.uninstall_command().is_none());
1044 }
1045
1046 #[test]
1047 fn install_dirs_cover_every_channel_that_installs_outside_path() {
1048 assert!(install_dirs(None).is_empty());
1049 let home = Path::new(if cfg!(windows) {
1050 "C:\\home\\u"
1051 } else {
1052 "/home/u"
1053 });
1054 let joined = install_dirs(Some(home))
1055 .iter()
1056 .map(|d| d.to_string_lossy().to_lowercase())
1057 .collect::<Vec<_>>()
1058 .join("|");
1059 // A copy nobody can see is a copy nobody upgrades, and it becomes the one that
1060 // runs the day PATH changes — so each of these is searched whether or not the
1061 // manager that owns it ever put itself on PATH.
1062 for marker in ["cargo", "uv", "pipx", "bun", "pnpm", "yarn"] {
1063 assert!(joined.contains(marker), "{marker} missing from {joined}");
1064 }
1065 let platform = if cfg!(windows) { "winget" } else { "homebrew" };
1066 assert!(
1067 joined.contains(platform),
1068 "{platform} missing from {joined}"
1069 );
1070 }
1071
1072 /// A virtual environment on disk: the interpreter beside the script, and the
1073 /// `pyvenv.cfg` one level up that no system-wide install has.
1074 fn make_project_venv(root: &Path, with_cfg: bool, with_python: bool) -> PathBuf {
1075 let venv = root.join("proj").join(".venv");
1076 let scripts = venv.join("bin");
1077 std::fs::create_dir_all(&scripts).unwrap();
1078 if with_python {
1079 std::fs::write(scripts.join("python"), "").unwrap();
1080 }
1081 if with_cfg {
1082 std::fs::write(venv.join("pyvenv.cfg"), "").unwrap();
1083 }
1084 let exe = scripts.join("devp");
1085 std::fs::write(&exe, "").unwrap();
1086 exe
1087 }
1088
1089 #[test]
1090 fn a_copy_inside_a_project_venv_is_recognised() {
1091 let dir = tempfile::tempdir().unwrap();
1092 let exe = make_project_venv(dir.path(), true, true);
1093 let found = project_venv_install(&exe).expect("a venv install");
1094 assert_eq!(found.venv, dir.path().join("proj").join(".venv"));
1095 assert_eq!(found.project, dir.path().join("proj"));
1096 }
1097
1098 #[test]
1099 fn a_machine_wide_pip_install_is_not_a_project_venv() {
1100 // An interpreter beside the script is not enough on its own: `/usr/bin` has one
1101 // too, and telling somebody their machine-wide install is in the wrong place is
1102 // both wrong and unfixable.
1103 let dir = tempfile::tempdir().unwrap();
1104 let exe = make_project_venv(dir.path(), false, true);
1105 assert!(project_venv_install(&exe).is_none());
1106 }
1107
1108 #[test]
1109 fn a_copy_with_no_interpreter_beside_it_is_not_a_venv_install() {
1110 let dir = tempfile::tempdir().unwrap();
1111 let exe = make_project_venv(dir.path(), true, false);
1112 assert!(project_venv_install(&exe).is_none());
1113 }
1114}