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_module_uninstall_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", "module", "uninstall", "git", "--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 module uninstall"),
"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 module_uninstall_unregistered_id_is_exit_3_before_credential_step() {
if std::env::var("IGNITION_SKIP_RIG_DOCKER_TESTS").is_ok() || !docker_compose_available() {
eprintln!(
"skipping: docker unavailable (or skip forced) — resolve_plan needs \
`docker compose config`"
);
return;
}
let (_config_dir, config, _project_dir) = rig_config_with_real_compose_file();
let (_roots_dir, roots) = isolated_roots();
let cwd = tempfile::tempdir().expect("cwd tempdir");
let out = ign_rig(
&config,
&roots,
cwd.path(),
&[
"rig",
"--rig",
"fixture",
"module",
"uninstall",
"not-a-real-module",
"--yes",
"--compact",
],
);
assert_eq!(
out.status.code(),
Some(3),
"unregistered id refuses at exit 3, never reaching the credential step"
);
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["error"]["code"],
Value::String("module_not_registered".into()),
"stable slug — NOT secret_unavailable, which is what an out-of-order \
check would produce with no IGNITION_TOKEN set"
);
let message = body["error"]["message"].as_str().expect("message");
assert!(
message.contains("not-a-real-module"),
"names the bad id: {message}"
);
assert!(message.contains("git"), "lists a known id: {message}");
}
#[test]
fn module_uninstall_still_declared_id_is_exit_7_before_credential_step() {
if std::env::var("IGNITION_SKIP_RIG_DOCKER_TESTS").is_ok() || !docker_compose_available() {
eprintln!(
"skipping: docker unavailable (or skip forced) — resolve_plan needs \
`docker compose config`"
);
return;
}
let project_dir = tempfile::tempdir().expect("project tempdir");
let compose_file = project_dir.path().join("docker-compose.yml");
std::fs::write(
&compose_file,
"services:\n app:\n image: alpine:latest\n",
)
.expect("write compose file");
let (_config_dir, config) = isolated_config();
std::fs::write(
&config,
format!(
"[rigs.fixture]\ncompose_file = '{}'\nproject_name = \"fixture\"\n\n\
[rigs.fixture.modules.git]\nversion = \"2.3.4\"\n",
compose_file.display()
),
)
.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",
"fixture",
"module",
"uninstall",
"git",
"--yes",
"--compact",
],
);
assert_eq!(
out.status.code(),
Some(7),
"still-declared id refuses at exit 7, never reaching the credential step"
);
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["error"]["code"],
Value::String("rig_error".into()),
"stable slug — NOT secret_unavailable, which is what an out-of-order \
check would produce with no IGNITION_TOKEN set"
);
let message = body["error"]["message"].as_str().expect("message");
assert!(message.contains("git"), "names the module: {message}");
assert!(message.contains("fixture"), "names the rig: {message}");
assert!(
message.contains("[rigs.fixture.modules.git]"),
"names the config table to edit: {message}"
);
assert!(message.contains("ign rig up"), "names the fix: {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}"
);
}
fn rig_config_with_real_compose_file() -> (tempfile::TempDir, PathBuf, tempfile::TempDir) {
let project_dir = tempfile::tempdir().expect("project tempdir");
let compose_file = project_dir.path().join("docker-compose.yml");
std::fs::write(
&compose_file,
"services:\n app:\n image: alpine:latest\n",
)
.expect("write compose file");
let (config_dir, config) = isolated_config();
std::fs::write(
&config,
format!(
"[rigs.fixture]\ncompose_file = '{}'\n",
compose_file.display()
),
)
.expect("write config");
(config_dir, config, project_dir)
}
#[test]
fn malformed_with_module_value_is_exit_2() {
let (_config_dir, config, project_dir_guard) = rig_config_with_real_compose_file();
let project_dir = project_dir_guard.path();
let (_roots_dir, roots) = isolated_roots();
let cwd = tempfile::tempdir().expect("cwd tempdir");
let out = ign_rig(
&config,
&roots,
cwd.path(),
&[
"rig",
"--rig",
"fixture",
"up",
"--with-module",
"git-no-separator",
"--compact",
],
);
assert_eq!(
out.status.code(),
Some(2),
"malformed --with-module refuses at exit 2, never reaching a docker/resolve failure"
);
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("invalid_input".into()),
"stable slug"
);
let message = body["error"]["message"].as_str().expect("message");
assert!(
message.contains("git-no-separator"),
"message names the received value: {message}"
);
let entries: Vec<_> = std::fs::read_dir(project_dir)
.expect("read project dir")
.map(|entry| entry.expect("dir entry").file_name())
.collect();
assert_eq!(
entries,
vec![std::ffi::OsString::from("docker-compose.yml")],
"nothing was written to the rig's project directory — the refusal fired \
before resolve_plan (and therefore before Docker) ever ran"
);
}
#[test]
fn unknown_module_id_via_flag_lists_knowns() {
let (_config_dir, config, project_dir_guard) = rig_config_with_real_compose_file();
let project_dir = project_dir_guard.path();
let (_roots_dir, roots) = isolated_roots();
let cwd = tempfile::tempdir().expect("cwd tempdir");
let out = ign_rig(
&config,
&roots,
cwd.path(),
&[
"rig",
"--rig",
"fixture",
"up",
"--with-module",
"nope@1.0.0",
"--compact",
],
);
assert_eq!(
out.status.code(),
Some(3),
"unregistered module id refuses at exit 3, never reaching a docker/resolve failure"
);
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("module_not_registered".into()),
"stable slug"
);
let message = body["error"]["message"].as_str().expect("message");
assert!(message.contains("nope"), "names the bad id: {message}");
assert!(message.contains("git"), "lists a known id: {message}");
assert!(
message.contains("project-scan-endpoint"),
"lists the OTHER known id too: {message}"
);
let entries: Vec<_> = std::fs::read_dir(project_dir)
.expect("read project dir")
.map(|entry| entry.expect("dir entry").file_name())
.collect();
assert_eq!(
entries,
vec![std::ffi::OsString::from("docker-compose.yml")],
"nothing was written to the rig's project directory"
);
}
#[test]
fn rig_reset_with_module_refusals_match_rig_up() {
let (_config_dir, config, _project_dir) = rig_config_with_real_compose_file();
let (_roots_dir, roots) = isolated_roots();
let cwd = tempfile::tempdir().expect("cwd tempdir");
let out = ign_rig(
&config,
&roots,
cwd.path(),
&[
"rig",
"--rig",
"fixture",
"reset",
"--yes",
"--with-module",
"nope@1.0.0",
"--compact",
],
);
assert_eq!(
out.status.code(),
Some(3),
"same exit-3 refusal as `rig up`"
);
let body: Value = serde_json::from_str(&stderr_envelope(&out)).expect("error envelope parses");
assert_eq!(
body["error"]["code"],
Value::String("module_not_registered".into())
);
}
#[test]
fn rig_up_reset_help_shows_with_module_and_fetch_policy_flags() {
let (_config_dir, config) = isolated_config();
let (_roots_dir, roots) = isolated_roots();
let cwd = tempfile::tempdir().expect("cwd tempdir");
for verb in ["up", "reset"] {
let out = ign_rig(&config, &roots, cwd.path(), &["rig", verb, "--help"]);
assert!(out.status.success(), "{verb} help exits 0");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("--with-module <ID@VERSION>"),
"{verb}: --with-module visible: {stdout}"
);
assert!(
stdout.contains("--refresh"),
"{verb}: --refresh visible: {stdout}"
);
assert!(
stdout.contains("--accept-upstream-change"),
"{verb}: --accept-upstream-change visible: {stdout}"
);
assert!(
stdout.to_lowercase().contains("no subtractive form")
|| stdout.to_lowercase().contains("additive"),
"{verb}: D-16's no-subtractive-form contract is discoverable in help: {stdout}"
);
}
}
#[test]
fn refresh_and_accept_upstream_change_together_is_a_usage_error() {
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",
"up",
"--refresh",
"--accept-upstream-change",
"--compact",
],
);
assert_eq!(
out.status.code(),
Some(2),
"clap's conflicts_with refuses the combination as a usage error"
);
}
fn docker_daemon_available() -> bool {
if !docker_compose_available() {
return false;
}
std::process::Command::new("docker")
.args(["version", "--format", "{{.Server.Os}}"])
.output()
.ok()
.filter(|out| out.status.success())
.map(|out| String::from_utf8_lossy(&out.stdout).trim() == "linux")
.unwrap_or(false)
}
#[test]
fn rig_up_with_module_json_reports_provisioned_modules() {
if std::env::var("IGNITION_SKIP_RIG_DOCKER_TESTS").is_ok() || !docker_daemon_available() {
eprintln!("skipping: docker unavailable (or skip forced) — this test drives a real rig");
return;
}
let project_dir = tempfile::tempdir().expect("project tempdir");
let compose_file = project_dir.path().join("docker-compose.yml");
std::fs::write(
&compose_file,
"services:\n app:\n image: alpine:latest\n command: [\"sleep\", \"infinity\"]\n",
)
.expect("write compose file");
let project_name = format!(
"ign-cli-test-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos()
);
let (_config_dir, config) = isolated_config();
std::fs::write(
&config,
format!(
"[rigs.fixture]\ncompose_file = '{}'\nproject_name = '{}'\nmodule_service = \"app\"\n",
compose_file.display(),
project_name
),
)
.expect("write config");
let (_roots_dir, roots) = isolated_roots();
let cwd = tempfile::tempdir().expect("cwd tempdir");
let payload =
b"synthetic .modl payload for rig_up_with_module_json_reports_provisioned_modules".to_vec();
let digest = sha256_hex(&payload);
let cache_root = tempfile::tempdir().expect("cache tempdir");
let module_dir = cache_root.path().join("modules").join("git");
std::fs::create_dir_all(&module_dir).expect("create module cache dir");
std::fs::write(module_dir.join(format!("2.3.4-{digest}.modl")), &payload)
.expect("seed cached artifact");
struct Teardown {
compose_file: PathBuf,
project_name: String,
}
impl Drop for Teardown {
fn drop(&mut self) {
let _ = std::process::Command::new("docker")
.args([
"compose",
"-p",
&self.project_name,
"-f",
self.compose_file.to_str().expect("utf8 path"),
"down",
"-v",
"--remove-orphans",
])
.output();
}
}
let _teardown = Teardown {
compose_file: compose_file.clone(),
project_name: project_name.clone(),
};
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_CLI_CACHE", cache_root.path())
.env_remove("IGNITION_RIG")
.env_remove("IGNITION_TOKEN")
.current_dir(cwd.path())
.args([
"rig",
"--rig",
"fixture",
"up",
"--with-module",
"git@2.3.4",
"--timeout",
"60",
"--json",
"--compact",
]);
let out = command.output().expect("spawn ign");
assert!(
out.status.success(),
"rig up succeeds: stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let body: Value = serde_json::from_str(&String::from_utf8_lossy(&out.stdout))
.expect("stdout envelope parses");
assert_eq!(body["ok"], Value::Bool(true));
let modules = body["data"]["provisioned_modules"]
.as_array()
.expect("provisioned_modules is always an array");
assert_eq!(modules.len(), 1, "exactly the one --with-module module");
assert_eq!(
body["data"]["orphaned_modules"],
serde_json::json!([]),
"a NEWLY declared module is not an orphan, and the key is present-and-empty"
);
let module = &modules[0];
assert_eq!(module["id"], Value::String("git".into()));
assert_eq!(module["version"], Value::String("2.3.4".into()));
assert_eq!(
module["gateway_module_id"],
Value::String("com.axone_io.ignition.git".into())
);
assert_eq!(module["digest_sha256"], Value::String(digest.clone()));
assert_eq!(module["source"], Value::String("cache".into()));
assert_eq!(
module["mount_target"],
Value::String("/usr/local/bin/ignition/user-lib/modules/git.modl".into())
);
let override_file = project_dir.path().join("compose.ign-modules.yml");
assert!(
override_file.exists(),
"the override file was written to the rig's project directory"
);
let contents = std::fs::read_to_string(&override_file).expect("read override");
assert!(contents.contains("git.modl"), "{contents}");
assert!(contents.contains("com.axone_io.ignition.git"), "{contents}");
}
struct OrphanRig {
_config_dir: tempfile::TempDir,
config: PathBuf,
_roots_dir: tempfile::TempDir,
roots: PathBuf,
cwd: tempfile::TempDir,
project_dir: tempfile::TempDir,
compose_file: PathBuf,
project_name: String,
cache_root: tempfile::TempDir,
}
impl Drop for OrphanRig {
fn drop(&mut self) {
let _ = std::process::Command::new("docker")
.args([
"compose",
"-p",
&self.project_name,
"-f",
self.compose_file.to_str().expect("utf8 path"),
"down",
"-v",
"--remove-orphans",
])
.output();
}
}
impl OrphanRig {
fn new(tag: &str) -> Self {
let project_dir = tempfile::tempdir().expect("project tempdir");
let compose_file = project_dir.path().join("docker-compose.yml");
std::fs::write(
&compose_file,
"services:\n app:\n image: alpine:latest\n command: [\"sleep\", \"infinity\"]\n",
)
.expect("write compose file");
let project_name = format!(
"ign-cli-orphan-{tag}-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos()
);
let (config_dir, config) = isolated_config();
std::fs::write(
&config,
format!(
"[rigs.fixture]\ncompose_file = '{}'\nproject_name = '{}'\nmodule_service = \"app\"\n",
compose_file.display(),
project_name
),
)
.expect("write config");
let (roots_dir, roots) = isolated_roots();
let payload = format!("synthetic .modl payload for orphan test {tag}").into_bytes();
let digest = sha256_hex(&payload);
let cache_root = tempfile::tempdir().expect("cache tempdir");
let module_dir = cache_root.path().join("modules").join("git");
std::fs::create_dir_all(&module_dir).expect("create module cache dir");
std::fs::write(module_dir.join(format!("2.3.4-{digest}.modl")), &payload)
.expect("seed cached artifact");
Self {
_config_dir: config_dir,
config,
_roots_dir: roots_dir,
roots,
cwd: tempfile::tempdir().expect("cwd tempdir"),
project_dir,
compose_file,
project_name,
cache_root,
}
}
fn override_file(&self) -> PathBuf {
self.project_dir.path().join("compose.ign-modules.yml")
}
fn up(&self, extra: &[&str], json: bool) -> std::process::Output {
self.run("up", &[], extra, json)
}
fn reset(&self, json: bool) -> std::process::Output {
self.run("reset", &["--yes"], &[], json)
}
fn run(&self, verb: &str, guard: &[&str], extra: &[&str], json: bool) -> std::process::Output {
let mut command = Command::cargo_bin("ign").expect("binary 'ign' not found");
command
.env("IGNITION_CLI_CONFIG", &self.config)
.env("IGNITION_RIG_ROOTS", &self.roots)
.env("IGNITION_CLI_CACHE", self.cache_root.path())
.env_remove("IGNITION_RIG")
.env_remove("IGNITION_TOKEN")
.current_dir(self.cwd.path())
.args(["rig", "--rig", "fixture", verb])
.args(guard)
.args(extra)
.args(["--timeout", "60"]);
if json {
command.args(["--json", "--compact"]);
}
command.output().expect("spawn ign")
}
}
fn assert_success(out: &std::process::Output, what: &str) {
assert!(
out.status.success(),
"{what} succeeds: stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn rig_up_after_undeclaring_reports_the_orphan_and_deletes_the_override() {
if std::env::var("IGNITION_SKIP_RIG_DOCKER_TESTS").is_ok() || !docker_daemon_available() {
eprintln!("skipping: docker unavailable (or skip forced) — this test drives a real rig");
return;
}
let rig = OrphanRig::new("json");
let out = rig.up(&[], true);
assert_success(&out, "rig up on a never-provisioned rig");
let body: Value = serde_json::from_str(&String::from_utf8_lossy(&out.stdout))
.expect("stdout envelope parses");
assert_eq!(
body["data"]["orphaned_modules"],
serde_json::json!([]),
"an unprovisioned rig still carries the key"
);
assert!(!rig.override_file().exists(), "nothing was provisioned");
let out = rig.up(&["--with-module", "git@2.3.4"], true);
assert_success(&out, "rig up --with-module git");
assert!(
rig.override_file().exists(),
"ign itself wrote the override that run 3 will find"
);
let mut planted = std::fs::read_to_string(rig.override_file()).expect("read override");
assert!(
planted.contains("target: '/usr/local/bin/ignition/user-lib/modules/git.modl'"),
"the real override names git: {planted}"
);
planted.push_str(
" target: '/usr/local/bin/ignition/user-lib/modules/totally-made-up.modl'\n\
\x20 target: '/tmp/outside/project-scan-endpoint.modl'\n",
);
std::fs::write(rig.override_file(), planted).expect("plant tampered targets");
let out = rig.up(&[], true);
assert_success(&out, "rig up after undeclaring");
let body: Value = serde_json::from_str(&String::from_utf8_lossy(&out.stdout))
.expect("stdout envelope parses");
let orphans = body["data"]["orphaned_modules"]
.as_array()
.expect("orphaned_modules is always an array");
assert_eq!(
orphans.len(),
1,
"exactly git: the unknown id and the out-of-directory path are dropped: {orphans:?}"
);
assert_eq!(orphans[0]["id"], Value::String("git".into()));
assert_eq!(
orphans[0]["gateway_module_id"],
Value::String("com.axone_io.ignition.git".into())
);
assert_eq!(
orphans[0]["remove_with"],
Value::String("ign rig module uninstall git --rig fixture --yes".into())
);
assert_eq!(
body["data"]["provisioned_modules"],
serde_json::json!([]),
"nothing is provisioned on the undeclared run"
);
assert!(
!rig.override_file().exists(),
"the stale override is deleted on the undeclare path"
);
let out = rig.up(&[], true);
assert_success(&out, "rig up after the report");
let body: Value = serde_json::from_str(&String::from_utf8_lossy(&out.stdout))
.expect("stdout envelope parses");
assert_eq!(body["data"]["orphaned_modules"], serde_json::json!([]));
}
#[test]
fn rig_up_after_undeclaring_prints_the_removal_command_for_humans() {
if std::env::var("IGNITION_SKIP_RIG_DOCKER_TESTS").is_ok() || !docker_daemon_available() {
eprintln!("skipping: docker unavailable (or skip forced) — this test drives a real rig");
return;
}
let rig = OrphanRig::new("human");
let out = rig.up(&["--with-module", "git@2.3.4"], false);
assert_success(&out, "rig up --with-module git");
let routine = String::from_utf8_lossy(&out.stdout).to_string();
assert!(
!routine.contains("orphaned module"),
"a routine up stays quiet: {routine}"
);
let out = rig.up(&[], false);
assert_success(&out, "rig up after undeclaring");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("orphaned module: git (com.axone_io.ignition.git)"),
"the orphan is named: {stdout}"
);
assert!(
stdout.contains("ign rig module uninstall git --rig fixture --yes"),
"the removal command is named: {stdout}"
);
}
#[test]
fn rig_reset_after_undeclaring_prints_the_removal_command() {
if std::env::var("IGNITION_SKIP_RIG_DOCKER_TESTS").is_ok() || !docker_daemon_available() {
eprintln!("skipping: docker unavailable (or skip forced) — this test drives a real rig");
return;
}
let rig = OrphanRig::new("reset");
let out = rig.up(&["--with-module", "git@2.3.4"], false);
assert_success(&out, "rig up --with-module git");
assert!(rig.override_file().exists(), "ign wrote the override");
let out = rig.reset(false);
assert_success(&out, "rig reset after undeclaring");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("orphaned module: git (com.axone_io.ignition.git)")
&& stdout.contains("ign rig module uninstall git --rig fixture --yes"),
"reset names the orphan and its removal command: {stdout}"
);
assert!(
!rig.override_file().exists(),
"the stale override is deleted on the reset path too"
);
}
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(bytes);
format!("{:x}", hasher.finalize())
}