use std::process::{Command, Output};
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)
);
}