use crate::config::Config;
use std::path::{Path, PathBuf};
use crate::cli::MigrateArgs;
#[cfg(windows)]
use crate::cli::MigrateInstallOrigin;
#[cfg(windows)]
use super::uninstall::spawn_delayed_delete;
#[cfg(windows)]
use super::uninstall::uninstall_path_files_only;
use super::uninstall::OUR_BINARIES;
#[cfg(windows)]
use super::update::{
cargo_bin_dir, classify_shadow_cleanup, current_exe_real_path,
current_install_shadows_cargo_install, detect_install_origin_for_path, same_path,
InstallOrigin, ShadowCleanupDecision,
};
#[cfg_attr(not(windows), allow(dead_code))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum TargetOutcome {
Removed,
Scheduled,
WouldRemove,
Skipped(String),
NeedsAdmin(String),
Failed(String),
}
#[derive(Debug, Clone)]
pub(crate) struct TargetReport {
pub(crate) id: &'static str,
pub(crate) label: String,
pub(crate) path: Option<PathBuf>,
pub(crate) outcome: TargetOutcome,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct CleanupTargets {
pub(crate) cargo_copy: bool,
pub(crate) other_edition: bool,
pub(crate) retired_update: bool,
}
pub(crate) fn resolve_targets(
cargo_copy: bool,
other_edition: bool,
retired_update: bool,
) -> CleanupTargets {
if !cargo_copy && !other_edition && !retired_update {
CleanupTargets {
cargo_copy: true,
other_edition: false,
retired_update: false,
}
} else {
CleanupTargets {
cargo_copy,
other_edition,
retired_update,
}
}
}
#[cfg_attr(not(windows), allow(dead_code))]
pub(crate) fn is_permission_error(kind: std::io::ErrorKind) -> bool {
matches!(kind, std::io::ErrorKind::PermissionDenied)
}
pub async fn run(config: &Config, args: MigrateArgs) -> i32 {
let targets = resolve_targets(args.cargo_copy, args.other_edition, args.retired_update);
let json = args.json || matches!(config.format, crate::config::OutputFormat::Json);
let reports = collect_and_execute(&args, targets).await;
let internal_error = reports
.iter()
.any(|r| matches!(&r.outcome, TargetOutcome::Failed(m) if m == INTERNAL_ERROR_MARKER));
if json {
print_json(&reports, &targets, args.dry_run);
} else if !args.quiet {
print_human(&reports, config, args.dry_run);
}
if internal_error {
2
} else {
0
}
}
const INTERNAL_ERROR_MARKER: &str = "__internal_error__";
#[cfg(windows)]
async fn collect_and_execute(args: &MigrateArgs, targets: CleanupTargets) -> Vec<TargetReport> {
let mut reports = Vec::new();
let Some(running) = current_exe_real_path() else {
reports.push(TargetReport {
id: "internal",
label: "determine running install location".to_string(),
path: None,
outcome: TargetOutcome::Failed(INTERNAL_ERROR_MARKER.to_string()),
});
return reports;
};
let running_dir = running.parent().map(|p| p.to_path_buf());
if targets.cargo_copy {
reports.push(execute_cargo_copy(args, &running, running_dir.as_deref()));
}
if targets.other_edition {
reports.push(execute_other_edition(
args,
&running,
running_dir.as_deref(),
));
}
if targets.retired_update {
reports.push(execute_retired_update_images(args, running_dir.as_deref()));
}
reports
}
#[cfg(target_os = "linux")]
async fn collect_and_execute(_args: &MigrateArgs, targets: CleanupTargets) -> Vec<TargetReport> {
let mut reports = Vec::new();
if targets.cargo_copy {
reports.push(TargetReport {
id: "cargo_copy",
label: "older cargo copy".to_string(),
path: None,
outcome: TargetOutcome::Skipped(
"not applicable on this platform (single install location)".to_string(),
),
});
}
if targets.other_edition {
reports.push(TargetReport {
id: "other_edition",
label: "other edition".to_string(),
path: None,
outcome: TargetOutcome::Skipped(
"not applicable on this platform (no Global/Corporate editions)".to_string(),
),
});
}
if targets.retired_update {
reports.push(TargetReport {
id: "retired_update",
label: "retired Windows update images".to_string(),
path: None,
outcome: TargetOutcome::Skipped(
"not applicable on this platform (Windows update compatibility only)".to_string(),
),
});
}
reports
}
#[cfg(target_os = "macos")]
async fn collect_and_execute(args: &MigrateArgs, targets: CleanupTargets) -> Vec<TargetReport> {
let mut reports = Vec::new();
if targets.cargo_copy {
reports.push(execute_macos_cargo_copy(args).await);
}
if targets.other_edition {
reports.push(TargetReport {
id: "other_edition",
label: "other edition".to_string(),
path: None,
outcome: TargetOutcome::Skipped(
"not applicable on macOS (no Global/Corporate editions)".to_string(),
),
});
}
if targets.retired_update {
reports.push(TargetReport {
id: "retired_update",
label: "retired Windows update images".to_string(),
path: None,
outcome: TargetOutcome::Skipped(
"not applicable on macOS (Unix replaces running binaries atomically)".to_string(),
),
});
}
reports
}
#[cfg(target_os = "macos")]
async fn execute_macos_cargo_copy(args: &MigrateArgs) -> TargetReport {
use super::unix_install::{
cargo_dist_receipt_valid, cargo_package_registered, cargo_uninstall,
uninstall_managed_archive, UnixInstallOrigin, UnixOriginKind,
};
use crate::cli::MigrateInstallOrigin;
use crate::platform::invoking_user::InvokingUser;
let id = "cargo_copy";
let label = "older Cargo/archive pair".to_string();
if args.install_origin != Some(MigrateInstallOrigin::MacosPkg) {
return TargetReport {
id,
label,
path: None,
outcome: TargetOutcome::Skipped(
"macOS cross-location cleanup is installer-only and requires --install-origin macos-pkg"
.to_string(),
),
};
}
match std::env::current_exe().and_then(|path| path.canonicalize()) {
Ok(path)
if path == Path::new("/usr/local/bin/nd300")
|| path == Path::new("/usr/local/bin/speedqx") =>
{
}
Ok(path) => {
return TargetReport {
id,
label,
path: Some(path),
outcome: TargetOutcome::Skipped(
"installer-declared macOS package origin does not match /usr/local/bin"
.to_string(),
),
};
}
Err(error) => {
return TargetReport {
id,
label,
path: None,
outcome: TargetOutcome::Failed(format!(
"{INTERNAL_ERROR_MARKER}: resolve installed package path: {error}"
)),
};
}
};
let user = match InvokingUser::detect() {
Ok(user) => user,
Err(error) => {
return TargetReport {
id,
label,
path: None,
outcome: TargetOutcome::Failed(format!("identify console user: {error}")),
};
}
};
let candidate = user.cargo_home().join("bin").join("nd300");
if !candidate.exists() {
return TargetReport {
id,
label,
path: None,
outcome: TargetOutcome::Skipped("no older Cargo/archive pair is present".to_string()),
};
}
let cargo_owned = cargo_package_registered(&user.cargo_home());
let archive_owned = cargo_dist_receipt_valid(&user);
let result = match (cargo_owned, archive_owned) {
(true, false) => cargo_uninstall(&user).await,
(false, true) => Ok(uninstall_managed_archive(
&user,
&UnixInstallOrigin {
kind: UnixOriginKind::ManagedArchive,
executable: candidate.clone(),
invocation_symlink: None,
},
)),
(true, true) => {
return TargetReport {
id,
label,
path: Some(candidate),
outcome: TargetOutcome::Skipped(
"candidate has conflicting Cargo and cargo-dist ownership evidence".to_string(),
),
};
}
(false, false) => {
return TargetReport {
id,
label,
path: Some(candidate),
outcome: TargetOutcome::Skipped(
"candidate pair has no exact Cargo registration or validated cargo-dist receipt"
.to_string(),
),
};
}
};
match result {
Ok(report)
if report.binary_removed
&& report.sibling_removed
&& !report.sibling_removal_failed =>
{
TargetReport {
id,
label,
path: Some(candidate),
outcome: TargetOutcome::Removed,
}
}
Ok(report) => TargetReport {
id,
label,
path: Some(candidate),
outcome: TargetOutcome::Failed(report.notes.join("; ")),
},
Err(error) => TargetReport {
id,
label,
path: Some(candidate),
outcome: TargetOutcome::Failed(error),
},
}
}
#[cfg(windows)]
fn resolve_cargo_bin_dir(args: &MigrateArgs) -> Option<PathBuf> {
if let Some(home) = &args.cargo_home {
return Some(PathBuf::from(home).join("bin"));
}
if let Some(profile) = &args.user_profile {
return Some(PathBuf::from(profile).join(".cargo").join("bin"));
}
cargo_bin_dir()
}
#[cfg(windows)]
fn execute_cargo_copy(
args: &MigrateArgs,
running: &Path,
running_dir: Option<&Path>,
) -> TargetReport {
let id = "cargo_copy";
let label = "older cargo copy".to_string();
let Some(cargo_bin) = resolve_cargo_bin_dir(args) else {
return TargetReport {
id,
label,
path: None,
outcome: TargetOutcome::Skipped("could not locate a .cargo\\bin directory".to_string()),
};
};
if let Some(rd) = running_dir {
if same_path(rd, &cargo_bin) {
return TargetReport {
id,
label,
path: None,
outcome: TargetOutcome::Skipped(
"the running install is the cargo copy — preserving it".to_string(),
),
};
}
}
if !current_install_shadows_cargo_install(running, &cargo_bin) {
return TargetReport {
id,
label,
path: None,
outcome: TargetOutcome::Skipped(
"running install does not shadow a cargo copy (nothing to consolidate)".to_string(),
),
};
}
let cargo_exe = cargo_bin.join("nd300.exe");
if !cargo_exe.exists() {
return TargetReport {
id,
label,
path: None,
outcome: TargetOutcome::Skipped("no cargo copy present".to_string()),
};
}
delete_target(id, label, &cargo_exe, args.dry_run)
}
#[cfg(windows)]
fn migrate_install_origin(origin: MigrateInstallOrigin) -> InstallOrigin {
match origin {
MigrateInstallOrigin::MsiGlobal => InstallOrigin::MsiGlobal,
MigrateInstallOrigin::MsiCorporate => InstallOrigin::MsiCorporate,
MigrateInstallOrigin::ExeGlobal => InstallOrigin::ExeGlobal,
MigrateInstallOrigin::ExeCorporate => InstallOrigin::ExeCorporate,
MigrateInstallOrigin::MacosPkg => InstallOrigin::Unknown,
}
}
#[cfg(windows)]
fn edition_bin_dirs(args: &MigrateArgs) -> (Option<PathBuf>, Option<PathBuf>) {
let global =
std::env::var_os("ProgramFiles").map(|pf| PathBuf::from(pf).join("nd300").join("bin"));
let corporate = if let Some(profile) = &args.user_profile {
Some(
PathBuf::from(profile)
.join("AppData")
.join("Local")
.join("Programs")
.join("nd300")
.join("bin"),
)
} else {
std::env::var_os("LOCALAPPDATA")
.map(|la| PathBuf::from(la).join("Programs").join("nd300").join("bin"))
};
(global, corporate)
}
#[cfg(windows)]
fn execute_other_edition(
args: &MigrateArgs,
running: &Path,
running_dir: Option<&Path>,
) -> TargetReport {
let id = "other_edition";
let label = "other edition (Global/Corporate)".to_string();
let (global_bin, corporate_bin) = edition_bin_dirs(args);
let running_origin = args
.install_origin
.map(migrate_install_origin)
.unwrap_or_else(|| detect_install_origin_for_path(running));
let other_bin: Option<PathBuf> = match running_origin {
InstallOrigin::MsiGlobal | InstallOrigin::ExeGlobal => corporate_bin.clone(),
InstallOrigin::MsiCorporate | InstallOrigin::ExeCorporate => global_bin.clone(),
InstallOrigin::CargoOrInstaller | InstallOrigin::Unknown => None,
};
let Some(other_bin) = other_bin else {
return TargetReport {
id,
label,
path: None,
outcome: TargetOutcome::Skipped(
"running install is not a known Windows edition — cannot determine the other edition"
.to_string(),
),
};
};
if let Some(rd) = running_dir {
if same_path(rd, &other_bin) {
return TargetReport {
id,
label,
path: None,
outcome: TargetOutcome::Skipped(
"computed 'other edition' equals the running edition — preserving it"
.to_string(),
),
};
}
}
let other_exe = other_bin.join("nd300.exe");
if !other_exe.exists() {
return TargetReport {
id,
label,
path: None,
outcome: TargetOutcome::Skipped("other edition not installed".to_string()),
};
}
delete_target(id, label, &other_exe, args.dry_run)
}
#[cfg(windows)]
fn delete_target(id: &'static str, label: String, exe: &Path, dry_run: bool) -> TargetReport {
if !is_allowlisted(exe) {
return TargetReport {
id,
label,
path: Some(exe.to_path_buf()),
outcome: TargetOutcome::Skipped(
"refusing: filename is not in the nd300/speedqx allowlist".to_string(),
),
};
}
if dry_run {
return TargetReport {
id,
label,
path: Some(exe.to_path_buf()),
outcome: TargetOutcome::WouldRemove,
};
}
if let Some(reason) = needs_admin_for(exe) {
return TargetReport {
id,
label,
path: Some(exe.to_path_buf()),
outcome: TargetOutcome::NeedsAdmin(reason),
};
}
let report = uninstall_path_files_only(exe);
let outcome = match classify_shadow_cleanup(&report) {
ShadowCleanupDecision::Removed => TargetOutcome::Removed,
ShadowCleanupDecision::Scheduled => TargetOutcome::Scheduled,
ShadowCleanupDecision::NotRemoved => {
let notes = report.notes.join("; ");
if notes.to_lowercase().contains("denied")
|| notes.to_lowercase().contains("permission")
{
TargetOutcome::NeedsAdmin(if notes.is_empty() {
exe.display().to_string()
} else {
notes
})
} else {
TargetOutcome::Failed(if notes.is_empty() {
"could not remove (no additional detail)".to_string()
} else {
notes
})
}
}
};
TargetReport {
id,
label,
path: Some(exe.to_path_buf()),
outcome,
}
}
#[cfg_attr(not(windows), allow(dead_code))]
pub(crate) fn is_allowlisted(exe: &Path) -> bool {
let name = exe
.file_name()
.map(|n| n.to_string_lossy().to_lowercase())
.unwrap_or_default();
OUR_BINARIES
.iter()
.any(|b| name == format!("{}.exe", b) || name == *b)
}
#[cfg(windows)]
fn needs_admin_for(exe: &Path) -> Option<String> {
use std::fs::OpenOptions;
match OpenOptions::new().write(true).open(exe) {
Ok(_) => None,
Err(e) if is_permission_error(e.kind()) => Some(format!(
"{} (perUser process cannot delete a perMachine copy — re-run elevated to remove it)",
exe.display()
)),
Err(_) => None,
}
}
#[cfg(any(windows, test))]
pub(crate) fn is_retired_update_image(path: &Path) -> bool {
let name = path
.file_name()
.map(|value| value.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
["nd300.update-old-", "speedqx.update-old-"]
.iter()
.find_map(|prefix| name.strip_prefix(prefix))
.and_then(|suffix| suffix.strip_suffix(".exe"))
.is_some_and(|version| {
!version.is_empty()
&& version
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '+' | '_'))
})
}
#[cfg(windows)]
fn execute_retired_update_images(args: &MigrateArgs, running_dir: Option<&Path>) -> TargetReport {
let id = "retired_update";
let label = "retired Windows update images".to_string();
let Some(dir) = running_dir else {
return TargetReport {
id,
label,
path: None,
outcome: TargetOutcome::Failed(INTERNAL_ERROR_MARKER.to_string()),
};
};
let mut search_dirs = vec![dir.to_path_buf()];
if let Some(program_files) = std::env::var_os("ProgramFiles") {
let legacy = PathBuf::from(program_files).join("nd-300").join("bin");
if !search_dirs
.iter()
.any(|candidate| same_path(candidate, &legacy))
{
search_dirs.push(legacy);
}
}
let mut retired = Vec::new();
let mut inspection_failures = Vec::new();
for search_dir in &search_dirs {
let entries = match std::fs::read_dir(search_dir) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => {
inspection_failures.push(format!("{}: {error}", search_dir.display()));
continue;
}
};
retired.extend(
entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.is_file() && is_retired_update_image(path)),
);
}
if !inspection_failures.is_empty() {
return TargetReport {
id,
label,
path: Some(dir.to_path_buf()),
outcome: TargetOutcome::Failed(format!(
"could not inspect install directories: {}",
inspection_failures.join("; ")
)),
};
}
if retired.is_empty() {
return TargetReport {
id,
label,
path: Some(dir.to_path_buf()),
outcome: TargetOutcome::Skipped("no retired update images found".to_string()),
};
}
if args.dry_run {
return TargetReport {
id,
label,
path: Some(dir.to_path_buf()),
outcome: TargetOutcome::WouldRemove,
};
}
let mut scheduled = false;
let mut failures = Vec::new();
for path in retired {
match std::fs::remove_file(&path) {
Ok(()) => {}
Err(_) => match spawn_delayed_delete(&path) {
Ok(()) => scheduled = true,
Err(error) => failures.push(format!("{}: {error}", path.display())),
},
}
}
let outcome = if failures.is_empty() {
if scheduled {
TargetOutcome::Scheduled
} else {
TargetOutcome::Removed
}
} else {
TargetOutcome::Failed(failures.join("; "))
};
TargetReport {
id,
label,
path: Some(dir.to_path_buf()),
outcome,
}
}
fn outcome_word(outcome: &TargetOutcome) -> String {
match outcome {
TargetOutcome::Removed => "removed".to_string(),
TargetOutcome::Scheduled => "scheduled for removal on exit".to_string(),
TargetOutcome::WouldRemove => "would remove (dry-run)".to_string(),
TargetOutcome::Skipped(r) => format!("skipped: {}", r),
TargetOutcome::NeedsAdmin(p) => format!("needs admin: {}", p),
TargetOutcome::Failed(m) => format!("failed: {}", m),
}
}
fn outcome_json_status(outcome: &TargetOutcome) -> &'static str {
match outcome {
TargetOutcome::Removed => "removed",
TargetOutcome::Scheduled => "scheduled",
TargetOutcome::WouldRemove => "would_remove",
TargetOutcome::Skipped(_) => "skipped",
TargetOutcome::NeedsAdmin(_) => "needs_admin",
TargetOutcome::Failed(_) => "failed",
}
}
fn print_human(reports: &[TargetReport], config: &Config, dry_run: bool) {
use crate::render::color;
println!();
let header = if dry_run {
"Install consolidation (dry-run — nothing will be deleted):"
} else {
"Install consolidation:"
};
println!(" {}", color::cyan(header, config));
for r in reports {
let line = match &r.path {
Some(p) => format!(
"{} — {} [{}]",
r.label,
outcome_word(&r.outcome),
p.display()
),
None => format!("{} — {}", r.label, outcome_word(&r.outcome)),
};
let colored = match &r.outcome {
TargetOutcome::Removed | TargetOutcome::Scheduled | TargetOutcome::WouldRemove => {
color::green(&line, config)
}
TargetOutcome::NeedsAdmin(_) | TargetOutcome::Failed(_) => color::yellow(&line, config),
TargetOutcome::Skipped(_) => color::dim(&line, config),
};
println!(" {} {}", color::dim("·", config), colored);
}
println!();
}
fn print_json(reports: &[TargetReport], targets: &CleanupTargets, dry_run: bool) {
let targets_json: Vec<serde_json::Value> = reports
.iter()
.map(|r| {
serde_json::json!({
"id": r.id,
"label": r.label,
"status": outcome_json_status(&r.outcome),
"detail": match &r.outcome {
TargetOutcome::Skipped(s)
| TargetOutcome::NeedsAdmin(s)
| TargetOutcome::Failed(s) => Some(s.clone()),
_ => None,
},
"path": r.path.as_ref().map(|p| p.display().to_string()),
})
})
.collect();
let output = serde_json::json!({
"action": "migrate-cleanup",
"dry_run": dry_run,
"requested": {
"cargo_copy": targets.cargo_copy,
"other_edition": targets.other_edition,
"retired_update": targets.retired_update,
},
"targets": targets_json,
"success": !reports.iter().any(|r| matches!(
&r.outcome,
TargetOutcome::Failed(m) if m == INTERNAL_ERROR_MARKER
)),
});
println!(
"{}",
serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
);
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn no_flag_defaults_to_cargo_only() {
let t = resolve_targets(false, false, false);
assert!(t.cargo_copy, "default must clean the cargo copy");
assert!(!t.other_edition, "default must NOT touch the other edition");
assert!(!t.retired_update, "default must NOT touch retired images");
}
#[test]
fn explicit_flags_are_respected() {
assert_eq!(
resolve_targets(true, false, false),
CleanupTargets {
cargo_copy: true,
other_edition: false,
retired_update: false,
}
);
assert_eq!(
resolve_targets(false, true, false),
CleanupTargets {
cargo_copy: false,
other_edition: true,
retired_update: false,
}
);
assert_eq!(
resolve_targets(true, true, false),
CleanupTargets {
cargo_copy: true,
other_edition: true,
retired_update: false,
}
);
assert_eq!(
resolve_targets(false, false, true),
CleanupTargets {
cargo_copy: false,
other_edition: false,
retired_update: true,
}
);
}
#[test]
fn retired_update_allowlist_is_exact_and_versioned() {
assert!(is_retired_update_image(Path::new(
"nd300.update-old-3.6.3.exe"
)));
assert!(is_retired_update_image(Path::new(
"SPEEDQX.UPDATE-OLD-3.6.3-RC.1.EXE"
)));
assert!(!is_retired_update_image(Path::new("nd300.exe")));
assert!(!is_retired_update_image(Path::new("nd300.update-old-.exe")));
assert!(!is_retired_update_image(Path::new("nd300-old.exe")));
assert!(!is_retired_update_image(Path::new(
"cargo.update-old-3.6.3.exe"
)));
assert!(!is_retired_update_image(Path::new(
"nd300.update-old-..\\cargo.exe"
)));
}
#[test]
fn allowlist_accepts_only_our_binaries() {
assert!(is_allowlisted(Path::new("nd300.exe")));
assert!(is_allowlisted(Path::new("speedqx.exe")));
assert!(is_allowlisted(Path::new("/home/me/.cargo/bin/nd300")));
assert!(is_allowlisted(Path::new("/home/me/.cargo/bin/speedqx")));
#[cfg(windows)]
{
assert!(is_allowlisted(Path::new(
r"C:\Users\me\.cargo\bin\nd300.exe"
)));
assert!(is_allowlisted(Path::new(
r"C:\Program Files\nd300\bin\speedqx.exe"
)));
}
}
#[test]
fn allowlist_refuses_cargo_rustup_and_everything_else() {
assert!(!is_allowlisted(Path::new("cargo.exe")));
assert!(!is_allowlisted(Path::new("rustup.exe")));
assert!(!is_allowlisted(Path::new("rustc.exe")));
assert!(!is_allowlisted(Path::new("cmd.exe")));
assert!(!is_allowlisted(Path::new("/home/me/.cargo/bin/cargo")));
assert!(!is_allowlisted(Path::new("nd300-old.exe")));
assert!(!is_allowlisted(Path::new("speedqx_backup.exe")));
#[cfg(windows)]
{
assert!(!is_allowlisted(Path::new(
r"C:\Users\me\.cargo\bin\cargo.exe"
)));
assert!(!is_allowlisted(Path::new(r"C:\Windows\System32\cmd.exe")));
assert!(!is_allowlisted(Path::new(
r"C:\Users\me\Downloads\nd300-setup.exe"
)));
assert!(!is_allowlisted(Path::new(r"C:\x\nd300-old.exe")));
}
}
#[test]
fn permission_denied_is_an_admin_signal() {
assert!(is_permission_error(std::io::ErrorKind::PermissionDenied));
assert!(!is_permission_error(std::io::ErrorKind::NotFound));
assert!(!is_permission_error(std::io::ErrorKind::AlreadyExists));
}
#[test]
fn downloads_is_never_a_computed_target_path() {
let safe_tails = [r"\.cargo\bin", r"\nd300\bin", r"\Programs\nd300\bin"];
for t in safe_tails {
assert!(
!t.to_lowercase().contains("download"),
"computed target tail must never be under Downloads: {t}"
);
}
}
#[cfg(windows)]
#[test]
fn dry_run_deletes_nothing() {
let dir = std::env::temp_dir().join(format!("nd300-migrate-dry-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let exe = dir.join("nd300.exe");
std::fs::write(&exe, b"fake").unwrap();
let report = delete_target("cargo_copy", "older cargo copy".to_string(), &exe, true);
assert_eq!(report.outcome, TargetOutcome::WouldRemove);
assert!(
exe.exists(),
"dry-run must NOT delete the file: {}",
exe.display()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(windows)]
#[test]
fn delete_target_refuses_non_allowlisted_file() {
let dir = std::env::temp_dir().join(format!("nd300-migrate-deny-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let cargo_exe = dir.join("cargo.exe");
std::fs::write(&cargo_exe, b"definitely not ours").unwrap();
let report = delete_target("cargo_copy", "x".to_string(), &cargo_exe, false);
assert!(
matches!(report.outcome, TargetOutcome::Skipped(_)),
"non-allowlisted file must be refused, got {:?}",
report.outcome
);
assert!(cargo_exe.exists(), "cargo.exe must NOT be deleted");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn outcome_json_status_is_stable() {
assert_eq!(outcome_json_status(&TargetOutcome::Removed), "removed");
assert_eq!(outcome_json_status(&TargetOutcome::Scheduled), "scheduled");
assert_eq!(
outcome_json_status(&TargetOutcome::WouldRemove),
"would_remove"
);
assert_eq!(
outcome_json_status(&TargetOutcome::Skipped("x".into())),
"skipped"
);
assert_eq!(
outcome_json_status(&TargetOutcome::NeedsAdmin("x".into())),
"needs_admin"
);
assert_eq!(
outcome_json_status(&TargetOutcome::Failed("x".into())),
"failed"
);
}
}