use std::path::{Path, PathBuf};
use assert_cmd::Command;
use serde_json::Value;
fn isolated_config() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
(dir, path)
}
fn isolated_roots() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().to_path_buf();
(dir, path)
}
fn ign_rig(config: &Path, roots: &Path, cwd: &Path, args: &[&str]) -> std::process::Output {
let mut command = Command::cargo_bin("ign").expect("binary 'ign' not found");
command
.env("IGNITION_CLI_CONFIG", config)
.env("IGNITION_RIG_ROOTS", roots)
.env_remove("IGNITION_RIG")
.env_remove("IGNITION_TOKEN")
.current_dir(cwd);
command.args(args).output().expect("spawn ign")
}
fn stderr_envelope(out: &std::process::Output) -> String {
let stderr = String::from_utf8_lossy(&out.stderr);
let start = stderr.find('{').unwrap_or(0);
stderr[start..].to_string()
}
fn docker_compose_available() -> bool {
std::process::Command::new("docker")
.args(["compose", "version"])
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
#[test]
fn missing_rig_exits_7_with_search_trail() {
let (_config_dir, config) = isolated_config();
let (_roots_dir, roots) = isolated_roots();
let cwd = tempfile::tempdir().expect("cwd tempdir");
let out = ign_rig(&config, &roots, cwd.path(), &["rig", "status", "--compact"]);
assert_eq!(out.status.code(), Some(7), "rig class");
assert!(out.stdout.is_empty(), "errors never touch stdout");
let body: Value = serde_json::from_str(&stderr_envelope(&out)).expect("error envelope parses");
assert_eq!(body["ok"], Value::Bool(false));
assert_eq!(body["profile"], Value::Null, "docker-only: profile null");
assert_eq!(
body["error"]["code"],
Value::String("rig_error".into()),
"stable slug"
);
let message = body["error"]["message"].as_str().expect("message");
assert!(
message.contains("no compose file discovered"),
"the diagnosis leads: {message}"
);
assert!(
message.contains("docker/compose.yml") && message.contains("docker-compose.yml"),
"cwd candidates named in the trail: {message}"
);
assert!(
body["error"]["hint"]
.as_str()
.unwrap_or("")
.contains("docker ps")
);
}
#[test]
fn unknown_rig_name_lists_knowns_golden() {
let (_config_dir, config) = isolated_config();
std::fs::write(
&config,
"[rigs.known-rig]\ncompose_file = \"/tmp/known-rig-compose.yml\"\n",
)
.expect("write config");
let (_roots_dir, roots) = isolated_roots();
let cwd = tempfile::tempdir().expect("cwd tempdir");
let out = ign_rig(
&config,
&roots,
cwd.path(),
&["rig", "--rig", "nope", "status", "--compact"],
);
assert_eq!(out.status.code(), Some(7));
assert!(out.stdout.is_empty(), "errors never touch stdout");
let body: Value = serde_json::from_str(&stderr_envelope(&out)).expect("error envelope parses");
assert_eq!(body["ok"], Value::Bool(false));
assert_eq!(body["profile"], Value::Null, "docker-only: profile null");
assert_eq!(body["error"]["code"], Value::String("rig_error".into()));
assert_eq!(
body["error"]["message"],
Value::String(
"rig error: rig \"nope\" not found (known rigs: [\"known-rig\"]); add a \
[rigs.nope] entry or run from the rig's directory"
.into()
),
"the exact refusal message — knowns listed, actionable fix named"
);
assert_eq!(
body["error"]["endpoint"],
Value::Null,
"discovery refusals carry no endpoint"
);
}
#[test]
fn discovery_precedence_config_default_beats_cwd() {
if std::env::var("IGNITION_SKIP_RIG_DOCKER_TESTS").is_ok() || docker_compose_available() {
eprintln!("skipping: docker present (or skip forced) — resolve would run for real");
return;
}
let (_config_dir, config) = isolated_config();
let rig_dir = tempfile::tempdir().expect("rig tempdir");
let remote_compose = rig_dir.path().join("remote-compose.yml");
std::fs::write(
&remote_compose,
"services:\n sidecar:\n image: alpine:latest\n",
)
.expect("write remote compose");
std::fs::write(
&config,
format!(
"[rig]\ndefault = \"remote\"\n\n[rigs.remote]\ncompose_file = \"{}\"\n",
remote_compose.display()
),
)
.expect("write config");
let cwd = tempfile::tempdir().expect("cwd tempdir");
std::fs::write(
cwd.path().join("compose.yml"),
"services:\n other:\n image: alpine:latest\n",
)
.expect("write cwd compose");
let (_roots_dir, roots) = isolated_roots();
let out = ign_rig(&config, &roots, cwd.path(), &["rig", "status", "--compact"]);
assert_eq!(out.status.code(), Some(7), "no docker: resolve fails");
let body: Value = serde_json::from_str(&stderr_envelope(&out)).expect("error envelope parses");
assert_eq!(body["profile"], Value::Null);
let message = body["error"]["message"].as_str().expect("message");
assert!(
message.contains("remote-compose.yml"),
"the DEFAULT rig's file was the one resolved: {message}"
);
let cwd_compose = cwd.path().join("compose.yml").display().to_string();
assert!(
!message.contains(&cwd_compose),
"the cwd candidate lost the precedence race: {message}"
);
assert!(
message.contains("failed to spawn") || message.contains("docker"),
"{message}"
);
}
#[test]
fn rig_help_shows_the_subtree() {
let (_config_dir, config) = isolated_config();
let (_roots_dir, roots) = isolated_roots();
let cwd = tempfile::tempdir().expect("cwd tempdir");
let out = ign_rig(&config, &roots, cwd.path(), &["rig", "--help"]);
assert!(out.status.success(), "help exits 0");
let stdout = String::from_utf8_lossy(&out.stdout);
for expected in ["--rig <NAME>", "up", "down", "reset", "status", "logs"] {
assert!(
stdout.contains(expected),
"help mentions {expected}: {stdout}"
);
}
let out = ign_rig(&config, &roots, cwd.path(), &["rig"]);
assert_eq!(out.status.code(), Some(2));
}
#[test]
fn rig_reset_refuses_without_yes_before_any_discovery() {
let (_config_dir, config) = isolated_config();
let (_roots_dir, roots) = isolated_roots();
let cwd = tempfile::tempdir().expect("cwd tempdir");
let out = ign_rig(&config, &roots, cwd.path(), &["rig", "reset", "--compact"]);
assert_eq!(
out.status.code(),
Some(2),
"guard exit 2 — NOT the exit 7 a discovery run would produce"
);
assert!(out.stdout.is_empty(), "errors never touch stdout");
let body: Value = serde_json::from_str(&stderr_envelope(&out)).expect("error envelope parses");
assert_eq!(body["ok"], Value::Bool(false));
assert_eq!(body["profile"], Value::Null, "docker-only: profile null");
assert_eq!(
body["error"]["code"],
Value::String("confirmation_required".into()),
"stable slug"
);
let message = body["error"]["message"].as_str().expect("message");
assert!(
message.contains("rig reset"),
"names the operation: {message}"
);
let hint = body["error"]["hint"].as_str().expect("hint required");
assert!(
hint.contains("--yes") && hint.contains("IGNITION_YES"),
"hint names the flag and the env escape hatch: {hint}"
);
}
#[test]
fn rig_reset_with_yes_in_no_rig_cwd_fails_cleanly_at_discovery() {
let (_config_dir, config) = isolated_config();
let (_roots_dir, roots) = isolated_roots();
let cwd = tempfile::tempdir().expect("cwd tempdir");
let out = ign_rig(
&config,
&roots,
cwd.path(),
&["rig", "reset", "--yes", "--compact"],
);
assert_eq!(
out.status.code(),
Some(7),
"guard passed: discovery failure"
);
let body: Value = serde_json::from_str(&stderr_envelope(&out)).expect("error envelope parses");
assert_eq!(body["profile"], Value::Null);
assert_eq!(body["error"]["code"], Value::String("rig_error".into()));
let message = body["error"]["message"].as_str().expect("message");
assert!(
message.contains("no compose file discovered"),
"the discovery diagnosis leads: {message}"
);
}
#[test]
fn rig_logs_help_shows_tail_follow_service() {
let (_config_dir, config) = isolated_config();
let (_roots_dir, roots) = isolated_roots();
let cwd = tempfile::tempdir().expect("cwd tempdir");
let out = ign_rig(&config, &roots, cwd.path(), &["rig", "logs", "--help"]);
assert!(out.status.success(), "help exits 0");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("--tail"), "tail visible: {stdout}");
assert!(stdout.contains("--follow"), "follow visible: {stdout}");
assert!(stdout.contains("-f"), "the -f short form visible: {stdout}");
assert!(
stdout.contains("[SERVICE]"),
"the SERVICE positional visible: {stdout}"
);
}
#[test]
fn ignition_rig_env_folds_into_selection() {
let (_config_dir, config) = isolated_config();
std::fs::write(
&config,
"[rigs.known-rig]\ncompose_file = \"/tmp/known-rig-compose.yml\"\n",
)
.expect("write config");
let (_roots_dir, roots) = isolated_roots();
let cwd = tempfile::tempdir().expect("cwd tempdir");
let mut command = Command::cargo_bin("ign").expect("binary 'ign' not found");
command
.env("IGNITION_CLI_CONFIG", &config)
.env("IGNITION_RIG_ROOTS", &roots)
.env("IGNITION_RIG", "env-named-rig")
.current_dir(cwd.path())
.args(["rig", "status", "--compact"]);
let out = command.output().expect("spawn ign");
assert_eq!(out.status.code(), Some(7));
let body: Value = serde_json::from_str(&stderr_envelope(&out)).expect("error envelope parses");
let message = body["error"]["message"].as_str().expect("message");
assert!(
message.contains("env-named-rig") && message.contains("known rigs"),
"the env-provided name drove the lookup: {message}"
);
}
#[test]
fn rig_trial_reset_refuses_without_yes_before_any_discovery() {
let (_config_dir, config) = isolated_config();
let (_roots_dir, roots) = isolated_roots();
let cwd = tempfile::tempdir().expect("cwd tempdir");
let out = ign_rig(
&config,
&roots,
cwd.path(),
&["rig", "trial", "reset", "--compact"],
);
assert_eq!(
out.status.code(),
Some(2),
"guard exit 2 — NOT the exit 7 a discovery run would produce"
);
assert!(out.stdout.is_empty(), "errors never touch stdout");
let body: Value = serde_json::from_str(&stderr_envelope(&out)).expect("error envelope parses");
assert_eq!(body["ok"], Value::Bool(false));
assert_eq!(body["profile"], Value::Null, "refusal: profile null");
assert_eq!(
body["error"]["code"],
Value::String("confirmation_required".into()),
"stable slug"
);
let message = body["error"]["message"].as_str().expect("message");
assert!(
message.contains("rig trial reset"),
"names the operation: {message}"
);
}
#[test]
fn rig_trial_help_shows_verbs_and_no_password_flag() {
let (_config_dir, config) = isolated_config();
let (_roots_dir, roots) = isolated_roots();
let cwd = tempfile::tempdir().expect("cwd tempdir");
let out = ign_rig(&config, &roots, cwd.path(), &["rig", "trial", "--help"]);
assert!(out.status.success(), "help exits 0");
let stdout = String::from_utf8_lossy(&out.stdout);
for expected in ["status", "reset"] {
assert!(
stdout.contains(expected),
"help mentions {expected}: {stdout}"
);
}
let out = ign_rig(
&config,
&roots,
cwd.path(),
&["rig", "trial", "reset", "--help"],
);
assert!(out.status.success(), "reset help exits 0");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("--user <NAME>"), "--user visible: {stdout}");
assert!(
!stdout.to_lowercase().contains("--password"),
"password NEVER rides a flag: {stdout}"
);
let out = ign_rig(&config, &roots, cwd.path(), &["rig", "trial"]);
assert_eq!(out.status.code(), Some(2));
}
#[test]
fn rig_restore_refuses_without_yes_before_any_discovery() {
let (_config_dir, config) = isolated_config();
let (_roots_dir, roots) = isolated_roots();
let cwd = tempfile::tempdir().expect("cwd tempdir");
let out = ign_rig(
&config,
&roots,
cwd.path(),
&[
"rig",
"restore",
"--file",
"/nonexistent/snap.gwbk",
"--compact",
],
);
assert_eq!(
out.status.code(),
Some(2),
"guard exit 2 — NOT the exit 7 a discovery run would produce"
);
assert!(out.stdout.is_empty(), "errors never touch stdout");
let body: Value = serde_json::from_str(&stderr_envelope(&out)).expect("error envelope parses");
assert_eq!(body["ok"], Value::Bool(false));
assert_eq!(body["profile"], Value::Null, "refusal: profile null");
assert_eq!(
body["error"]["code"],
Value::String("confirmation_required".into()),
"stable slug"
);
let message = body["error"]["message"].as_str().expect("message");
assert!(
message.contains("rig restore"),
"names the operation: {message}"
);
}
#[test]
fn rig_restore_with_yes_in_no_rig_cwd_fails_cleanly_at_discovery() {
let (_config_dir, config) = isolated_config();
let (_roots_dir, roots) = isolated_roots();
let cwd = tempfile::tempdir().expect("cwd tempdir");
let out = ign_rig(
&config,
&roots,
cwd.path(),
&[
"rig",
"restore",
"--file",
"/nonexistent/snap.gwbk",
"--yes",
"--compact",
],
);
assert_eq!(
out.status.code(),
Some(7),
"guard passed: discovery failure"
);
let body: Value = serde_json::from_str(&stderr_envelope(&out)).expect("error envelope parses");
assert_eq!(body["profile"], Value::Null);
assert_eq!(body["error"]["code"], Value::String("rig_error".into()));
assert!(
body["error"]["message"]
.as_str()
.expect("message")
.contains("no compose file discovered"),
"the discovery diagnosis leads"
);
}
#[test]
fn rig_snapshot_restore_help_surfaces() {
let (_config_dir, config) = isolated_config();
let (_roots_dir, roots) = isolated_roots();
let cwd = tempfile::tempdir().expect("cwd tempdir");
let out = ign_rig(&config, &roots, cwd.path(), &["rig", "snapshot", "--help"]);
assert!(out.status.success(), "snapshot help exits 0");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("--output <DIR>"),
"output visible: {stdout}"
);
assert!(stdout.contains("-o"), "the -o short form visible: {stdout}");
let out = ign_rig(&config, &roots, cwd.path(), &["rig", "restore", "--help"]);
assert!(out.status.success(), "restore help exits 0");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("--file <PATH>"), "file visible: {stdout}");
assert!(
stdout.contains("--timeout <SECS>"),
"timeout visible: {stdout}"
);
assert!(
stdout.contains("--yes"),
"the destructive guard is documented in the verb's help: {stdout}"
);
}