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 NPM: &[&str] = &["/node_modules/", "/_npx/"];
pub const UV_TOOL: &[&str] = &["/uv/tools/", "/uv-tool/"];
pub const PIPX: &[&str] = &["/pipx/"];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Channel {
Installer,
Cargo,
Npm,
UvTool,
Pipx,
Pip,
WinGet,
Scoop,
Homebrew,
Unknown,
}
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::NPM) || npm_shim_beside(exe) {
Channel::Npm
} else if any(marker::UV_TOOL) {
Channel::UvTool
} else if any(marker::PIPX) {
Channel::Pipx
} 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::UvTool => "uv",
Channel::Pipx => "pipx",
Channel::Pip => "pip",
Channel::WinGet => "WinGet",
Channel::Scoop => "Scoop",
Channel::Homebrew => "Homebrew",
Channel::Unknown => "an unrecognised location",
}
}
pub fn upgrade_command(self) -> Option<String> {
Some(
match self {
Channel::Installer => {
return Some(if cfg!(windows) {
format!("iwr -useb {} | iex", crate::constants::INSTALL_PS1_URL)
} else {
format!("curl -fsSL {} | sh", crate::constants::INSTALL_SH_URL)
});
}
Channel::Cargo => "cargo install dev-prune --force",
Channel::Npm => "npm install -g dev-prune@latest",
Channel::UvTool => "uv tool upgrade dev-prune",
Channel::Pipx => "pipx upgrade dev-prune",
Channel::Pip => "pip install --upgrade dev-prune",
Channel::WinGet => {
return Some(format!(
"winget upgrade {}",
crate::constants::WINGET_PACKAGE_ID
));
}
Channel::Scoop => "scoop update dev-prune",
Channel::Homebrew => "brew upgrade dev-prune",
Channel::Unknown => return None,
}
.to_string(),
)
}
pub fn uninstall_command(self) -> Option<String> {
Some(
match self {
Channel::Cargo => "cargo uninstall dev-prune",
Channel::Npm => "npm uninstall -g dev-prune",
Channel::UvTool => "uv tool uninstall dev-prune",
Channel::Pipx => "pipx uninstall dev-prune",
Channel::Pip => "pip uninstall dev-prune",
Channel::WinGet => {
return Some(format!(
"winget uninstall {}",
crate::constants::WINGET_PACKAGE_ID
));
}
Channel::Scoop => "scoop uninstall dev-prune",
Channel::Homebrew => "brew uninstall dev-prune",
Channel::Installer | Channel::Unknown => return None,
}
.to_string(),
)
}
pub fn owns_its_files(self) -> bool {
!matches!(self, Channel::Installer | Channel::Unknown)
}
pub fn replaces_its_directory(self) -> bool {
matches!(self, Channel::WinGet | Channel::Scoop | Channel::Homebrew)
}
}
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 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),
);
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"));
} else {
dirs.push(home.join(".npm-global").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::*;
#[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,
),
(
"/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 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::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::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:?}");
}
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"] {
assert!(joined.contains(marker), "{marker} missing from {joined}");
}
let platform = if cfg!(windows) { "winget" } else { "homebrew" };
assert!(
joined.contains(platform),
"{platform} missing from {joined}"
);
}
}