use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use tempfile::TempDir;
fn devp_uninstall(config_dir: &Path, extra_path_dirs: &[&Path]) -> Command {
let exe = Path::new(env!("CARGO_BIN_EXE_dev-prune"));
let mut cmd = Command::new(exe);
cmd.env("DEV_PRUNE_NO_AUTO_SETUP", "1");
cmd.env("DEV_PRUNE_OFFLINE", "1");
cmd.env("DEV_PRUNE_CONFIG_DIR", config_dir);
cmd.env("XDG_DATA_HOME", config_dir.join("xdg-data"));
let mut paths: Vec<PathBuf> = vec![exe.parent().unwrap().to_path_buf()];
paths.extend(extra_path_dirs.iter().map(|p| p.to_path_buf()));
cmd.env("PATH", std::env::join_paths(paths).unwrap());
cmd.stdin(std::process::Stdio::null());
cmd.arg("uninstall");
cmd
}
fn devp_with_real_path(config_dir: &Path) -> Command {
let exe = Path::new(env!("CARGO_BIN_EXE_dev-prune"));
let mut cmd = Command::new(exe);
cmd.env("DEV_PRUNE_NO_AUTO_SETUP", "1");
cmd.env("DEV_PRUNE_OFFLINE", "1");
cmd.env("DEV_PRUNE_CONFIG_DIR", config_dir);
cmd
}
fn combined(out: &Output) -> String {
format!(
"{}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
)
}
fn exe_name(stem: &str) -> String {
if cfg!(windows) {
format!("{stem}.exe")
} else {
stem.to_string()
}
}
fn fake_managed_install(config_dir: &Path) -> PathBuf {
let bin = config_dir.join("bin");
fs::create_dir_all(&bin).unwrap();
for stem in ["dev-prune", "devp"] {
fs::write(bin.join(exe_name(stem)), b"not a real binary").unwrap();
}
bin
}
fn seed_registry(config_dir: &Path, repo_parent: &Path) {
let repo = repo_parent.join("seedrepo");
git_repo(&repo);
let out = devp_with_real_path(config_dir)
.args(["link", repo.to_str().unwrap()])
.output()
.unwrap();
assert!(
out.status.success(),
"seed link failed:\n{}",
combined(&out)
);
assert!(config_dir.join("registry.json").exists());
}
fn git_repo(path: &Path) {
fs::create_dir_all(path).unwrap();
let no_config = path.join("no-such-gitconfig");
let run = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(path)
.env("GIT_CONFIG_GLOBAL", &no_config)
.env("GIT_CONFIG_SYSTEM", &no_config)
.output()
.unwrap()
};
run(&["init"]);
fs::write(path.join("README.md"), "fixture").unwrap();
run(&["add", "."]);
run(&[
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"-m",
"init",
]);
}
#[test]
fn a_light_uninstall_removes_the_managed_binaries_and_keeps_the_config() {
let tmp = TempDir::new().unwrap();
let config = tmp.path().join("config");
let bin = fake_managed_install(&config);
seed_registry(&config, tmp.path());
let out = devp_uninstall(&config, &[]).output().unwrap();
assert!(
out.status.success(),
"light uninstall failed:\n{}",
combined(&out)
);
for stem in ["dev-prune", "devp"] {
assert!(
!bin.join(exe_name(stem)).exists(),
"{stem} survived a light uninstall"
);
}
assert!(
config.join("registry.json").exists(),
"light uninstall deleted the registry:\n{}",
combined(&out)
);
assert!(
combined(&out).contains("preserved"),
"success message missing:\n{}",
combined(&out)
);
}
#[test]
fn a_deep_uninstall_without_confirmation_refuses_and_deletes_nothing() {
let tmp = TempDir::new().unwrap();
let config = tmp.path().join("config");
let bin = fake_managed_install(&config);
seed_registry(&config, tmp.path());
let out = devp_uninstall(&config, &[]).arg("--deep").output().unwrap();
assert!(
!out.status.success(),
"deep uninstall proceeded without confirmation:\n{}",
combined(&out)
);
assert!(
combined(&out).contains("--yes"),
"the refusal must name the flag that overrides it:\n{}",
combined(&out)
);
assert!(bin.join(exe_name("devp")).exists());
assert!(config.join("registry.json").exists());
}
#[test]
fn a_deep_uninstall_removes_the_config_directory_and_per_repo_configs() {
let tmp = TempDir::new().unwrap();
let config = tmp.path().join("config");
fake_managed_install(&config);
let repo = tmp.path().join("repo");
git_repo(&repo);
fs::write(repo.join(".devprune.json"), "{}").unwrap();
let out = devp_with_real_path(&config)
.args(["link", repo.to_str().unwrap()])
.output()
.unwrap();
assert!(out.status.success(), "link failed:\n{}", combined(&out));
let out = devp_uninstall(&config, &[])
.args(["--deep", "--yes"])
.output()
.unwrap();
assert!(
out.status.success(),
"deep uninstall failed:\n{}",
combined(&out)
);
assert!(!config.exists(), "config directory survived --deep");
assert!(
!repo.join(".devprune.json").exists(),
"per-repo config survived --deep"
);
assert!(
repo.join("README.md").exists(),
"repo contents were touched"
);
assert!(
repo.join(".git").exists(),
"the repository itself was touched"
);
}
#[test]
fn the_sweep_lists_strays_but_deletes_nothing_without_yes() {
let tmp = TempDir::new().unwrap();
let config = tmp.path().join("config");
fake_managed_install(&config);
let straybin = tmp.path().join("straybin");
fs::create_dir_all(&straybin).unwrap();
let stray = straybin.join(exe_name("devp"));
fs::write(&stray, b"a stray copy").unwrap();
let out = devp_uninstall(&config, &[&straybin]).output().unwrap();
let text = combined(&out);
assert!(
text.contains("more cop"),
"the stray was not announced:\n{text}"
);
assert!(
text.contains("straybin"),
"the stray location was not shown:\n{text}"
);
assert!(
text.contains("--yes"),
"no pointer to --yes for a non-interactive run:\n{text}"
);
assert!(stray.exists(), "the sweep deleted without confirmation");
assert!(
out.status.success(),
"declining the sweep changed the exit code:\n{text}"
);
}
#[test]
fn the_sweep_with_yes_deletes_strays_and_their_shims() {
let tmp = TempDir::new().unwrap();
let config = tmp.path().join("config");
fake_managed_install(&config);
let straybin = tmp.path().join("straybin");
fs::create_dir_all(&straybin).unwrap();
let names: &[&str] = if cfg!(windows) {
&["devp.exe", "devp.cmd", "devp.ps1", "dev-prune.exe"]
} else {
&["devp", "dev-prune"]
};
for name in names {
fs::write(straybin.join(name), b"a stray copy").unwrap();
}
let bystander = straybin.join("devp-helper.sh");
fs::write(&bystander, b"not ours").unwrap();
let out = devp_uninstall(&config, &[&straybin])
.arg("--yes")
.output()
.unwrap();
let text = combined(&out);
assert!(
out.status.success(),
"uninstall with a sweep failed:\n{text}"
);
for name in names {
assert!(
!straybin.join(name).exists(),
"stray `{name}` survived a confirmed sweep:\n{text}"
);
}
assert!(
bystander.exists(),
"the sweep deleted a file that is not dev-prune's"
);
assert!(text.contains("stray cop"), "no removal report:\n{text}");
}
#[test]
fn the_sweep_never_touches_a_development_build() {
let tmp = TempDir::new().unwrap();
let config = tmp.path().join("config");
fake_managed_install(&config);
let out = devp_uninstall(&config, &[]).arg("--yes").output().unwrap();
assert!(
out.status.success(),
"uninstall failed:\n{}",
combined(&out)
);
assert!(
Path::new(env!("CARGO_BIN_EXE_dev-prune")).exists(),
"the sweep deleted the development build under test"
);
}
#[test]
fn hands_off_uninstall_reports_what_it_leaves_alone() {
let tmp = TempDir::new().unwrap();
let config = tmp.path().join("config");
fake_managed_install(&config);
let out = devp_uninstall(&config, &[]).output().unwrap();
let text = combined(&out);
assert!(
text.contains("DEV_PRUNE_NO_AUTO_SETUP"),
"the hands-off skip was silent:\n{text}"
);
}