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