use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
const A4: &str = env!("CARGO_BIN_EXE_a4");
const VERSION: &str = env!("CARGO_PKG_VERSION");
struct Sandbox {
_dir: tempfile::TempDir,
home: PathBuf,
arete_home: PathBuf,
install_dir: PathBuf,
}
impl Sandbox {
fn new() -> Sandbox {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join("home");
fs::create_dir_all(&home).unwrap();
Sandbox {
arete_home: home.join(".arete"),
install_dir: home.join(".local").join("bin"),
home,
_dir: dir,
}
}
fn cmd(&self, exe: &Path) -> Command {
let mut cmd = Command::new(exe);
cmd.env_remove("CI")
.env_remove("GITHUB_PATH")
.env_remove("XDG_BIN_HOME")
.env("HOME", &self.home)
.env("USERPROFILE", &self.home)
.env("ARETE_HOME", &self.arete_home)
.env("A4_INSTALL_DIR", &self.install_dir)
.env("A4_NO_MODIFY_PATH", "1")
.env("A4_NO_UPDATE_CHECK", "1")
.env("DO_NOT_TRACK", "1")
.env("NO_COLOR", "1")
.env("PATH", self.home.join("empty-path"));
cmd
}
fn a4(&self) -> Command {
self.cmd(Path::new(A4))
}
fn binary(&self) -> PathBuf {
let name = if cfg!(windows) { "a4.exe" } else { "a4" };
self.install_dir.join(name)
}
fn receipt(&self) -> serde_json::Value {
let text = fs::read_to_string(self.arete_home.join("receipt.json")).unwrap();
serde_json::from_str(&text).unwrap()
}
}
fn stdout(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).to_string()
}
fn stderr(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).to_string()
}
fn parse_install_stdout(text: &str) -> (serde_json::Value, String, String) {
let lines: Vec<&str> = text.trim_end().lines().collect();
assert!(lines.len() >= 2, "stdout too short: {text:?}");
let export = lines[lines.len() - 1].to_string();
let a4_bin = lines[lines.len() - 2]
.strip_prefix("A4_BIN=")
.unwrap_or_else(|| panic!("second-to-last line must be A4_BIN=: {text:?}"))
.to_string();
let json_text = lines[..lines.len() - 2].join("\n");
let json = serde_json::from_str(&json_text).unwrap_or_else(|error| {
panic!("stdout before the final lines is not JSON ({error}): {json_text}")
});
(json, a4_bin, export)
}
#[test]
fn install_writes_receipt_prints_a4_bin_and_rerun_is_noop() {
let sandbox = Sandbox::new();
let output = sandbox
.a4()
.args(["self", "install", "--source", "sh", "--json"])
.output()
.unwrap();
assert!(output.status.success(), "stderr: {}", stderr(&output));
let (json, a4_bin, export) = parse_install_stdout(&stdout(&output));
assert_eq!(json["schemaVersion"], 1);
assert_eq!(json["status"], "installed");
assert_eq!(json["version"], VERSION);
assert_eq!(json["source"], "sh");
assert_eq!(json["verified"], false);
assert_eq!(json["modifyPath"], false);
assert_eq!(json["pathModified"], serde_json::json!([]));
assert_eq!(json["shadowedBy"], serde_json::Value::Null);
assert_eq!(Path::new(&a4_bin), sandbox.binary());
if cfg!(windows) {
assert!(export.starts_with("$env:Path = "), "{export}");
} else {
assert_eq!(export, "export PATH=\"$HOME/.local/bin:$PATH\"");
}
let receipt = sandbox.receipt();
assert_eq!(receipt["schemaVersion"], 1);
assert_eq!(receipt["version"], VERSION);
assert_eq!(
Path::new(receipt["binary"].as_str().unwrap()),
sandbox.binary()
);
assert_eq!(
Path::new(receipt["installDir"].as_str().unwrap()),
sandbox.install_dir
);
assert_eq!(receipt["verified"], false);
assert!(receipt["installedAt"].as_str().unwrap().ends_with('Z'));
assert!(!sandbox
.install_dir
.join(format!("a4.tmp-{}", std::process::id()))
.exists());
let version = sandbox
.cmd(&sandbox.binary())
.arg("--version")
.output()
.unwrap();
assert!(version.status.success());
assert!(stdout(&version).contains(VERSION));
let before = fs::metadata(sandbox.binary()).unwrap().modified().unwrap();
let rerun = sandbox
.cmd(&sandbox.binary())
.args(["self", "install", "--source", "sh", "--json"])
.output()
.unwrap();
assert!(rerun.status.success(), "stderr: {}", stderr(&rerun));
let (json, a4_bin, _) = parse_install_stdout(&stdout(&rerun));
assert_eq!(json["status"], "unchanged");
assert_eq!(Path::new(&a4_bin), sandbox.binary());
assert_eq!(
fs::metadata(sandbox.binary()).unwrap().modified().unwrap(),
before
);
assert!(stderr(&rerun).contains("already installed"));
}
#[test]
fn human_output_goes_to_stderr_and_final_lines_to_stdout() {
let sandbox = Sandbox::new();
let output = sandbox.a4().args(["self", "install"]).output().unwrap();
assert!(output.status.success(), "stderr: {}", stderr(&output));
let out = stdout(&output);
let lines: Vec<&str> = out.trim_end().lines().collect();
assert_eq!(
lines.len(),
2,
"stdout must be exactly the two final lines: {out:?}"
);
assert!(lines[0].starts_with("A4_BIN="));
assert!(stderr(&output).contains("Installed a4"));
assert!(stderr(&output).contains("Not verified"));
}
#[test]
fn unknown_source_is_rejected() {
let sandbox = Sandbox::new();
let output = sandbox
.a4()
.args(["self", "install", "--source", "brew"])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(1));
assert!(stderr(&output).contains("--source"));
assert!(!sandbox.binary().exists());
}
#[test]
fn signature_from_another_key_fails_and_installs_nothing() {
let sandbox = Sandbox::new();
let fixtures = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/selfhost");
let output = sandbox
.a4()
.args(["self", "install", "--source", "sh", "--json"])
.arg("--checksums")
.arg(fixtures.join("checksums.txt"))
.arg("--signature")
.arg(fixtures.join("checksums.txt.minisig"))
.output()
.unwrap();
assert_eq!(output.status.code(), Some(1));
assert!(
stderr(&output).contains("Signature check failed"),
"{}",
stderr(&output)
);
assert!(!sandbox.binary().exists());
assert!(!sandbox.arete_home.join("receipt.json").exists());
assert!(stdout(&output).is_empty());
}
#[test]
fn missing_signature_flag_is_a_usage_error() {
let sandbox = Sandbox::new();
let output = sandbox
.a4()
.args(["self", "install", "--checksums", "x.txt"])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(2));
assert!(!sandbox.binary().exists());
}
#[cfg(unix)]
#[test]
fn path_edits_are_idempotent_and_removed_by_uninstall() {
let sandbox = Sandbox::new();
let zshrc = sandbox.home.join(".zshrc");
fs::write(&zshrc, "alias ll='ls -l'\n").unwrap();
let profile = sandbox.home.join(".profile");
let run = || {
let mut cmd = sandbox.a4();
cmd.env_remove("A4_NO_MODIFY_PATH")
.env("SHELL", "/bin/zsh")
.args(["self", "install", "--source", "manual", "--json"]);
cmd.output().unwrap()
};
let first = run();
assert!(first.status.success(), "stderr: {}", stderr(&first));
let (json, _, _) = parse_install_stdout(&stdout(&first));
assert_eq!(json["modifyPath"], true);
let modified: Vec<String> = json["pathModified"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
assert_eq!(
modified,
vec![profile.display().to_string(), zshrc.display().to_string()]
);
let expected_line = "export PATH=\"$HOME/.local/bin:$PATH\" # added by a4 self install\n";
assert_eq!(fs::read_to_string(&profile).unwrap(), expected_line);
assert_eq!(
fs::read_to_string(&zshrc).unwrap(),
format!("alias ll='ls -l'\n{expected_line}")
);
assert!(!sandbox.home.join(".bashrc").exists());
assert!(!sandbox.home.join(".bash_profile").exists());
let second = run();
assert!(second.status.success());
let (json, _, _) = parse_install_stdout(&stdout(&second));
assert_eq!(json["pathModified"], serde_json::json!([]));
assert_eq!(fs::read_to_string(&profile).unwrap(), expected_line);
assert_eq!(
fs::read_to_string(&zshrc).unwrap(),
format!("alias ll='ls -l'\n{expected_line}")
);
fs::write(sandbox.arete_home.join("credentials.toml"), "[keys]\n").unwrap();
let uninstall = sandbox
.a4()
.args(["self", "uninstall", "--json"])
.output()
.unwrap();
assert!(uninstall.status.success(), "stderr: {}", stderr(&uninstall));
let json: serde_json::Value = serde_json::from_str(&stdout(&uninstall)).unwrap();
assert_eq!(json["schemaVersion"], 1);
let removed: Vec<String> = json["removed"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
assert!(removed.contains(&sandbox.binary().display().to_string()));
assert!(removed.contains(
&sandbox
.arete_home
.join("receipt.json")
.display()
.to_string()
));
assert!(!sandbox.binary().exists());
assert!(!sandbox.arete_home.join("receipt.json").exists());
assert_eq!(fs::read_to_string(&zshrc).unwrap(), "alias ll='ls -l'\n");
assert_eq!(fs::read_to_string(&profile).unwrap(), "");
let left: Vec<String> = json["leftBehind"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
assert!(left.contains(
&sandbox
.arete_home
.join("credentials.toml")
.display()
.to_string()
));
assert!(sandbox.arete_home.join("credentials.toml").exists());
assert!(stderr(&uninstall).contains("Left behind"));
}
#[cfg(unix)]
#[test]
fn ci_env_skips_rc_edits_but_github_path_is_honoured() {
let sandbox = Sandbox::new();
let github_path = sandbox.home.join("github_path");
let output = sandbox
.a4()
.env_remove("A4_NO_MODIFY_PATH")
.env("CI", "true")
.env("GITHUB_PATH", &github_path)
.env("SHELL", "/bin/bash")
.args(["self", "install", "--json"])
.output()
.unwrap();
assert!(output.status.success(), "stderr: {}", stderr(&output));
let (json, _, _) = parse_install_stdout(&stdout(&output));
assert_eq!(json["modifyPath"], false);
assert_eq!(
json["pathModified"],
serde_json::json!([github_path.display().to_string()])
);
assert!(!sandbox.home.join(".profile").exists());
assert_eq!(
fs::read_to_string(&github_path).unwrap(),
format!("{}\n", sandbox.install_dir.display())
);
}
#[cfg(unix)]
#[test]
fn shadowing_binary_is_reported_never_deleted() {
use std::os::unix::fs::PermissionsExt;
let sandbox = Sandbox::new();
let cargo_bin = sandbox.home.join(".cargo").join("bin");
fs::create_dir_all(&cargo_bin).unwrap();
let other = cargo_bin.join("a4");
fs::write(&other, "#!/bin/sh\nexit 0\n").unwrap();
fs::set_permissions(&other, fs::Permissions::from_mode(0o755)).unwrap();
let path = std::env::join_paths([&cargo_bin, &sandbox.install_dir]).unwrap();
let output = sandbox
.a4()
.env("PATH", path)
.args(["self", "install", "--json"])
.output()
.unwrap();
assert!(output.status.success(), "stderr: {}", stderr(&output));
let (json, _, _) = parse_install_stdout(&stdout(&output));
assert_eq!(
json["shadowedBy"],
serde_json::json!(other.display().to_string())
);
let err = stderr(&output);
assert!(err.contains("cargo uninstall a4-cli"), "{err}");
assert!(other.exists());
}
#[test]
fn uninstall_without_install_reports_nothing_to_remove() {
let sandbox = Sandbox::new();
let output = sandbox
.a4()
.args(["self", "uninstall", "--json"])
.output()
.unwrap();
assert!(output.status.success(), "stderr: {}", stderr(&output));
let json: serde_json::Value = serde_json::from_str(&stdout(&output)).unwrap();
assert_eq!(json["removed"], serde_json::json!([]));
assert!(stderr(&output).contains("Nothing to remove"));
}