use std::path::{Path, PathBuf};
mod marker {
pub const WINGET: &[&str] = &["/microsoft/winget/packages/", "/winget/links/"];
pub const SCOOP: &[&str] = &["/scoop/apps/", "/scoop/shims/"];
pub const HOMEBREW: &[&str] = &["/cellar/", "/homebrew/", "/linuxbrew/"];
pub const CARGO: &[&str] = &["/.cargo/"];
pub const BUN: &[&str] = &["/.bun/"];
pub const PNPM: &[&str] = &["/pnpm/global/", "/.pnpm-global/"];
pub const YARN: &[&str] = &[
"/yarn/global/",
"/yarn/data/global/",
"/.yarn/bin/",
"/yarn/bin/",
];
pub const NPM: &[&str] = &["/node_modules/", "/_npx/"];
pub const UV_TOOL: &[&str] = &["/uv/tools/", "/uv-tool/"];
pub const PIPX: &[&str] = &["/pipx/"];
pub const FOREIGN: &[(&str, &str)] = &[
("/.deno/bin/", "Deno"),
("/.volta/bin/", "Volta"),
("/volta/tools/", "Volta"),
("/mise/shims/", "mise"),
("/mise/installs/", "mise"),
("/.asdf/shims/", "asdf"),
("/nix/store/", "Nix"),
("/usr/bin/", "the system package manager"),
];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Channel {
Installer,
Cargo,
Npm,
Bun,
Pnpm,
Yarn,
UvTool,
Pipx,
Pip,
WinGet,
Scoop,
Homebrew,
Unknown,
Foreign(&'static str),
}
impl Channel {
pub fn detect() -> Self {
let Ok(exe) = std::env::current_exe() else {
return Channel::Unknown;
};
let managed = crate::setup::managed_exe_path().ok();
Self::detect_at(&exe, managed.as_deref())
}
pub fn detect_at(exe: &Path, managed: Option<&Path>) -> Self {
if let Some(managed) = managed
&& exe == managed
{
return Channel::Installer;
}
let path = exe.to_string_lossy().replace('\\', "/").to_lowercase();
let any = |markers: &[&str]| markers.iter().any(|m| path.contains(m));
if any(marker::WINGET) {
Channel::WinGet
} else if any(marker::SCOOP) {
Channel::Scoop
} else if any(marker::HOMEBREW) {
Channel::Homebrew
} else if any(marker::CARGO) {
Channel::Cargo
} else if any(marker::BUN) {
Channel::Bun
} else if any(marker::PNPM) {
Channel::Pnpm
} else if any(marker::YARN) {
Channel::Yarn
} else if any(marker::NPM) {
Channel::Npm
} else if any(marker::UV_TOOL) {
Channel::UvTool
} else if any(marker::PIPX) {
Channel::Pipx
} else if let Some((_, name)) = marker::FOREIGN.iter().find(|(m, _)| path.contains(m)) {
Channel::Foreign(name)
} else if npm_shim_beside(exe) {
Channel::Npm
} else if pip_script_beside(exe) {
Channel::Pip
} else {
Channel::Unknown
}
}
pub fn label(self) -> &'static str {
match self {
Channel::Installer => "the install script",
Channel::Cargo => "cargo",
Channel::Npm => "npm",
Channel::Bun => "bun",
Channel::Pnpm => "pnpm",
Channel::Yarn => "yarn",
Channel::UvTool => "uv",
Channel::Pipx => "pipx",
Channel::Pip => "pip",
Channel::WinGet => "WinGet",
Channel::Scoop => "Scoop",
Channel::Homebrew => "Homebrew",
Channel::Unknown => "an unrecognised location",
Channel::Foreign(name) => name,
}
}
pub fn upgrade_command(self) -> Option<String> {
self.upgrade_argv().map(|argv| self.typed_form(&argv))
}
pub fn uninstall_command(self) -> Option<String> {
self.uninstall_argv().map(|argv| self.typed_form(&argv))
}
pub fn install_command(self) -> Option<String> {
self.install_argv().map(|argv| self.typed_form(&argv))
}
pub fn install_sources(self) -> Vec<Vec<String>> {
match self {
Channel::Scoop => vec![owned(&[
"scoop",
"bucket",
"add",
crate::constants::SCOOP_BUCKET_NAME,
crate::constants::SCOOP_BUCKET_URL,
])],
Channel::Homebrew => vec![owned(&["brew", "tap", crate::constants::HOMEBREW_TAP])],
_ => Vec::new(),
}
}
pub fn install_argv(self) -> Option<Vec<String>> {
Some(match self {
Channel::Cargo => {
if crate::adapters::binary_available("cargo-binstall") {
owned(&["cargo", "binstall", "dev-prune", "-y"])
} else {
owned(&["cargo", "install", "dev-prune"])
}
}
Channel::Npm => owned(&["npm", "install", "-g", "dev-prune"]),
Channel::Bun => owned(&["bun", "add", "-g", "dev-prune"]),
Channel::Pnpm => owned(&["pnpm", "add", "-g", "dev-prune"]),
Channel::Yarn => owned(&["yarn", "global", "add", "dev-prune"]),
Channel::UvTool => owned(&["uv", "tool", "install", "dev-prune@latest"]),
Channel::Pipx => owned(&["pipx", "install", "dev-prune"]),
Channel::WinGet => vec![
"winget".to_string(),
"install".to_string(),
"--id".to_string(),
crate::constants::WINGET_PACKAGE_ID.to_string(),
"--accept-package-agreements".to_string(),
"--accept-source-agreements".to_string(),
],
Channel::Scoop => owned(&["scoop", "install", "dev-prune"]),
Channel::Homebrew => owned(&["brew", "install", "dev-prune"]),
Channel::Installer => self.installer_argv(),
Channel::Pip | Channel::Unknown | Channel::Foreign(_) => return None,
})
}
pub fn upgrade_argv(self) -> Option<Vec<String>> {
Some(match self {
Channel::Cargo => {
if crate::adapters::binary_available("cargo-binstall") {
owned(&["cargo", "binstall", "dev-prune", "--force", "-y"])
} else {
owned(&["cargo", "install", "dev-prune", "--force"])
}
}
Channel::Npm => owned(&["npm", "install", "-g", "dev-prune@latest"]),
Channel::Bun => owned(&["bun", "add", "-g", "dev-prune@latest"]),
Channel::Pnpm => owned(&["pnpm", "add", "-g", "dev-prune@latest"]),
Channel::Yarn => owned(&["yarn", "global", "upgrade", "dev-prune"]),
Channel::UvTool => owned(&["uv", "tool", "upgrade", "dev-prune"]),
Channel::Pipx => owned(&["pipx", "upgrade", "dev-prune"]),
Channel::Pip => owned(&["pip", "install", "--upgrade", "dev-prune"]),
Channel::WinGet => vec![
"winget".to_string(),
"upgrade".to_string(),
"--id".to_string(),
crate::constants::WINGET_PACKAGE_ID.to_string(),
"--accept-package-agreements".to_string(),
"--accept-source-agreements".to_string(),
],
Channel::Scoop => owned(&["scoop", "update", "dev-prune"]),
Channel::Homebrew => owned(&["brew", "upgrade", "dev-prune"]),
Channel::Installer => self.installer_argv(),
Channel::Unknown | Channel::Foreign(_) => return None,
})
}
pub fn uninstall_argv(self) -> Option<Vec<String>> {
Some(match self {
Channel::Cargo => owned(&["cargo", "uninstall", "dev-prune"]),
Channel::Npm => owned(&["npm", "uninstall", "-g", "dev-prune"]),
Channel::Bun => owned(&["bun", "remove", "-g", "dev-prune"]),
Channel::Pnpm => owned(&["pnpm", "remove", "-g", "dev-prune"]),
Channel::Yarn => owned(&["yarn", "global", "remove", "dev-prune"]),
Channel::UvTool => owned(&["uv", "tool", "uninstall", "dev-prune"]),
Channel::Pipx => owned(&["pipx", "uninstall", "dev-prune"]),
Channel::Pip => owned(&["pip", "uninstall", "-y", "dev-prune"]),
Channel::WinGet => vec![
"winget".to_string(),
"uninstall".to_string(),
"--id".to_string(),
crate::constants::WINGET_PACKAGE_ID.to_string(),
],
Channel::Scoop => owned(&["scoop", "uninstall", "dev-prune"]),
Channel::Homebrew => owned(&["brew", "uninstall", "dev-prune"]),
Channel::Installer | Channel::Unknown | Channel::Foreign(_) => return None,
})
}
fn installer_argv(self) -> Vec<String> {
if cfg!(windows) {
vec![
"powershell".to_string(),
"-NoProfile".to_string(),
"-Command".to_string(),
format!("iwr -useb {} | iex", crate::constants::INSTALL_PS1_URL),
]
} else {
vec![
"sh".to_string(),
"-c".to_string(),
format!("curl -fsSL {} | sh", crate::constants::INSTALL_SH_URL),
]
}
}
fn typed_form(self, argv: &[String]) -> String {
if self == Channel::Installer {
return argv.last().cloned().unwrap_or_default();
}
argv.join(" ")
}
pub fn owns_its_files(self) -> bool {
!matches!(self, Channel::Installer | Channel::Unknown)
}
pub fn may_delete_directly(self) -> bool {
matches!(self, Channel::Installer | Channel::Unknown)
}
pub fn replaces_its_directory(self) -> bool {
matches!(self, Channel::WinGet | Channel::Scoop | Channel::Homebrew)
}
}
fn owned(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
fn npm_shim_beside(exe: &Path) -> bool {
exe.parent()
.is_some_and(|dir| dir.join("node_modules").join("dev-prune").exists())
}
fn pip_script_beside(exe: &Path) -> bool {
exe.parent().is_some_and(|dir| {
["python.exe", "python", "python3"]
.iter()
.any(|interpreter| dir.join(interpreter).exists())
})
}
pub struct ProjectVenvInstall {
pub venv: PathBuf,
pub project: PathBuf,
}
pub fn project_venv_install(exe: &Path) -> Option<ProjectVenvInstall> {
if !pip_script_beside(exe) {
return None;
}
let venv = exe.parent()?.parent()?;
if !venv.join("pyvenv.cfg").exists() {
return None;
}
Some(ProjectVenvInstall {
venv: venv.to_path_buf(),
project: venv.parent()?.to_path_buf(),
})
}
pub fn install_dirs(home: Option<&Path>) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = Vec::new();
let Some(home) = home else {
return dirs;
};
let scripts = if cfg!(windows) { "Scripts" } else { "bin" };
dirs.push(home.join(".cargo").join("bin"));
dirs.push(home.join(".local").join("bin"));
dirs.push(
home.join(".local")
.join("share")
.join("uv")
.join("tools")
.join("dev-prune")
.join(scripts),
);
dirs.push(
home.join(".local")
.join("pipx")
.join("venvs")
.join("dev-prune")
.join(scripts),
);
dirs.push(
home.join("pipx")
.join("venvs")
.join("dev-prune")
.join(scripts),
);
dirs.push(home.join(".bun").join("bin"));
if cfg!(windows) {
dirs.push(
home.join("AppData")
.join("Roaming")
.join("uv")
.join("tools")
.join("dev-prune")
.join(scripts),
);
dirs.push(home.join("AppData").join("Roaming").join("npm"));
dirs.push(
home.join("AppData")
.join("Local")
.join("Microsoft")
.join("WinGet")
.join("Links"),
);
dirs.push(home.join("scoop").join("shims"));
dirs.push(home.join("AppData").join("Local").join("pnpm"));
dirs.push(home.join("AppData").join("Local").join("Yarn").join("bin"));
} else {
dirs.push(home.join(".npm-global").join("bin"));
dirs.push(home.join(".local").join("share").join("pnpm"));
dirs.push(home.join(".yarn").join("bin"));
dirs.push(PathBuf::from("/opt/homebrew/bin"));
dirs.push(PathBuf::from("/usr/local/bin"));
dirs.push(PathBuf::from("/home/linuxbrew/.linuxbrew/bin"));
}
dirs
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn each_channel_is_recognised_by_its_marker_directory() {
let cases: &[(&str, Channel)] = &[
("/home/k/.cargo/bin/dev-prune", Channel::Cargo),
(
"/usr/lib/node_modules/dev-prune/bin/dev-prune",
Channel::Npm,
),
(
"/usr/lib/node_modules/dev-prune-linux-x64/bin/dev-prune",
Channel::Npm,
),
(
"/home/k/.bun/install/global/node_modules/@dev-prune/linux-x64/dev-prune",
Channel::Bun,
),
(
"/home/k/.local/share/pnpm/global/5/node_modules/@dev-prune/linux-x64/dev-prune",
Channel::Pnpm,
),
(
"/home/k/.config/yarn/global/node_modules/@dev-prune/linux-x64/dev-prune",
Channel::Yarn,
),
(
r"C:\Users\k\AppData\Local\pnpm\global\5\node_modules\@dev-prune\win32-x64\dev-prune.exe",
Channel::Pnpm,
),
(
r"C:\Users\k\AppData\Local\Yarn\Data\global\node_modules\@dev-prune\win32-x64\dev-prune.exe",
Channel::Yarn,
),
(
"/home/k/.local/share/uv/tools/dev-prune/bin/dev-prune",
Channel::UvTool,
),
(
"/home/k/.local/pipx/venvs/dev-prune/bin/dev-prune",
Channel::Pipx,
),
(
r"C:\Users\k\AppData\Local\Microsoft\WinGet\Packages\VKrishna04.dev-prune_x\dev-prune.exe",
Channel::WinGet,
),
(
r"C:\Users\k\scoop\apps\dev-prune\1.5.1\dev-prune.exe",
Channel::Scoop,
),
(
"/opt/homebrew/Cellar/dev-prune/1.5.1/bin/dev-prune",
Channel::Homebrew,
),
("/opt/somewhere/dev-prune", Channel::Unknown),
];
for (path, expected) in cases {
assert_eq!(
Channel::detect_at(Path::new(path), None),
*expected,
"{path}"
);
}
}
#[test]
fn a_managed_tree_with_no_known_commands_is_foreign_rather_than_unknown() {
for (path, name) in [
("/home/k/.deno/bin/dev-prune", "Deno"),
("/home/k/.volta/bin/dev-prune", "Volta"),
("/home/k/.local/share/mise/shims/dev-prune", "mise"),
("/usr/bin/dev-prune", "the system package manager"),
] {
assert_eq!(
Channel::detect_at(Path::new(path), None),
Channel::Foreign(name),
"{path} was not read as {name}'s"
);
}
}
#[test]
fn a_python_next_door_does_not_make_a_foreign_tree_pips() {
let tmp = TempDir::new().unwrap();
let shims = tmp.path().join(".asdf/shims");
std::fs::create_dir_all(&shims).unwrap();
std::fs::write(shims.join("python3"), "").unwrap();
std::fs::write(shims.join("python.exe"), "").unwrap();
let exe = shims.join("dev-prune");
std::fs::write(&exe, "").unwrap();
assert_eq!(Channel::detect_at(&exe, None), Channel::Foreign("asdf"));
std::fs::create_dir_all(shims.join("node_modules/dev-prune")).unwrap();
assert_eq!(Channel::detect_at(&exe, None), Channel::Foreign("asdf"));
let loose = tmp.path().join("bin");
std::fs::create_dir_all(loose.join("node_modules/dev-prune")).unwrap();
let exe = loose.join("dev-prune");
std::fs::write(&exe, "").unwrap();
assert_eq!(Channel::detect_at(&exe, None), Channel::Npm);
}
#[test]
fn usr_local_bin_stays_unclaimed() {
let tmp = TempDir::new().unwrap();
let bin = tmp.path().join("usr/local/bin");
std::fs::create_dir_all(&bin).unwrap();
assert_eq!(
Channel::detect_at(&bin.join("dev-prune"), None),
Channel::Unknown
);
}
#[test]
fn only_the_installer_and_an_unclaimed_copy_may_be_deleted_outright() {
for channel in [Channel::Installer, Channel::Unknown] {
assert!(channel.uninstall_argv().is_none());
assert!(channel.may_delete_directly(), "{channel:?}");
}
let foreign = Channel::Foreign("Deno");
assert!(foreign.uninstall_argv().is_none());
assert!(!foreign.may_delete_directly());
assert!(foreign.install_argv().is_none());
assert!(foreign.upgrade_argv().is_none());
}
#[test]
fn the_managed_copy_is_the_installer_channel() {
let managed = Path::new("/home/k/.cargo/odd/dev-prune/bin/dev-prune");
assert_eq!(
Channel::detect_at(managed, Some(managed)),
Channel::Installer
);
}
#[test]
fn a_directory_owning_manager_wins_over_a_nested_marker() {
let path = Path::new(r"C:\Users\k\scoop\apps\rust\current\.cargo\bin\dev-prune.exe");
assert_eq!(Channel::detect_at(path, None), Channel::Scoop);
}
#[test]
fn exactly_the_versioned_directory_managers_replace_their_directory() {
let all = [
Channel::Installer,
Channel::Cargo,
Channel::Npm,
Channel::Bun,
Channel::Pnpm,
Channel::Yarn,
Channel::UvTool,
Channel::Pipx,
Channel::Pip,
Channel::WinGet,
Channel::Scoop,
Channel::Homebrew,
Channel::Unknown,
];
let replacing: Vec<Channel> = all
.iter()
.copied()
.filter(|c| c.replaces_its_directory())
.collect();
assert_eq!(
replacing,
vec![Channel::WinGet, Channel::Scoop, Channel::Homebrew]
);
assert!(replacing.iter().all(|c| c.owns_its_files()));
}
#[test]
fn every_managed_channel_can_name_both_of_its_commands() {
for channel in [
Channel::Cargo,
Channel::Npm,
Channel::Bun,
Channel::Pnpm,
Channel::Yarn,
Channel::UvTool,
Channel::Pipx,
Channel::Pip,
Channel::WinGet,
Channel::Scoop,
Channel::Homebrew,
] {
assert!(channel.upgrade_command().is_some(), "{channel:?}");
assert!(channel.uninstall_command().is_some(), "{channel:?}");
}
for (channel, client) in [
(Channel::Npm, "npm"),
(Channel::Bun, "bun"),
(Channel::Pnpm, "pnpm"),
(Channel::Yarn, "yarn"),
] {
for command in [
channel.upgrade_command().unwrap(),
channel.uninstall_command().unwrap(),
] {
assert!(
command.starts_with(client),
"{channel:?} names `{command}`, not {client}"
);
}
}
assert!(Channel::Installer.upgrade_command().is_some());
assert!(Channel::Installer.uninstall_command().is_none());
assert!(Channel::Unknown.upgrade_command().is_none());
assert!(Channel::Unknown.uninstall_command().is_none());
}
#[test]
fn install_dirs_cover_every_channel_that_installs_outside_path() {
assert!(install_dirs(None).is_empty());
let home = Path::new(if cfg!(windows) {
"C:\\home\\u"
} else {
"/home/u"
});
let joined = install_dirs(Some(home))
.iter()
.map(|d| d.to_string_lossy().to_lowercase())
.collect::<Vec<_>>()
.join("|");
for marker in ["cargo", "uv", "pipx", "bun", "pnpm", "yarn"] {
assert!(joined.contains(marker), "{marker} missing from {joined}");
}
let platform = if cfg!(windows) { "winget" } else { "homebrew" };
assert!(
joined.contains(platform),
"{platform} missing from {joined}"
);
}
fn make_project_venv(root: &Path, with_cfg: bool, with_python: bool) -> PathBuf {
let venv = root.join("proj").join(".venv");
let scripts = venv.join("bin");
std::fs::create_dir_all(&scripts).unwrap();
if with_python {
std::fs::write(scripts.join("python"), "").unwrap();
}
if with_cfg {
std::fs::write(venv.join("pyvenv.cfg"), "").unwrap();
}
let exe = scripts.join("devp");
std::fs::write(&exe, "").unwrap();
exe
}
#[test]
fn a_copy_inside_a_project_venv_is_recognised() {
let dir = tempfile::tempdir().unwrap();
let exe = make_project_venv(dir.path(), true, true);
let found = project_venv_install(&exe).expect("a venv install");
assert_eq!(found.venv, dir.path().join("proj").join(".venv"));
assert_eq!(found.project, dir.path().join("proj"));
}
#[test]
fn a_machine_wide_pip_install_is_not_a_project_venv() {
let dir = tempfile::tempdir().unwrap();
let exe = make_project_venv(dir.path(), false, true);
assert!(project_venv_install(&exe).is_none());
}
#[test]
fn a_copy_with_no_interpreter_beside_it_is_not_a_venv_install() {
let dir = tempfile::tempdir().unwrap();
let exe = make_project_venv(dir.path(), true, false);
assert!(project_venv_install(&exe).is_none());
}
}