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    pub const NPM: &[&str] = &["/node_modules/", "/_npx/"];
35    pub const UV_TOOL: &[&str] = &["/uv/tools/", "/uv-tool/"];
36    pub const PIPX: &[&str] = &["/pipx/"];
37}
38
39/// The package manager that owns the running binary.
40///
41/// One channel owns one binary. A copy installed through uv is upgraded through uv,
42/// never through npm, because two managers writing the same PATH entry would fight over
43/// it forever.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Channel {
46    /// `install.sh` / `install.ps1` put it under the managed `<config>/bin`.
47    Installer,
48    /// `cargo install` / `cargo binstall` put it under `~/.cargo/bin`.
49    Cargo,
50    /// `npm install -g` — the binary lives under a `node_modules` tree.
51    Npm,
52    /// `uv tool install` — under uv's tool environments.
53    UvTool,
54    /// `pipx install` — under a `pipx` venv.
55    Pipx,
56    /// `pip install` — a console script beside a Python interpreter, in the system
57    /// scripts directory or a virtualenv's.
58    Pip,
59    /// `winget install` — under `%LOCALAPPDATA%\Microsoft\WinGet\Packages`.
60    WinGet,
61    /// `scoop install` — under `~/scoop/apps`, shimmed from `~/scoop/shims`.
62    Scoop,
63    /// `brew install` — under the Cellar, symlinked into the prefix's `bin`.
64    Homebrew,
65    /// Anywhere else: a dev build, a hand-copied binary, a distro package.
66    Unknown,
67}
68
69impl Channel {
70    /// Classify the running executable.
71    pub fn detect() -> Self {
72        let Ok(exe) = std::env::current_exe() else {
73            return Channel::Unknown;
74        };
75        let managed = crate::setup::managed_exe_path().ok();
76        Self::detect_at(&exe, managed.as_deref())
77    }
78
79    /// Classify `exe` by the directories in its path.
80    ///
81    /// Purely lexical: this must not touch the network or spawn anything, and each
82    /// channel's layout is stable enough that its marker directory is a reliable
83    /// fingerprint. `managed` is passed in rather than resolved here so tests can probe
84    /// the classification without a config directory on disk.
85    ///
86    /// The managed path is checked first and the three directory-owning managers next.
87    /// Order is load-bearing at least once already: a Scoop install of a Rust toolchain
88    /// can put `.cargo` inside `~/scoop`, and misreading that as `Cargo` would send
89    /// `devp update` to run `cargo install` against a directory Scoop replaces wholesale.
90    pub fn detect_at(exe: &Path, managed: Option<&Path>) -> Self {
91        if let Some(managed) = managed
92            && exe == managed
93        {
94            return Channel::Installer;
95        }
96        let path = exe.to_string_lossy().replace('\\', "/").to_lowercase();
97        let any = |markers: &[&str]| markers.iter().any(|m| path.contains(m));
98
99        if any(marker::WINGET) {
100            Channel::WinGet
101        } else if any(marker::SCOOP) {
102            Channel::Scoop
103        } else if any(marker::HOMEBREW) {
104            Channel::Homebrew
105        } else if any(marker::CARGO) {
106            Channel::Cargo
107        } else if any(marker::NPM) || npm_shim_beside(exe) {
108            Channel::Npm
109        } else if any(marker::UV_TOOL) {
110            Channel::UvTool
111        } else if any(marker::PIPX) {
112            Channel::Pipx
113        } else if pip_script_beside(exe) {
114            Channel::Pip
115        } else {
116            Channel::Unknown
117        }
118    }
119
120    /// How to name this channel in a sentence addressed to the user.
121    pub fn label(self) -> &'static str {
122        match self {
123            Channel::Installer => "the install script",
124            Channel::Cargo => "cargo",
125            Channel::Npm => "npm",
126            Channel::UvTool => "uv",
127            Channel::Pipx => "pipx",
128            Channel::Pip => "pip",
129            Channel::WinGet => "WinGet",
130            Channel::Scoop => "Scoop",
131            Channel::Homebrew => "Homebrew",
132            Channel::Unknown => "an unrecognised location",
133        }
134    }
135
136    /// The command that upgrades through this channel, as the user would type it.
137    ///
138    /// `None` for [`Channel::Unknown`] only: there is no command to name for a binary
139    /// somebody copied into place by hand.
140    pub fn upgrade_command(self) -> Option<String> {
141        Some(
142            match self {
143                // The one channel whose command is not a fixed string: it is the install
144                // one-liner, and its URL has a single source of truth in `constants`.
145                Channel::Installer => {
146                    return Some(if cfg!(windows) {
147                        format!("iwr -useb {} | iex", crate::constants::INSTALL_PS1_URL)
148                    } else {
149                        format!("curl -fsSL {} | sh", crate::constants::INSTALL_SH_URL)
150                    });
151                }
152                Channel::Cargo => "cargo install dev-prune --force",
153                Channel::Npm => "npm install -g dev-prune@latest",
154                Channel::UvTool => "uv tool upgrade dev-prune",
155                Channel::Pipx => "pipx upgrade dev-prune",
156                Channel::Pip => "pip install --upgrade dev-prune",
157                Channel::WinGet => {
158                    return Some(format!(
159                        "winget upgrade {}",
160                        crate::constants::WINGET_PACKAGE_ID
161                    ));
162                }
163                Channel::Scoop => "scoop update dev-prune",
164                Channel::Homebrew => "brew upgrade dev-prune",
165                Channel::Unknown => return None,
166            }
167            .to_string(),
168        )
169    }
170
171    /// The command that uninstalls through this channel.
172    ///
173    /// `None` where there is no manager to tell: the installer's own copy is deleted by
174    /// `devp uninstall` itself, and an unrecognised copy is just a file.
175    pub fn uninstall_command(self) -> Option<String> {
176        Some(
177            match self {
178                Channel::Cargo => "cargo uninstall dev-prune",
179                Channel::Npm => "npm uninstall -g dev-prune",
180                Channel::UvTool => "uv tool uninstall dev-prune",
181                Channel::Pipx => "pipx uninstall dev-prune",
182                Channel::Pip => "pip uninstall dev-prune",
183                Channel::WinGet => {
184                    return Some(format!(
185                        "winget uninstall {}",
186                        crate::constants::WINGET_PACKAGE_ID
187                    ));
188                }
189                Channel::Scoop => "scoop uninstall dev-prune",
190                Channel::Homebrew => "brew uninstall dev-prune",
191                Channel::Installer | Channel::Unknown => return None,
192            }
193            .to_string(),
194        )
195    }
196
197    /// Whether a package manager keeps a record of this install that deleting the file
198    /// would falsify.
199    ///
200    /// When true, `devp uninstall` names the manager's own command instead of quietly
201    /// removing the file: `pip list` still showing a package whose binary is gone, or
202    /// `cargo install` refusing to reinstall over its own bookkeeping, is worse than a
203    /// leftover binary the user was told about.
204    pub fn owns_its_files(self) -> bool {
205        !matches!(self, Channel::Installer | Channel::Unknown)
206    }
207
208    /// Whether this channel replaces its install *directory* wholesale on upgrade.
209    ///
210    /// This is the distinction the old per-command classifiers did not have, and the one
211    /// that caused a real bug. WinGet, Scoop and Homebrew each version their package
212    /// directory and swap the whole thing — `…\WinGet\Packages\<id>\`, `~/scoop/apps/
213    /// <pkg>/<version>/`, `<prefix>/Cellar/<pkg>/<version>/`. Anything dev-prune writes
214    /// beside its own executable there is gone at the next upgrade, and anything
215    /// *pointing* at it — a scheduled task, a git hook — is left aimed at a path that no
216    /// longer exists.
217    ///
218    /// So nothing durable is ever written into one of these directories. The `devp`
219    /// twin goes to the managed `<config>/bin` instead, which this program owns and
220    /// which no package manager will replace underneath it.
221    pub fn replaces_its_directory(self) -> bool {
222        matches!(self, Channel::WinGet | Channel::Scoop | Channel::Homebrew)
223    }
224}
225
226/// npm's global shims sit *beside* its `node_modules`, not inside it, so the path alone
227/// does not identify them.
228fn npm_shim_beside(exe: &Path) -> bool {
229    exe.parent()
230        .is_some_and(|dir| dir.join("node_modules").join("dev-prune").exists())
231}
232
233/// pip puts console scripts beside the interpreter that installed them — a system
234/// `Scripts`/`bin` directory or a virtualenv's — and there is no marker in the path to
235/// say so. The interpreter next door is the only evidence there is.
236///
237/// Checked last, after uv and pipx: both of those are pip installs underneath, and both
238/// have an interpreter beside the script. Their own markers must win, or `devp
239/// uninstall` would tell a pipx user to run `pip uninstall` inside a venv they do not
240/// know exists.
241fn pip_script_beside(exe: &Path) -> bool {
242    exe.parent().is_some_and(|dir| {
243        ["python.exe", "python", "python3"]
244            .iter()
245            .any(|interpreter| dir.join(interpreter).exists())
246    })
247}
248
249/// This binary, running from inside a project's own virtual environment.
250///
251/// The distinction that matters is not "was this installed by pip" — a machine-wide
252/// `pip install` is a perfectly good way to get the tool. It is "does this copy live
253/// inside one project's environment", because such a copy dies with the environment,
254/// and until it does it is a package that project's `requirements.txt` has to account
255/// for before the environment can ever be pruned.
256///
257/// `pyvenv.cfg` one directory above the script is what separates the two: every virtual
258/// environment has one and no system install does.
259pub struct ProjectVenvInstall {
260    /// The environment root — the directory holding `pyvenv.cfg`.
261    pub venv: PathBuf,
262    /// The directory the environment sits in, which is the project in every layout
263    /// anyone actually uses.
264    pub project: PathBuf,
265}
266
267/// Detect a [`ProjectVenvInstall`] for `exe`, or `None` if this copy lives anywhere else.
268///
269/// Takes the executable rather than reading `current_exe` so the detection can be tested
270/// against a directory tree instead of against whichever machine runs the suite.
271pub fn project_venv_install(exe: &Path) -> Option<ProjectVenvInstall> {
272    if !pip_script_beside(exe) {
273        return None;
274    }
275    let venv = exe.parent()?.parent()?;
276    if !venv.join("pyvenv.cfg").exists() {
277        return None;
278    }
279    Some(ProjectVenvInstall {
280        venv: venv.to_path_buf(),
281        project: venv.parent()?.to_path_buf(),
282    })
283}
284
285/// Every fixed directory a channel installs into, whether or not it is on `PATH`.
286///
287/// Shared by `devp doctor` (which reports copies running a different version) and `devp
288/// uninstall` (which offers to sweep them up). They looked in different places before
289/// this was one list, which meant doctor could report a stale copy that uninstall would
290/// then fail to find.
291///
292/// Non-existent entries are included; callers filter. `home` is passed in so the list
293/// can be tested without a home directory full of package managers.
294pub fn install_dirs(home: Option<&Path>) -> Vec<PathBuf> {
295    let mut dirs: Vec<PathBuf> = Vec::new();
296    let Some(home) = home else {
297        return dirs;
298    };
299    // `bin` on unix, `Scripts` on Windows — the same venv layout under both uv and
300    // pipx, and the reason a Windows uv copy is missed by a unix-shaped guess.
301    let scripts = if cfg!(windows) { "Scripts" } else { "bin" };
302
303    dirs.push(home.join(".cargo").join("bin"));
304    dirs.push(home.join(".local").join("bin"));
305    dirs.push(
306        home.join(".local")
307            .join("share")
308            .join("uv")
309            .join("tools")
310            .join("dev-prune")
311            .join(scripts),
312    );
313    dirs.push(
314        home.join(".local")
315            .join("pipx")
316            .join("venvs")
317            .join("dev-prune")
318            .join(scripts),
319    );
320    dirs.push(
321        home.join("pipx")
322            .join("venvs")
323            .join("dev-prune")
324            .join(scripts),
325    );
326
327    if cfg!(windows) {
328        // uv keeps its tool environments under `%APPDATA%` on Windows, which is not
329        // under `.local` at all.
330        dirs.push(
331            home.join("AppData")
332                .join("Roaming")
333                .join("uv")
334                .join("tools")
335                .join("dev-prune")
336                .join(scripts),
337        );
338        dirs.push(home.join("AppData").join("Roaming").join("npm"));
339        dirs.push(
340            home.join("AppData")
341                .join("Local")
342                .join("Microsoft")
343                .join("WinGet")
344                .join("Links"),
345        );
346        dirs.push(home.join("scoop").join("shims"));
347    } else {
348        dirs.push(home.join(".npm-global").join("bin"));
349        dirs.push(PathBuf::from("/opt/homebrew/bin"));
350        dirs.push(PathBuf::from("/usr/local/bin"));
351        dirs.push(PathBuf::from("/home/linuxbrew/.linuxbrew/bin"));
352    }
353    dirs
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    #[test]
361    fn each_channel_is_recognised_by_its_marker_directory() {
362        let cases: &[(&str, Channel)] = &[
363            ("/home/k/.cargo/bin/dev-prune", Channel::Cargo),
364            (
365                "/usr/lib/node_modules/dev-prune/bin/dev-prune",
366                Channel::Npm,
367            ),
368            // The platform package, which is where the executable npm actually runs
369            // lives. `devp doctor` suppresses its missing-twin warning on the strength
370            // of this: npm ships the second name as a launcher of its own, so there is
371            // no file to look for beside this one.
372            (
373                "/usr/lib/node_modules/dev-prune-linux-x64/bin/dev-prune",
374                Channel::Npm,
375            ),
376            (
377                "/home/k/.local/share/uv/tools/dev-prune/bin/dev-prune",
378                Channel::UvTool,
379            ),
380            (
381                "/home/k/.local/pipx/venvs/dev-prune/bin/dev-prune",
382                Channel::Pipx,
383            ),
384            (
385                r"C:\Users\k\AppData\Local\Microsoft\WinGet\Packages\VKrishna04.dev-prune_x\dev-prune.exe",
386                Channel::WinGet,
387            ),
388            (
389                r"C:\Users\k\scoop\apps\dev-prune\1.5.1\dev-prune.exe",
390                Channel::Scoop,
391            ),
392            (
393                "/opt/homebrew/Cellar/dev-prune/1.5.1/bin/dev-prune",
394                Channel::Homebrew,
395            ),
396            ("/opt/somewhere/dev-prune", Channel::Unknown),
397        ];
398        for (path, expected) in cases {
399            assert_eq!(
400                Channel::detect_at(Path::new(path), None),
401                *expected,
402                "{path}"
403            );
404        }
405    }
406
407    #[test]
408    fn the_managed_copy_is_the_installer_channel() {
409        // Even a managed directory that happens to live under `.cargo` is the
410        // installer's — the managed path is an identity, not a heuristic.
411        let managed = Path::new("/home/k/.cargo/odd/dev-prune/bin/dev-prune");
412        assert_eq!(
413            Channel::detect_at(managed, Some(managed)),
414            Channel::Installer
415        );
416    }
417
418    /// A Rust toolchain installed through Scoop puts `.cargo` under `~/scoop`. Reading
419    /// that as `Cargo` would send an upgrade to `cargo install` against a directory
420    /// Scoop replaces wholesale, so the directory-owning managers are tested first.
421    #[test]
422    fn a_directory_owning_manager_wins_over_a_nested_marker() {
423        let path = Path::new(r"C:\Users\k\scoop\apps\rust\current\.cargo\bin\dev-prune.exe");
424        assert_eq!(Channel::detect_at(path, None), Channel::Scoop);
425    }
426
427    /// The three managers that version their whole package directory are exactly the
428    /// three nothing durable may be written into. Asserted rather than assumed: adding a
429    /// channel without answering this question is how the orphaned-twin bug happened.
430    #[test]
431    fn exactly_the_versioned_directory_managers_replace_their_directory() {
432        let all = [
433            Channel::Installer,
434            Channel::Cargo,
435            Channel::Npm,
436            Channel::UvTool,
437            Channel::Pipx,
438            Channel::Pip,
439            Channel::WinGet,
440            Channel::Scoop,
441            Channel::Homebrew,
442            Channel::Unknown,
443        ];
444        let replacing: Vec<Channel> = all
445            .iter()
446            .copied()
447            .filter(|c| c.replaces_its_directory())
448            .collect();
449        assert_eq!(
450            replacing,
451            vec![Channel::WinGet, Channel::Scoop, Channel::Homebrew]
452        );
453        // Anything that replaces its directory is by definition manager-owned.
454        assert!(replacing.iter().all(|c| c.owns_its_files()));
455    }
456
457    #[test]
458    fn every_managed_channel_can_name_both_of_its_commands() {
459        for channel in [
460            Channel::Cargo,
461            Channel::Npm,
462            Channel::UvTool,
463            Channel::Pipx,
464            Channel::Pip,
465            Channel::WinGet,
466            Channel::Scoop,
467            Channel::Homebrew,
468        ] {
469            assert!(channel.upgrade_command().is_some(), "{channel:?}");
470            assert!(channel.uninstall_command().is_some(), "{channel:?}");
471        }
472        // The installer replaces its own copy and has no manager to uninstall through.
473        assert!(Channel::Installer.upgrade_command().is_some());
474        assert!(Channel::Installer.uninstall_command().is_none());
475        assert!(Channel::Unknown.upgrade_command().is_none());
476        assert!(Channel::Unknown.uninstall_command().is_none());
477    }
478
479    #[test]
480    fn install_dirs_cover_every_channel_that_installs_outside_path() {
481        assert!(install_dirs(None).is_empty());
482        let home = Path::new(if cfg!(windows) {
483            "C:\\home\\u"
484        } else {
485            "/home/u"
486        });
487        let joined = install_dirs(Some(home))
488            .iter()
489            .map(|d| d.to_string_lossy().to_lowercase())
490            .collect::<Vec<_>>()
491            .join("|");
492        // A copy nobody can see is a copy nobody upgrades, and it becomes the one that
493        // runs the day PATH changes — so each of these is searched whether or not the
494        // manager that owns it ever put itself on PATH.
495        for marker in ["cargo", "uv", "pipx"] {
496            assert!(joined.contains(marker), "{marker} missing from {joined}");
497        }
498        let platform = if cfg!(windows) { "winget" } else { "homebrew" };
499        assert!(
500            joined.contains(platform),
501            "{platform} missing from {joined}"
502        );
503    }
504
505    /// A virtual environment on disk: the interpreter beside the script, and the
506    /// `pyvenv.cfg` one level up that no system-wide install has.
507    fn make_project_venv(root: &Path, with_cfg: bool, with_python: bool) -> PathBuf {
508        let venv = root.join("proj").join(".venv");
509        let scripts = venv.join("bin");
510        std::fs::create_dir_all(&scripts).unwrap();
511        if with_python {
512            std::fs::write(scripts.join("python"), "").unwrap();
513        }
514        if with_cfg {
515            std::fs::write(venv.join("pyvenv.cfg"), "").unwrap();
516        }
517        let exe = scripts.join("devp");
518        std::fs::write(&exe, "").unwrap();
519        exe
520    }
521
522    #[test]
523    fn a_copy_inside_a_project_venv_is_recognised() {
524        let dir = tempfile::tempdir().unwrap();
525        let exe = make_project_venv(dir.path(), true, true);
526        let found = project_venv_install(&exe).expect("a venv install");
527        assert_eq!(found.venv, dir.path().join("proj").join(".venv"));
528        assert_eq!(found.project, dir.path().join("proj"));
529    }
530
531    #[test]
532    fn a_machine_wide_pip_install_is_not_a_project_venv() {
533        // An interpreter beside the script is not enough on its own: `/usr/bin` has one
534        // too, and telling somebody their machine-wide install is in the wrong place is
535        // both wrong and unfixable.
536        let dir = tempfile::tempdir().unwrap();
537        let exe = make_project_venv(dir.path(), false, true);
538        assert!(project_venv_install(&exe).is_none());
539    }
540
541    #[test]
542    fn a_copy_with_no_interpreter_beside_it_is_not_a_venv_install() {
543        let dir = tempfile::tempdir().unwrap();
544        let exe = make_project_venv(dir.path(), true, false);
545        assert!(project_venv_install(&exe).is_none());
546    }
547}