use crate::config::Config;
use std::path::{Path, PathBuf};
use crate::cli::MigrateArgs;
#[cfg(windows)]
use super::uninstall::uninstall_path;
use super::uninstall::OUR_BINARIES;
#[cfg(windows)]
use super::update::{
cargo_bin_dir, classify_install_path, classify_shadow_cleanup, current_exe_real_path,
current_install_shadows_cargo_install, 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) fn resolve_targets(cargo_copy: bool, other_edition: bool) -> CleanupTargets {
if !cargo_copy && !other_edition {
CleanupTargets {
cargo_copy: true,
other_edition: false,
}
} else {
CleanupTargets {
cargo_copy,
other_edition,
}
}
}
#[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);
let json = args.json || matches!(config.format, crate::config::OutputFormat::Json);
let reports = collect_and_execute(&args, targets);
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)]
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(),
));
}
reports
}
#[cfg(not(windows))]
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(),
),
});
}
reports
}
#[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 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 = classify_install_path(&running.to_string_lossy());
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(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,
}
}
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,
},
"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);
assert!(t.cargo_copy, "default must clean the cargo copy");
assert!(!t.other_edition, "default must NOT touch the other edition");
}
#[test]
fn explicit_flags_are_respected() {
assert_eq!(
resolve_targets(true, false),
CleanupTargets {
cargo_copy: true,
other_edition: false
}
);
assert_eq!(
resolve_targets(false, true),
CleanupTargets {
cargo_copy: false,
other_edition: true
}
);
assert_eq!(
resolve_targets(true, true),
CleanupTargets {
cargo_copy: true,
other_edition: true
}
);
}
#[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"
);
}
}