use std::io::Write as _;
use std::path::{Path, PathBuf};
use assert_cmd::Command;
fn isolated_config() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
(dir, path)
}
fn write_profile_config(config: &Path, url: &str) {
std::fs::write(
config,
format!(
"active = \"dev\"\n\n[profiles.dev]\nurl = \"{url}\"\nauth = {{ token_env = \"IGNITION_TOKEN\" }}\n"
),
)
.expect("write config");
}
fn ign_cmd(config: &Path, url: &str) -> Command {
let mut command = Command::cargo_bin("ign").expect("binary 'ign' not found");
command
.env("IGNITION_CLI_CONFIG", config)
.env("IGNITION_TOKEN", "mock:name-key")
.env("IGNITION_URL", url)
.env_remove("IGNITION_PROFILE")
.env_remove("IGNITION_JSON")
.env_remove("IGNITION_YES")
.env_remove("VISUAL")
.env_remove("EDITOR");
command
}
const VIEW: &str = "com.example/views/Dashboard/view.json";
const VIEW_ZIP: &str = "com.example/resources/views/Dashboard/view.json";
const VIEW_JSON: &str = r#"{
"scope": "G",
"children": [
{
"type": "ia.display.label",
"eventScripts": {
"actionPerformed": {
"config": {
"script": "\tprint \u0027clicked\u0027\n\tprint \u0027done \u003c\u003e\u0026\u003d\u0027"
}
}
}
}
]
}"#;
const NESTED: &str = "com.example/views/Nested/Deep/other.json";
const NESTED_ZIP: &str = "com.example/resources/views/Nested/Deep/other.json";
fn nested_json(value: u32) -> String {
format!(r#"{{"scope":"G","value":{value}}}"#)
}
fn fixture_zip() -> Vec<u8> {
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
writer
.start_file("project.json", options)
.expect("project.json starts");
writer
.write_all(br#"{"title":"T","enabled":true}"#)
.expect("project.json writes");
writer
.start_file(VIEW_ZIP, options)
.expect("view member starts");
writer
.write_all(VIEW_JSON.as_bytes())
.expect("view member writes");
writer
.start_file(NESTED_ZIP, options)
.expect("nested member starts");
writer
.write_all(nested_json(42).as_bytes())
.expect("nested member writes");
writer.finish().expect("zip finalizes").into_inner()
}
fn editor_script(dir: &Path, name: &str, body: &str) -> String {
let path = dir.join(name);
std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).expect("script written");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
.expect("script chmod +x");
}
path.display().to_string()
}
const NOOP_EDITOR: &str = "exit 0";
const SET99_EDITOR: &str = r#"printf '{"scope":"G","value":99}' > "$1""#;
const BREAKER_EDITOR: &str = r#"printf '{ broken' > "$1""#;
async fn export_mock(server: &wiremock::MockServer, expected_gets: u64) -> wiremock::MockGuard {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/data/api/v1/projects/export/proj",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_raw(fixture_zip(), "application/zip"),
)
.expect(expected_gets)
.mount_as_scoped(server)
.await
}
async fn import_mock(server: &wiremock::MockServer, expected_imports: u64) -> wiremock::MockGuard {
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/data/api/v1/projects/import/proj",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"status":"imported"})),
)
.expect(expected_imports)
.mount_as_scoped(server)
.await
}
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()
}
#[derive(Clone)]
struct AlternatingResponder {
hits: std::sync::Arc<std::sync::atomic::AtomicUsize>,
fetch: std::sync::Arc<Vec<u8>>,
fresh: std::sync::Arc<Vec<u8>>,
}
impl AlternatingResponder {
fn new(fetch: Vec<u8>, fresh: Vec<u8>) -> Self {
use std::sync::atomic::AtomicUsize;
Self {
hits: std::sync::Arc::new(AtomicUsize::new(0)),
fetch: std::sync::Arc::new(fetch),
fresh: std::sync::Arc::new(fresh),
}
}
}
impl wiremock::Respond for AlternatingResponder {
fn respond(&self, _request: &wiremock::Request) -> wiremock::ResponseTemplate {
use std::sync::atomic::Ordering;
let n = self.hits.fetch_add(1, Ordering::SeqCst);
let body = if n.is_multiple_of(2) {
&self.fetch
} else {
&self.fresh
};
wiremock::ResponseTemplate::new(200).set_body_raw(
std::sync::Arc::unwrap_or_clone(body.clone()),
"application/zip",
)
}
}
fn lingering_edit_tree(dir: &Path) -> Option<PathBuf> {
std::fs::read_dir(dir)
.ok()?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.find(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("ign-edit-"))
})
}
#[tokio::test]
async fn edit_stdout_is_byte_pure_under_max_diagnostics() {
let (_dir, config) = isolated_config();
let scratch = tempfile::tempdir().expect("scratch");
let server = wiremock::MockServer::start().await;
std::fs::write(
&config,
format!(
"bogus_key = 1\nactive = \"dev\"\n\n[profiles.dev]\nurl = \"{}\"\nauth = {{ token_env = \"IGNITION_TOKEN\" }}\n",
server.uri()
),
)
.expect("noisy config");
let editor = editor_script(scratch.path(), "noop-editor", NOOP_EDITOR);
{
let _gets = export_mock(&server, 1).await;
let _imports = import_mock(&server, 0).await;
let out = ign_cmd(&config, &server.uri())
.env("EDITOR", &editor)
.env("IGNITION_LOG", "trace")
.args([
"edit",
"proj",
NESTED,
"--verbose",
"--verbose",
"--verbose",
])
.output()
.expect("spawn ign");
assert!(
out.status.success(),
"no-op edit exits clean: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
out.stdout.is_empty(),
"edit must write ZERO stdout bytes (no-op, max diagnostics) — got: {:?}",
String::from_utf8_lossy(&out.stdout)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("edit: no changes — nothing pushed"),
"the no-op prose renders on stderr: {stderr}"
);
assert!(
stderr.contains("bogus_key"),
"expected the unknown-key warning on stderr (noise precondition): {stderr}"
);
}
let (_dir, config) = isolated_config();
let scratch = tempfile::tempdir().expect("scratch");
let server = wiremock::MockServer::start().await;
std::fs::write(
&config,
format!(
"bogus_key = 1\nactive = \"dev\"\n\n[profiles.dev]\nurl = \"{}\"\nauth = {{ token_env = \"IGNITION_TOKEN\" }}\n",
server.uri()
),
)
.expect("noisy config");
let editor = editor_script(scratch.path(), "set99-editor", SET99_EDITOR);
{
let _gets = export_mock(&server, 2).await;
let _imports = import_mock(&server, 1).await;
let out = ign_cmd(&config, &server.uri())
.env("EDITOR", &editor)
.env("IGNITION_LOG", "trace")
.args([
"edit",
"proj",
NESTED,
"--yes",
"--verbose",
"--verbose",
"--verbose",
])
.output()
.expect("spawn ign");
assert!(
out.status.success(),
"happy-path edit exits clean: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
out.stdout.is_empty(),
"edit must write ZERO stdout bytes (pushed, max diagnostics) — got: {:?}",
String::from_utf8_lossy(&out.stdout)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("edit would write 1 member(s) to proj")
&& stderr.contains("edit: pushed 1 member(s) to proj"),
"the blast-radius summary + pushed line render on stderr: {stderr}"
);
assert!(
stderr.contains("bogus_key"),
"expected the unknown-key warning on stderr (noise precondition): {stderr}"
);
} }
#[tokio::test]
async fn edit_without_editor_refuses_pre_resolve_with_null_profile_and_zero_requests() {
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let server = wiremock::MockServer::start().await;
{
let _gets = export_mock(&server, 0).await;
let _imports = import_mock(&server, 0).await;
let out = ign_cmd(&config, &server.uri())
.args(["edit", "proj", NESTED])
.output()
.expect("spawn ign");
assert_eq!(out.status.code(), Some(2), "usage-class refusal");
assert!(out.stdout.is_empty(), "refusals keep stdout empty");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("no $EDITOR set"),
"13-05's stable editor-env prefix: {stderr}"
);
assert!(
!stderr.contains("[profile: dev]"),
"pre-resolve refusals echo NO profile: {stderr}"
);
let out = ign_cmd(&config, &server.uri())
.args(["edit", "proj", NESTED, "--json"])
.output()
.expect("spawn ign");
assert_eq!(out.status.code(), Some(2));
assert!(out.stdout.is_empty(), "refusals keep stdout empty");
let envelope: serde_json::Value =
serde_json::from_str(stderr_envelope(&out).trim()).expect("envelope parses");
assert_eq!(envelope["ok"], serde_json::Value::Bool(false));
assert_eq!(envelope["profile"], serde_json::Value::Null);
assert_eq!(envelope["error"]["code"], "invalid_input");
let message = envelope["error"]["message"].as_str().expect("message");
assert!(
message.contains("no $EDITOR set") && message.contains("code --wait"),
"the envelope message carries the stable prefix + the IDE hint: {message}"
);
} }
#[tokio::test]
async fn edit_unknown_resource_path_refuses_naming_valid_members_before_the_editor_opens() {
let (_dir, config) = isolated_config();
let scratch = tempfile::tempdir().expect("scratch");
let server = wiremock::MockServer::start().await;
write_profile_config(&config, &server.uri());
let marker = scratch.path().join("editor-ran.marker");
let editor = editor_script(
scratch.path(),
"marker-editor",
&format!("touch {}", marker.display()),
);
{
let _gets = export_mock(&server, 1).await;
let _imports = import_mock(&server, 0).await;
let out = ign_cmd(&config, &server.uri())
.env("EDITOR", &editor)
.args(["edit", "proj", "views/Missing/thing.json"])
.output()
.expect("spawn ign");
assert_eq!(out.status.code(), Some(2), "usage-class refusal");
assert!(out.stdout.is_empty(), "refusals keep stdout empty");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("\"views/Missing/thing.json\" is not a resource member of proj"),
"the refusal names the bad path: {stderr}"
);
assert!(
stderr.contains("valid members:") && stderr.contains(VIEW) && stderr.contains(NESTED),
"the refusal lists the valid candidates: {stderr}"
);
assert!(
!marker.exists(),
"the editor never opened — the refusal fires BEFORE the edit"
);
} }
#[tokio::test]
async fn edit_guard_refusal_golden_without_yes() {
let (_dir, config) = isolated_config();
let scratch = tempfile::tempdir().expect("scratch");
let server = wiremock::MockServer::start().await;
write_profile_config(&config, &server.uri());
let editor = editor_script(scratch.path(), "set99-editor", SET99_EDITOR);
{
let _gets = export_mock(&server, 4).await;
let _imports = import_mock(&server, 0).await;
let out = ign_cmd(&config, &server.uri())
.env("EDITOR", &editor)
.args(["edit", "proj", NESTED])
.output()
.expect("spawn ign");
assert_eq!(out.status.code(), Some(2), "the guard refuses exit 2");
assert!(out.stdout.is_empty(), "refusals keep stdout empty");
let stderr = String::from_utf8_lossy(&out.stderr);
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stderr.trim_end(),
snapbox::str![[r#"
[profile: dev]
error: edit would write 1 member(s) to proj
write: com.example/views/Nested/Deep/other.json is destructive; rerun with --yes to confirm
hint: this operation is destructive; re-run with --yes or set IGNITION_YES=1
"#]],
);
let out = ign_cmd(&config, &server.uri())
.env("EDITOR", &editor)
.args(["edit", "proj", NESTED, "--json"])
.output()
.expect("spawn ign");
assert_eq!(out.status.code(), Some(2));
let envelope: serde_json::Value =
serde_json::from_str(stderr_envelope(&out).trim()).expect("envelope parses");
assert_eq!(envelope["ok"], serde_json::Value::Bool(false));
assert_eq!(envelope["profile"], "dev");
assert_eq!(envelope["error"]["code"], "confirmation_required");
let message = envelope["error"]["message"].as_str().expect("message");
assert!(
message.contains("edit would write 1 member(s) to proj")
&& message.contains(" write: com.example/views/Nested/Deep/other.json"),
"the message IS the staged diff summary: {message}"
);
} }
#[tokio::test]
async fn edit_happy_path_pushes_the_spliced_member_exactly_once() {
let (_dir, config) = isolated_config();
let scratch = tempfile::tempdir().expect("scratch");
let tmp = tempfile::tempdir().expect("tmpdir redirection");
let server = wiremock::MockServer::start().await;
write_profile_config(&config, &server.uri());
let editor = editor_script(scratch.path(), "set99-editor", SET99_EDITOR);
{
let _gets = export_mock(&server, 2).await;
let imports = import_mock(&server, 1).await;
let out = ign_cmd(&config, &server.uri())
.env("EDITOR", &editor)
.env("TMPDIR", tmp.path())
.args(["edit", "proj", NESTED, "--yes"])
.output()
.expect("spawn ign");
assert!(
out.status.success(),
"the guarded push succeeds: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(out.stdout.is_empty(), "zero stdout, always");
let requests = imports.received_requests().await;
assert_eq!(requests.len(), 1, "EXACTLY ONE import fired");
let body = &requests[0].body;
let mut archive =
zip::ZipArchive::new(std::io::Cursor::new(body.as_slice())).expect("walkable zip");
let mut read_member = |name: &str| -> String {
use std::io::Read as _;
let mut member = archive
.by_name(name)
.expect("member present in the import zip");
let mut bytes = Vec::new();
member.read_to_end(&mut bytes).expect("member reads");
String::from_utf8(bytes).expect("member is UTF-8")
};
assert_eq!(
read_member(NESTED_ZIP),
nested_json(99),
"the edited member rides the SPLICED content"
);
assert_eq!(
read_member(VIEW_ZIP),
VIEW_JSON,
"the untouched codec member rides byte-exactly"
);
assert_eq!(
read_member("project.json"),
r#"{"title":"T","enabled":true}"#,
"project.json survives the surgery"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("edit would write 1 member(s) to proj")
&& stderr.contains("edit: pushed 1 member(s) to proj"),
"the success prose renders on stderr: {stderr}"
);
assert!(
lingering_edit_tree(tmp.path()).is_none(),
"the temp edit tree is removed on success"
);
} }
#[tokio::test]
async fn edit_staleness_refusal_fires_with_and_without_yes() {
let scratch = tempfile::tempdir().expect("scratch");
let editor = editor_script(scratch.path(), "set99-editor", SET99_EDITOR);
let server = wiremock::MockServer::start().await;
let (_dir, config) = isolated_config();
write_profile_config(&config, &server.uri());
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/data/api/v1/projects/export/proj",
))
.respond_with(AlternatingResponder::new(fixture_zip(), {
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
writer
.start_file("project.json", options)
.expect("project.json starts");
writer
.write_all(br#"{"title":"T","enabled":true}"#)
.expect("project.json writes");
writer
.start_file(VIEW_ZIP, options)
.expect("view member starts");
writer.write_all(VIEW_JSON.as_bytes()).expect("writes");
writer
.start_file(NESTED_ZIP, options)
.expect("nested member starts");
writer
.write_all(nested_json(43).as_bytes())
.expect("writes");
writer.finish().expect("zip finalizes").into_inner()
}))
.expect(4)
.mount(&server)
.await;
{
let _imports = import_mock(&server, 0).await;
let out = ign_cmd(&config, &server.uri())
.env("EDITOR", &editor)
.args(["edit", "proj", NESTED])
.output()
.expect("spawn ign");
assert_eq!(out.status.code(), Some(2), "stale refusals exit 2");
assert!(out.stdout.is_empty(), "refusals keep stdout empty");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains(NESTED) && stderr.contains("changed on gateway since fetch"),
"the stable staleness message names the member: {stderr}"
);
assert!(
stderr.contains("re-run to fetch fresh"),
"the recovery advice rides the message: {stderr}"
);
let out = ign_cmd(&config, &server.uri())
.env("EDITOR", &editor)
.args(["edit", "proj", NESTED, "--yes"])
.output()
.expect("spawn ign");
assert_eq!(
out.status.code(),
Some(2),
"staleness refuses EVEN WITH --yes"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("changed on gateway since fetch"),
"the same refusal with --yes: {stderr}"
);
} }
#[tokio::test]
async fn edit_fail_closed_keeps_the_tree_at_the_printed_path() {
let (_dir, config) = isolated_config();
let scratch = tempfile::tempdir().expect("scratch");
let tmp = tempfile::tempdir().expect("tmpdir redirection");
let server = wiremock::MockServer::start().await;
write_profile_config(&config, &server.uri());
let editor = editor_script(scratch.path(), "breaker-editor", BREAKER_EDITOR);
{
let _gets = export_mock(&server, 1).await;
let _imports = import_mock(&server, 0).await;
let out = ign_cmd(&config, &server.uri())
.env("EDITOR", &editor)
.env("TMPDIR", tmp.path())
.args(["edit", "proj", VIEW])
.output()
.expect("spawn ign");
assert_eq!(out.status.code(), Some(2), "fail-closed refuses exit 2");
assert!(out.stdout.is_empty(), "refusals keep stdout empty");
let stderr = String::from_utf8_lossy(&out.stderr);
let marker = "preserved at ";
assert!(
stderr.contains(marker) && stderr.contains("fix the member by hand and re-run"),
"the kept-tree recovery clause renders: {stderr}"
);
let at = stderr.find(marker).expect("marker present");
let raw = &stderr[at + marker.len()..];
let kept = PathBuf::from(raw.split(" — ").next().expect("path before the dash"));
assert!(kept.exists(), "the kept edit tree exists: {kept:?}");
assert!(
kept.starts_with(tmp.path()),
"the kept path is the redirected TMPDIR tree: {kept:?}"
);
let broken =
std::fs::read_to_string(kept.join(VIEW_ZIP)).expect("the broken member survives");
assert!(
broken.contains("{ broken"),
"the user's (broken) edit is preserved for hand-repair: {broken:?}"
);
} }
#[tokio::test]
async fn edit_noop_is_clean_and_cleans_its_temp_tree() {
let (_dir, config) = isolated_config();
let scratch = tempfile::tempdir().expect("scratch");
let tmp = tempfile::tempdir().expect("tmpdir redirection");
let server = wiremock::MockServer::start().await;
write_profile_config(&config, &server.uri());
let editor = editor_script(scratch.path(), "noop-editor", NOOP_EDITOR);
{
let _gets = export_mock(&server, 1).await;
let _imports = import_mock(&server, 0).await;
let out = ign_cmd(&config, &server.uri())
.env("EDITOR", &editor)
.env("TMPDIR", tmp.path())
.args(["edit", "proj", NESTED])
.output()
.expect("spawn ign");
assert!(
out.status.success(),
"the no-op exits clean: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(out.stdout.is_empty(), "zero stdout, always");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("edit: no changes — nothing pushed"),
"the no-op prose renders on stderr: {stderr}"
);
assert!(
lingering_edit_tree(tmp.path()).is_none(),
"the temp edit tree is removed on the no-op path"
);
} }