Skip to main content

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}