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/// Every fixed directory a channel installs into, whether or not it is on `PATH`.
250///
251/// Shared by `devp doctor` (which reports copies running a different version) and `devp
252/// uninstall` (which offers to sweep them up). They looked in different places before
253/// this was one list, which meant doctor could report a stale copy that uninstall would
254/// then fail to find.
255///
256/// Non-existent entries are included; callers filter. `home` is passed in so the list
257/// can be tested without a home directory full of package managers.
258pub fn install_dirs(home: Option<&Path>) -> Vec<PathBuf> {
259 let mut dirs: Vec<PathBuf> = Vec::new();
260 let Some(home) = home else {
261 return dirs;
262 };
263 // `bin` on unix, `Scripts` on Windows — the same venv layout under both uv and
264 // pipx, and the reason a Windows uv copy is missed by a unix-shaped guess.
265 let scripts = if cfg!(windows) { "Scripts" } else { "bin" };
266
267 dirs.push(home.join(".cargo").join("bin"));
268 dirs.push(home.join(".local").join("bin"));
269 dirs.push(
270 home.join(".local")
271 .join("share")
272 .join("uv")
273 .join("tools")
274 .join("dev-prune")
275 .join(scripts),
276 );
277 dirs.push(
278 home.join(".local")
279 .join("pipx")
280 .join("venvs")
281 .join("dev-prune")
282 .join(scripts),
283 );
284 dirs.push(
285 home.join("pipx")
286 .join("venvs")
287 .join("dev-prune")
288 .join(scripts),
289 );
290
291 if cfg!(windows) {
292 // uv keeps its tool environments under `%APPDATA%` on Windows, which is not
293 // under `.local` at all.
294 dirs.push(
295 home.join("AppData")
296 .join("Roaming")
297 .join("uv")
298 .join("tools")
299 .join("dev-prune")
300 .join(scripts),
301 );
302 dirs.push(home.join("AppData").join("Roaming").join("npm"));
303 dirs.push(
304 home.join("AppData")
305 .join("Local")
306 .join("Microsoft")
307 .join("WinGet")
308 .join("Links"),
309 );
310 dirs.push(home.join("scoop").join("shims"));
311 } else {
312 dirs.push(home.join(".npm-global").join("bin"));
313 dirs.push(PathBuf::from("/opt/homebrew/bin"));
314 dirs.push(PathBuf::from("/usr/local/bin"));
315 dirs.push(PathBuf::from("/home/linuxbrew/.linuxbrew/bin"));
316 }
317 dirs
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn each_channel_is_recognised_by_its_marker_directory() {
326 let cases: &[(&str, Channel)] = &[
327 ("/home/k/.cargo/bin/dev-prune", Channel::Cargo),
328 (
329 "/usr/lib/node_modules/dev-prune/bin/dev-prune",
330 Channel::Npm,
331 ),
332 (
333 "/home/k/.local/share/uv/tools/dev-prune/bin/dev-prune",
334 Channel::UvTool,
335 ),
336 (
337 "/home/k/.local/pipx/venvs/dev-prune/bin/dev-prune",
338 Channel::Pipx,
339 ),
340 (
341 r"C:\Users\k\AppData\Local\Microsoft\WinGet\Packages\VKrishna04.dev-prune_x\dev-prune.exe",
342 Channel::WinGet,
343 ),
344 (
345 r"C:\Users\k\scoop\apps\dev-prune\1.5.1\dev-prune.exe",
346 Channel::Scoop,
347 ),
348 (
349 "/opt/homebrew/Cellar/dev-prune/1.5.1/bin/dev-prune",
350 Channel::Homebrew,
351 ),
352 ("/opt/somewhere/dev-prune", Channel::Unknown),
353 ];
354 for (path, expected) in cases {
355 assert_eq!(
356 Channel::detect_at(Path::new(path), None),
357 *expected,
358 "{path}"
359 );
360 }
361 }
362
363 #[test]
364 fn the_managed_copy_is_the_installer_channel() {
365 // Even a managed directory that happens to live under `.cargo` is the
366 // installer's — the managed path is an identity, not a heuristic.
367 let managed = Path::new("/home/k/.cargo/odd/dev-prune/bin/dev-prune");
368 assert_eq!(
369 Channel::detect_at(managed, Some(managed)),
370 Channel::Installer
371 );
372 }
373
374 /// A Rust toolchain installed through Scoop puts `.cargo` under `~/scoop`. Reading
375 /// that as `Cargo` would send an upgrade to `cargo install` against a directory
376 /// Scoop replaces wholesale, so the directory-owning managers are tested first.
377 #[test]
378 fn a_directory_owning_manager_wins_over_a_nested_marker() {
379 let path = Path::new(r"C:\Users\k\scoop\apps\rust\current\.cargo\bin\dev-prune.exe");
380 assert_eq!(Channel::detect_at(path, None), Channel::Scoop);
381 }
382
383 /// The three managers that version their whole package directory are exactly the
384 /// three nothing durable may be written into. Asserted rather than assumed: adding a
385 /// channel without answering this question is how the orphaned-twin bug happened.
386 #[test]
387 fn exactly_the_versioned_directory_managers_replace_their_directory() {
388 let all = [
389 Channel::Installer,
390 Channel::Cargo,
391 Channel::Npm,
392 Channel::UvTool,
393 Channel::Pipx,
394 Channel::Pip,
395 Channel::WinGet,
396 Channel::Scoop,
397 Channel::Homebrew,
398 Channel::Unknown,
399 ];
400 let replacing: Vec<Channel> = all
401 .iter()
402 .copied()
403 .filter(|c| c.replaces_its_directory())
404 .collect();
405 assert_eq!(
406 replacing,
407 vec![Channel::WinGet, Channel::Scoop, Channel::Homebrew]
408 );
409 // Anything that replaces its directory is by definition manager-owned.
410 assert!(replacing.iter().all(|c| c.owns_its_files()));
411 }
412
413 #[test]
414 fn every_managed_channel_can_name_both_of_its_commands() {
415 for channel in [
416 Channel::Cargo,
417 Channel::Npm,
418 Channel::UvTool,
419 Channel::Pipx,
420 Channel::Pip,
421 Channel::WinGet,
422 Channel::Scoop,
423 Channel::Homebrew,
424 ] {
425 assert!(channel.upgrade_command().is_some(), "{channel:?}");
426 assert!(channel.uninstall_command().is_some(), "{channel:?}");
427 }
428 // The installer replaces its own copy and has no manager to uninstall through.
429 assert!(Channel::Installer.upgrade_command().is_some());
430 assert!(Channel::Installer.uninstall_command().is_none());
431 assert!(Channel::Unknown.upgrade_command().is_none());
432 assert!(Channel::Unknown.uninstall_command().is_none());
433 }
434
435 #[test]
436 fn install_dirs_cover_every_channel_that_installs_outside_path() {
437 assert!(install_dirs(None).is_empty());
438 let home = Path::new(if cfg!(windows) {
439 "C:\\home\\u"
440 } else {
441 "/home/u"
442 });
443 let joined = install_dirs(Some(home))
444 .iter()
445 .map(|d| d.to_string_lossy().to_lowercase())
446 .collect::<Vec<_>>()
447 .join("|");
448 // A copy nobody can see is a copy nobody upgrades, and it becomes the one that
449 // runs the day PATH changes — so each of these is searched whether or not the
450 // manager that owns it ever put itself on PATH.
451 for marker in ["cargo", "uv", "pipx"] {
452 assert!(joined.contains(marker), "{marker} missing from {joined}");
453 }
454 let platform = if cfg!(windows) { "winget" } else { "homebrew" };
455 assert!(
456 joined.contains(platform),
457 "{platform} missing from {joined}"
458 );
459 }
460}