Skip to main content

dev_prune/
channel.rs

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