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