use std::io::Write;
use std::process::{Command, Output, Stdio};
fn ebman(args: &[&str]) -> Output {
let home = std::env::temp_dir().join(format!("ebman-cli-bare-{}", std::process::id()));
let _ = std::fs::create_dir_all(&home);
let mut cmd = Command::new(env!("CARGO_BIN_EXE_ebman"));
no_aws_credentials(&mut cmd)
.args(args)
.env("NO_COLOR", "1")
.env("HOME", &home)
.output()
.unwrap_or_else(|e| {
panic!(
"could not run the ebman binary at {}: {e}",
env!("CARGO_BIN_EXE_ebman")
)
})
}
fn stdout(o: &Output) -> String {
String::from_utf8_lossy(&o.stdout).into_owned()
}
fn stderr(o: &Output) -> String {
String::from_utf8_lossy(&o.stderr).into_owned()
}
#[test]
fn version_prints_the_crate_version_and_exits_zero() {
let out = ebman(&["--version"]);
assert_eq!(out.status.code(), Some(0));
let v = env!("CARGO_PKG_VERSION");
assert!(
stdout(&out).contains(v),
"--version must print {v}, got: {:?}",
stdout(&out)
);
}
#[test]
fn help_exits_zero_and_lists_the_subcommands() {
let out = ebman(&["--help"]);
assert_eq!(out.status.code(), Some(0));
let text = stdout(&out) + &stderr(&out);
for sub in ["envs", "lint", "action", "mcp"] {
assert!(
text.contains(sub),
"--help should mention `{sub}`: {text:?}"
);
}
}
#[test]
fn an_unknown_subcommand_is_refused_and_names_the_valid_ones() {
let out = ebman(&["definitely-not-a-subcommand"]);
assert_ne!(
out.status.code(),
Some(0),
"an unknown subcommand must not exit 0"
);
let text = stdout(&out) + &stderr(&out);
assert!(
text.contains("envs") || text.contains("unknown"),
"the refusal should say what IS valid: {text:?}"
);
}
#[test]
fn every_advertised_subcommand_routes_somewhere() {
let subs = [
"envs",
"action",
"ctl",
"lint",
"drift",
"audit",
"mcp",
"explain",
"versions",
"completions",
];
for sub in subs {
let out = ebman(&[sub, "--help"]);
let text = stdout(&out) + &stderr(&out);
assert!(
!text.contains("unknown subcommand"),
"`ebman {sub} --help` fell through to the unknown-subcommand \
path, so the registry and the dispatch disagree. (Not dumping \
the output: it is the whole help text, ~8KB, and the line that \
matters is the `unknown subcommand` one.)"
);
}
}
#[test]
fn completions_emit_a_script_for_each_supported_shell() {
for (shell, needle) in [
("bash", "complete"),
("zsh", "#compdef"),
("fish", "complete"),
] {
let out = ebman(&["completions", shell]);
assert_eq!(
out.status.code(),
Some(0),
"`completions {shell}` must exit 0, stderr: {:?}",
stderr(&out)
);
let body = stdout(&out);
assert!(
body.contains(needle),
"`completions {shell}` output should look like a {shell} script \
(expected {needle:?}): {:.120?}",
body
);
assert!(
body.len() > 200,
"`completions {shell}` produced {} bytes — too short to be a real script",
body.len()
);
}
}
#[test]
fn completions_refuses_an_unsupported_shell() {
let out = ebman(&["completions", "csh"]);
assert_ne!(out.status.code(), Some(0), "csh is not supported");
}
#[test]
fn argument_errors_exit_two_not_one() {
let cases: &[&[&str]] = &[
&["lint", "--severity"], &["lint", "--severity", "banana"], &["action"], ];
for args in cases {
let out = ebman(args);
let code = out.status.code();
assert!(
code == Some(2) || code == Some(1),
"`ebman {}` should exit with a usage error, got {code:?}: {:?}",
args.join(" "),
stderr(&out)
);
assert_ne!(code, Some(0), "`ebman {}` must not succeed", args.join(" "));
}
}
#[test]
fn the_tui_refuses_a_non_tty_with_a_useful_message() {
let out = ebman(&["--demo"]);
assert_ne!(out.status.code(), Some(0));
let text = stdout(&out) + &stderr(&out);
assert!(
text.contains("needs a terminal"),
"must explain itself rather than surfacing an OS error: {text:?}"
);
assert!(
!text.contains("os error"),
"a raw OS error is what this guard exists to prevent: {text:?}"
);
assert!(
text.contains("envs") || text.contains("headless"),
"and should point at the headless path for scripting: {text:?}"
);
}
fn ebman_with_config(config: &str, args: &[&str]) -> Output {
static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let home = std::env::temp_dir().join(format!(
"ebman-cli-test-{}-{}",
std::process::id(),
SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
let cfg_dir = home.join(".config/ebman");
if let Err(e) = std::fs::create_dir_all(&cfg_dir) {
panic!(
"could not create the temp config dir {}: {e}",
cfg_dir.display()
);
}
if let Err(e) = std::fs::write(cfg_dir.join("config.toml"), config) {
panic!("could not write the temp config.toml: {e}");
}
let mut cmd = Command::new(env!("CARGO_BIN_EXE_ebman"));
no_aws_credentials(&mut cmd)
.args(args)
.env("NO_COLOR", "1")
.env("HOME", &home)
.output()
.unwrap_or_else(|e| panic!("could not run ebman: {e}"))
}
fn no_aws_credentials(cmd: &mut Command) -> &mut Command {
cmd.env("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
.env(
"AWS_SECRET_ACCESS_KEY",
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
)
.env("AWS_SESSION_TOKEN", "invalid-for-tests")
.env("AWS_REGION", "us-east-1")
.env("AWS_DEFAULT_REGION", "us-east-1")
.env("AWS_EC2_METADATA_DISABLED", "true")
.env("AWS_ENDPOINT_URL", "http://127.0.0.1:1")
.env_remove("AWS_PROFILE")
.env_remove("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI")
.env_remove("AWS_CONTAINER_CREDENTIALS_FULL_URI")
.env_remove("AWS_CONTAINER_AUTHORIZATION_TOKEN")
}
#[test]
fn a_read_only_env_pin_refuses_a_headless_write() {
let out = ebman_with_config(
"safety.envs.locked-prod.read_only = true
",
&["action", "rebuild", "--env", "locked-prod"],
);
assert_eq!(
out.status.code(),
Some(3),
"a pinned env must exit 3 (the documented refusal code), got {:?}: {:?}",
out.status.code(),
stderr(&out)
);
let text = stdout(&out) + &stderr(&out);
assert!(
text.contains("locked-prod"),
"the refusal must name the env: {text:?}"
);
assert!(
text.to_lowercase().contains("read") || text.contains("safety"),
"and say why: {text:?}"
);
}
#[test]
fn the_pin_applies_only_to_the_env_it_names() {
let out = ebman_with_config(
"safety.envs.locked-prod.read_only = true
",
&["action", "rebuild", "--env", "some-other-env"],
);
assert_ne!(
out.status.code(),
Some(3),
"an unpinned env must not hit the safety refusal; it should get as \
far as needing AWS. stderr: {:?}",
stderr(&out)
);
}
fn ebman_with_freeze(reason: &str, incident: bool, args: &[&str]) -> Output {
static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let home = std::env::temp_dir().join(format!(
"ebman-cli-freeze-{}-{}",
std::process::id(),
SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
let cache = home.join(".cache/ebman");
if let Err(e) = std::fs::create_dir_all(&cache) {
panic!(
"could not create the temp cache dir {}: {e}",
cache.display()
);
}
let body = format!(
"{{\"pid\":{},\"reason\":\"{reason}\",\"incident\":{incident},\"at\":\"{}\"}}\n",
std::process::id(),
chrono::Utc::now().to_rfc3339(),
);
if let Err(e) = std::fs::write(cache.join("freeze.json"), body) {
panic!("could not write the temp freeze marker: {e}");
}
let mut cmd = Command::new(env!("CARGO_BIN_EXE_ebman"));
no_aws_credentials(&mut cmd)
.args(args)
.env("NO_COLOR", "1")
.env("HOME", &home)
.output()
.unwrap_or_else(|e| panic!("could not run ebman: {e}"))
}
#[test]
fn a_live_freeze_refuses_a_cli_fix_run() {
let out = ebman_with_freeze(
"db migration",
false,
&["lint", "--fix", "--yes", "--env", "api-prod"],
);
let err = stderr(&out);
assert_eq!(
out.status.code(),
Some(3),
"a freeze refusal is exit 3, same class as a pin: {err}"
);
assert!(
err.contains("db migration"),
"the operator must see WHY it is frozen: {err}"
);
assert!(err.contains(":thaw-deploys"), "and how to lift it: {err}");
}
#[test]
fn an_incident_freeze_points_at_incident_end() {
let out = ebman_with_freeze(
"sev1",
true,
&["lint", "--fix", "--yes", "--env", "api-prod"],
);
let err = stderr(&out);
assert_eq!(out.status.code(), Some(3), "{err}");
assert!(
err.contains(":incident END"),
"an incident freeze must point at :incident END, not :thaw-deploys: {err}"
);
}
#[test]
fn without_a_marker_nothing_is_refused_for_a_freeze() {
let out = ebman(&["lint", "--fix", "--yes", "--env", "api-prod"]);
let err = stderr(&out);
assert!(
!err.contains("deploys frozen"),
"no marker was written, so nothing may claim a freeze: {err}"
);
}
#[test]
fn drift_quiet_suppresses_the_no_state_message() {
let empty = std::env::temp_dir().join(format!("ebman-drift-empty-{}", std::process::id()));
if let Err(e) = std::fs::create_dir_all(&empty) {
panic!("could not create the empty dir: {e}");
}
let dir = empty.display().to_string();
let loud = ebman(&["drift", "--tfdir", &dir]);
let err = stderr(&loud);
assert!(
err.contains("no terraform.tfstate"),
"a normal run must say it found nothing: {err:?}"
);
assert!(
err.contains("terraform state pull"),
"and must name the remote-backend workflow — the old message \
stopped at \"pass --tfstate\", which is useless if your state \
is in HCP: {err:?}"
);
let hushed = ebman(&["drift", "--tfdir", &dir, "--quiet"]);
assert!(
stderr(&hushed).is_empty() && stdout(&hushed).is_empty(),
"--quiet must suppress both streams, got stderr={:?} stdout={:?}",
stderr(&hushed),
stdout(&hushed)
);
}
#[test]
fn drift_json_with_no_state_is_well_formed() {
let empty = std::env::temp_dir().join(format!("ebman-drift-json-{}", std::process::id()));
if let Err(e) = std::fs::create_dir_all(&empty) {
panic!("could not create the empty dir: {e}");
}
let out = ebman(&["drift", "--tfdir", &empty.display().to_string(), "--json"]);
assert_eq!(out.status.code(), Some(0), "no state is not an error");
let body = stdout(&out);
let v: serde_json::Value = serde_json::from_str(body.trim())
.unwrap_or_else(|e| panic!("the no-state JSON must parse: {e}\n{body}"));
for key in ["tfstate", "state", "envs"] {
assert!(
v.get(key).is_some(),
"a missing key and a null one read differently to a consumer: {body}"
);
}
}
#[test]
fn a_tfdir_that_does_not_exist_is_an_error() {
let out = ebman(&["drift", "--tfdir", "/no/such/directory-for-this-test"]);
assert_eq!(
out.status.code(),
Some(2),
"a bad --tfdir must exit 2, not proceed against another fleet's \
state: stderr={:?}",
stderr(&out)
);
let err = stderr(&out);
assert!(
err.contains("--tfdir"),
"and must name the flag that was wrong: {err:?}"
);
}
#[test]
fn mcp_serve_honours_peek_bodies_from_the_operator_config() {
let descriptions = |config: Option<&str>, tag: &str| -> String {
let home =
std::env::temp_dir().join(format!("ebman-cli-peekbodies-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&home);
let dir = home.join(".config/ebman");
if let Some(body) = config {
let _ = std::fs::create_dir_all(&dir);
let _ = std::fs::write(dir.join("config.toml"), body);
} else {
let _ = std::fs::create_dir_all(&home);
}
let mut cmd = Command::new(env!("CARGO_BIN_EXE_ebman"));
no_aws_credentials(&mut cmd);
let mut child = cmd
.args(["mcp", "serve"])
.env("NO_COLOR", "1")
.env("HOME", &home)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap_or_else(|e| panic!("could not spawn ebman: {e}"));
let frames = concat!(
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}"#,
"\n",
r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#,
"\n"
);
if let Some(mut si) = child.stdin.take() {
let _ = si.write_all(frames.as_bytes());
}
let out = child
.wait_with_output()
.unwrap_or_else(|e| panic!("ebman mcp serve did not exit: {e}"));
String::from_utf8_lossy(&out.stdout).into_owned()
};
let off = descriptions(Some("mcp.peek_bodies = false\n"), "off");
assert!(
off.contains("BODIES ARE WITHHELD"),
"the server must read the key and declare the policy to the agent; \
without that, a withheld body is indistinguishable from an empty \
queue. Got: {off}"
);
let on = descriptions(None, "on");
assert!(
!on.contains("BODIES ARE WITHHELD"),
"a default server must not claim to withhold anything: {on}"
);
assert!(
on.contains("worker_queues"),
"sanity: the control run must have produced a real tool list: {on}"
);
}