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