use crate::config::{Config, OutputFormat};
use crate::render::color;
use std::path::{Path, PathBuf};
use super::{fail_icon, is_interactive, prompt_yes_no, success_icon};
/// Binaries that are part of this package (installed together by cargo-dist).
const OUR_BINARIES: &[&str] = &["nd300", "speedqx"];
/// Tracks what we cleaned up for reporting.
pub(crate) struct CleanupReport {
/// True only when the binary is actually gone now (Unix `remove_file`, or a
/// non-running sibling delete). On Windows the *running* exe can't be deleted
/// in place, so this stays false even on a successful uninstall — see
/// `binary_removal_scheduled`.
pub(crate) binary_removed: bool,
/// Windows-only: the running exe couldn't be removed now, but a background
/// `cmd /C … del` was successfully spawned to delete it once this process
/// exits. Kept distinct from `binary_removed` so the updater's shadow-cleanup
/// guard can tell "already gone" from "scheduled for removal on exit" and not
/// be silently defeated by an optimistic "removed".
pub(crate) binary_removal_scheduled: bool,
pub(crate) sibling_removed: bool,
pub(crate) receipt_removed: bool,
pub(crate) path_cleaned: bool,
pub(crate) notes: Vec<String>,
}
pub async fn run(config: &Config) -> i32 {
let exe_path = match std::env::current_exe() {
Ok(p) => p,
Err(e) => {
if config.format == OutputFormat::Json {
let output = serde_json::json!({
"action": "uninstall",
"success": false,
"message": format!("Could not determine binary location: {}", e),
});
println!(
"{}",
serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
);
} else {
println!(
" {} {}",
color::red(fail_icon(config), config),
color::red(
&format!("Could not determine binary location: {}", e),
config
),
);
}
return 2;
}
};
// Resolve symlinks to get the real path
let real_path = match exe_path.canonicalize() {
Ok(p) => p,
Err(_) => exe_path.clone(),
};
if config.format == OutputFormat::Json {
return run_json(&real_path, config).await;
}
println!();
println!(
" This will remove nd300 and speedqx from: {}",
color::cyan(
&real_path
.parent()
.unwrap_or(&real_path)
.display()
.to_string(),
config
),
);
// Show what we'll clean up
let receipt_dir = get_receipt_dir();
if let Some(ref dir) = receipt_dir {
if dir.exists() {
println!(
" Config/receipt directory: {}",
color::cyan(&dir.display().to_string(), config),
);
}
}
#[cfg(windows)]
{
let bin_dir = real_path.parent().map(|p| p.to_path_buf());
if let Some(ref dir) = bin_dir {
if is_sole_package_in_dir(dir) {
println!(
" PATH entry to clean: {}",
color::cyan(&dir.display().to_string(), config),
);
}
}
}
println!();
if is_interactive(config) {
if !prompt_yes_no(" Are you sure you want to uninstall nd300 and speedqx? (y/N): ") {
println!(" Uninstall cancelled.");
return 0;
}
println!();
}
let report = uninstall_path(&real_path);
// Print results
if report.binary_removed {
print_ok("nd300 binary removed", config);
} else if report.binary_removal_scheduled {
// Windows: the running exe is deleted by the spawned helper once we exit.
print_ok(
"nd300 binary scheduled for removal (completes when this process exits)",
config,
);
} else {
print_fail("Failed to remove nd300 binary", config);
}
if report.sibling_removed {
print_ok("speedqx binary removed", config);
}
if report.receipt_removed {
print_ok("Install receipt cleaned up", config);
}
if report.path_cleaned {
print_ok("PATH entry removed", config);
}
for note in &report.notes {
println!(" {}", color::dim(note, config));
}
println!();
if report.binary_removed || report.binary_removal_scheduled {
println!(
" {} {}",
color::green(success_icon(config), config),
color::green("nd300 and speedqx have been uninstalled", config),
);
0
} else {
println!(
" {} {}",
color::red(fail_icon(config), config),
color::red("Uninstall incomplete — could not remove binary", config),
);
2
}
}
async fn run_json(exe_path: &Path, _config: &Config) -> i32 {
let report = uninstall_path(exe_path);
// `binary_removed` stays a literal "is it gone right now?" so existing
// scripts read the same field; `success` and the exit code key off the OR so
// a Windows scheduled-on-exit removal is reported as success.
let succeeded = report.binary_removed || report.binary_removal_scheduled;
let output = serde_json::json!({
"action": "uninstall",
"success": succeeded,
"binary_removed": report.binary_removed,
"binary_removal_scheduled": report.binary_removal_scheduled,
"sibling_removed": report.sibling_removed,
"receipt_removed": report.receipt_removed,
"path_cleaned": report.path_cleaned,
"notes": report.notes,
"path": exe_path.display().to_string(),
});
println!(
"{}",
serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
);
if succeeded {
0
} else {
2
}
}
pub(crate) fn uninstall_path(exe_path: &Path) -> CleanupReport {
let mut report = CleanupReport {
binary_removed: false,
binary_removal_scheduled: false,
sibling_removed: false,
receipt_removed: false,
path_cleaned: false,
notes: Vec::new(),
};
// Step 1: Remove the receipt/config directory
// This is safe to do first since it's nd300-specific
if let Some(receipt_dir) = get_receipt_dir() {
if receipt_dir.exists() {
match std::fs::remove_dir_all(&receipt_dir) {
Ok(_) => report.receipt_removed = true,
Err(e) => report.notes.push(format!(
"Could not remove receipt dir {}: {}",
receipt_dir.display(),
e
)),
}
}
}
// Step 2: Remove sibling binaries (speedqx) from the same directory
if let Some(bin_dir) = exe_path.parent() {
let exe_name = exe_path
.file_name()
.map(|n| n.to_string_lossy().to_lowercase())
.unwrap_or_default();
for name in OUR_BINARIES {
let sibling = if cfg!(windows) {
bin_dir.join(format!("{}.exe", name))
} else {
bin_dir.join(name)
};
let sibling_lower = sibling
.file_name()
.map(|n| n.to_string_lossy().to_lowercase())
.unwrap_or_default();
// Skip the current exe (handled separately in step 4)
if sibling_lower == exe_name {
continue;
}
if sibling.exists() {
match std::fs::remove_file(&sibling) {
Ok(_) => report.sibling_removed = true,
Err(e) => {
report
.notes
.push(format!("Could not remove {}: {}", sibling.display(), e))
}
}
}
}
}
// Step 3: Clean up PATH on Windows
// Only remove the bin dir from PATH if only our binaries were there
#[cfg(windows)]
{
let bin_dir = exe_path.parent().map(|p| p.to_path_buf());
if let Some(ref dir) = bin_dir {
if is_sole_package_in_dir(dir) {
match remove_from_user_path(dir) {
Ok(true) => report.path_cleaned = true,
Ok(false) => {} // wasn't in PATH, nothing to do
Err(e) => report.notes.push(format!("Could not clean PATH: {}", e)),
}
} else {
report.notes.push(
"Other binaries share the install directory — PATH entry left intact"
.to_string(),
);
}
}
}
// Step 4: Remove the binary itself (must be last)
#[cfg(unix)]
{
// On Unix, a running binary can be unlinked — the inode persists until exit
match std::fs::remove_file(exe_path) {
Ok(_) => report.binary_removed = true,
Err(e) => report.notes.push(format!("Failed to remove binary: {}", e)),
}
}
#[cfg(windows)]
{
// On Windows, a running exe cannot be deleted directly.
// Spawn a background cmd that waits briefly, then deletes the file.
// The file is NOT gone yet, so we set `binary_removal_scheduled` (not
// `binary_removed`) — the updater's shadow guard depends on the
// distinction.
match build_delayed_delete_command(exe_path) {
Some(script) => {
match std::process::Command::new("cmd")
.args(["/C", &script])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
Ok(_) => report.binary_removal_scheduled = true,
Err(e) => report
.notes
.push(format!("Failed to spawn cleanup process: {}", e)),
}
}
None => {
// The path contains characters that can't be safely embedded in a
// `cmd /C` string. Refuse rather than risk a malformed/dangerous
// command — neither flag is set, so this is reported as a failure.
report.notes.push(format!(
"Could not schedule binary removal: path contains characters unsafe for a Windows shell command: {}",
exe_path.display()
));
}
}
}
report
}
/// Build the `cmd /C` script that deletes `exe_path` shortly after the current
/// process exits (Windows can't delete a running exe in place).
///
/// Returns `None` if the path contains any character that is either illegal in a
/// real Windows path or unsafe to embed in a `cmd` command line — `"`, newline,
/// carriage return, or any cmd metacharacter (`& ^ | < >`). All of these are
/// already illegal in genuine Windows file paths, so `None` is an honest refusal
/// rather than a real limitation. `%` is escaped to `%%` so `cmd`'s
/// environment-variable expansion can't mangle a path containing a literal `%`.
#[cfg(windows)]
fn build_delayed_delete_command(exe_path: &Path) -> Option<String> {
let raw = exe_path.to_string_lossy();
// Reject anything that would break out of the quoted `del "..."` argument or
// that cmd would interpret as a metacharacter.
const FORBIDDEN: [char; 8] = ['"', '\n', '\r', '&', '^', '|', '<', '>'];
if raw.chars().any(|c| FORBIDDEN.contains(&c)) {
return None;
}
// Escape `%` so cmd doesn't try to expand `%VAR%`-style sequences in the path.
let safe = raw.replace('%', "%%");
Some(format!("ping localhost -n 3 > nul & del \"{}\"", safe))
}
fn print_ok(label: &str, config: &Config) {
println!(
" {} {}",
color::green(success_icon(config), config),
color::green(label, config),
);
}
fn print_fail(label: &str, config: &Config) {
println!(
" {} {}",
color::red(fail_icon(config), config),
color::red(label, config),
);
}
/// Get the cargo-dist receipt directory for nd300.
/// - Windows: %LOCALAPPDATA%\nd300
/// - macOS/Linux: ~/.config/nd300 (XDG_CONFIG_HOME respected)
fn get_receipt_dir() -> Option<PathBuf> {
#[cfg(windows)]
{
std::env::var("LOCALAPPDATA")
.ok()
.map(|base| PathBuf::from(base).join("nd300"))
}
#[cfg(not(windows))]
{
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| {
std::env::var("HOME")
.ok()
.map(|h| PathBuf::from(h).join(".config"))
});
base.map(|b| b.join("nd300"))
}
}
/// Check if only our package's binaries (nd300, speedqx) are in the given directory.
/// If other binaries are present (e.g. cargo, rustup), we must NOT remove the
/// directory from PATH — that would break the user's Rust toolchain.
#[cfg(windows)]
fn is_sole_package_in_dir(dir: &Path) -> bool {
let our_names: Vec<String> = OUR_BINARIES.iter().map(|n| format!("{}.exe", n)).collect();
match std::fs::read_dir(dir) {
Ok(entries) => {
let other_exes: Vec<_> = entries
.filter_map(|e| e.ok())
.filter(|e| {
let name = e.file_name().to_string_lossy().to_lowercase();
name.ends_with(".exe") && !our_names.contains(&name)
})
.collect();
other_exes.is_empty()
}
Err(_) => false,
}
}
/// True if a single PATH entry refers to the same directory as `target`,
/// ignoring surrounding whitespace, ASCII case, and a trailing `\` or `/`.
///
/// Only the comparison is normalized — callers keep the original (untrimmed)
/// slice for any entry they retain, so non-matching paths are never rewritten.
#[cfg(windows)]
fn path_entry_matches_target(entry: &str, target: &str) -> bool {
let norm = |s: &str| -> String { s.trim().trim_end_matches(['\\', '/']).to_lowercase() };
let entry_norm = norm(entry);
// An empty entry (e.g. a stray `;;`) never matches a real target dir.
!entry_norm.is_empty() && entry_norm == norm(target)
}
/// Remove a directory from the user-level PATH environment variable (Windows registry).
/// Returns Ok(true) if the entry was found and removed, Ok(false) if it wasn't in PATH.
#[cfg(windows)]
fn remove_from_user_path(dir_to_remove: &Path) -> Result<bool, String> {
use std::process::Command;
// Read current user PATH from registry
let output = Command::new("reg")
.args(["query", "HKCU\\Environment", "/v", "PATH"])
.output()
.map_err(|e| format!("Failed to query registry: {}", e))?;
let text = String::from_utf8_lossy(&output.stdout);
// Parse the PATH value and its registry type from reg query output
// Format: " PATH REG_EXPAND_SZ value"
let (current_path, reg_type) = match text.lines().find(|line| {
line.contains("PATH") && (line.contains("REG_EXPAND_SZ") || line.contains("REG_SZ"))
}) {
Some(line) => {
if let Some(idx) = line.find("REG_EXPAND_SZ") {
(
line[idx + "REG_EXPAND_SZ".len()..].trim().to_string(),
"REG_EXPAND_SZ",
)
} else if let Some(idx) = line.find("REG_SZ") {
(line[idx + "REG_SZ".len()..].trim().to_string(), "REG_SZ")
} else {
return Ok(false);
}
}
None => return Ok(false), // No user PATH set
};
let dir_str = dir_to_remove.to_string_lossy();
// Filter out the directory we want to remove. The comparison is
// case-insensitive AND trailing-slash-insensitive (mirroring `same_path` in
// update.rs) so a PATH entry with a trailing `\` or `/` still matches and is
// removed — but we keep the ORIGINAL (untrimmed) slices for any entries we
// retain, so we never rewrite paths we aren't removing.
let new_parts: Vec<&str> = current_path
.split(';')
.filter(|part| !path_entry_matches_target(part, &dir_str))
.filter(|part| !part.trim().is_empty())
.collect();
let original_count = current_path
.split(';')
.filter(|p| !p.trim().is_empty())
.count();
if new_parts.len() == original_count {
return Ok(false); // Directory wasn't in PATH
}
let new_path = new_parts.join(";");
// Write updated PATH back to registry
let status = Command::new("reg")
.args([
"add",
"HKCU\\Environment",
"/v",
"PATH",
"/t",
reg_type,
"/d",
&new_path,
"/f",
])
.output()
.map_err(|e| format!("Failed to update registry: {}", e))?;
if status.status.success() {
// Broadcast WM_SETTINGCHANGE so Explorer picks up the change
let _ = Command::new("powershell")
.args([
"-NoProfile",
"-Command",
"Add-Type -Namespace Win32 -Name NativeMethods -MemberDefinition '[DllImport(\"user32.dll\", SetLastError = true, CharSet = CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);'; $HWND_BROADCAST = [IntPtr]0xffff; $WM_SETTINGCHANGE = 0x1a; $result = [UIntPtr]::Zero; [Win32.NativeMethods]::SendMessageTimeout($HWND_BROADCAST, $WM_SETTINGCHANGE, [UIntPtr]::Zero, 'Environment', 2, 5000, [ref]$result)",
])
.output();
Ok(true)
} else {
Err("Failed to write updated PATH to registry".to_string())
}
}
#[cfg(test)]
mod tests {
#[cfg(windows)]
use super::*;
// ── L3: PATH-entry matching ignores case, whitespace, and trailing slash ──
#[cfg(windows)]
#[test]
fn path_entry_matches_target_variants() {
let target = r"C:\x\bin";
// Exact, trailing-backslash, trailing-forward-slash, mixed case, and
// surrounding whitespace all match.
assert!(path_entry_matches_target(r"C:\x\bin", target));
assert!(path_entry_matches_target(r"C:\x\bin\", target));
assert!(path_entry_matches_target("C:\\x\\bin/", target));
assert!(path_entry_matches_target(r"c:\X\BIN", target));
assert!(path_entry_matches_target(" C:\\x\\bin\\ ", target));
assert!(path_entry_matches_target(r"C:\x\bin//", target));
// A trailing slash on the TARGET side is normalized too.
assert!(path_entry_matches_target(r"C:\x\bin", r"C:\x\bin\"));
// A different (longer) directory must NOT match.
assert!(!path_entry_matches_target(r"C:\x\bingo", target));
assert!(!path_entry_matches_target(r"C:\x", target));
assert!(!path_entry_matches_target("", target));
assert!(!path_entry_matches_target(" ", target));
}
// ── L4: delayed-delete command is built safely or honestly refused ────────
#[cfg(windows)]
#[test]
fn build_delayed_delete_command_normal_path() {
let cmd = build_delayed_delete_command(Path::new(r"C:\Users\me\.cargo\bin\nd300.exe"))
.expect("a normal path yields a command");
assert!(cmd.contains(r#"del "C:\Users\me\.cargo\bin\nd300.exe""#));
assert!(cmd.starts_with("ping localhost -n 3 > nul & del \""));
}
#[cfg(windows)]
#[test]
fn build_delayed_delete_command_escapes_percent() {
let cmd = build_delayed_delete_command(Path::new(r"C:\weird%path\nd300.exe"))
.expect("a percent in a path is escaped, not refused");
assert!(cmd.contains(r"C:\weird%%path\nd300.exe"));
// The single literal percent must not survive un-escaped.
assert!(!cmd.contains(r"weird%path"));
}
#[cfg(windows)]
#[test]
fn build_delayed_delete_command_refuses_metacharacters() {
// Every cmd metacharacter / quote / newline yields a refusal (None).
for bad in [
r#"C:\a"b\nd300.exe"#,
r"C:\a&b\nd300.exe",
r"C:\a^b\nd300.exe",
r"C:\a|b\nd300.exe",
r"C:\a<b\nd300.exe",
r"C:\a>b\nd300.exe",
"C:\\a\nb\\nd300.exe",
"C:\\a\rb\\nd300.exe",
] {
assert!(
build_delayed_delete_command(Path::new(bad)).is_none(),
"expected refusal for path: {:?}",
bad
);
}
}
}