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