use iron_shell::value::Value;
use std::collections::BTreeMap;
fn call(name: &str, args: Vec<Value>) -> anyhow::Result<Value> {
let mut env = iron_shell::env::Env::new();
iron_shell::builtins::call(name, args, &mut env)
}
static TX_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct TxCleanup;
impl Drop for TxCleanup {
fn drop(&mut self) {
while iron_shell::tx::rollback().is_ok() {}
}
}
fn workspace() -> std::path::PathBuf {
let w = std::env::temp_dir().join(format!("ae-txc-ws-{}", std::process::id()));
std::fs::create_dir_all(&w).unwrap();
std::env::set_var("IRONSHELL_WORKSPACE", &w);
w
}
fn scratch_path(name: &str) -> std::path::PathBuf {
workspace().join(name)
}
fn text(v: &str) -> Value {
Value::Str(v.to_string())
}
fn path_arg(p: &std::path::Path) -> Value {
Value::Str(p.to_string_lossy().into_owned())
}
#[test]
fn file_patch_is_undone_by_rollback() {
let _lock = TX_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _cleanup = TxCleanup;
let p = scratch_path("patch.txt");
std::fs::write(&p, "alpha beta\n").unwrap();
let patch = Value::Record(BTreeMap::from([
("find".to_string(), text("alpha")),
("replace".to_string(), text("omega")),
]));
iron_shell::tx::begin().unwrap();
call("file_patch", vec![path_arg(&p), Value::Array(vec![patch])]).unwrap();
assert_eq!(std::fs::read_to_string(&p).unwrap(), "omega beta\n");
iron_shell::tx::rollback().unwrap();
assert_eq!(std::fs::read_to_string(&p).unwrap(), "alpha beta\n");
let _ = std::fs::remove_file(&p);
}
#[test]
fn file_insert_is_undone_by_rollback() {
let _lock = TX_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _cleanup = TxCleanup;
let p = scratch_path("insert.txt");
std::fs::write(&p, "alpha\nbeta\n").unwrap();
iron_shell::tx::begin().unwrap();
call(
"file_insert",
vec![path_arg(&p), Value::Int(1), text("new")],
)
.unwrap();
assert_eq!(std::fs::read_to_string(&p).unwrap(), "new\nalpha\nbeta\n");
iron_shell::tx::rollback().unwrap();
assert_eq!(std::fs::read_to_string(&p).unwrap(), "alpha\nbeta\n");
let _ = std::fs::remove_file(&p);
}
#[test]
fn file_move_restores_source_and_removes_new_destination() {
let _lock = TX_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _cleanup = TxCleanup;
let src = scratch_path("move-src.txt");
let dst = scratch_path("move-dst.txt");
std::fs::write(&src, "moving\n").unwrap();
let _ = std::fs::remove_file(&dst);
iron_shell::tx::begin().unwrap();
call("file_move", vec![path_arg(&src), path_arg(&dst)]).unwrap();
assert!(!src.exists());
assert_eq!(std::fs::read_to_string(&dst).unwrap(), "moving\n");
iron_shell::tx::rollback().unwrap();
assert_eq!(std::fs::read_to_string(&src).unwrap(), "moving\n");
assert!(
!dst.exists(),
"rollback must remove the created destination"
);
let _ = std::fs::remove_file(&src);
}
#[test]
fn file_move_restores_a_destination_it_replaced() {
let _lock = TX_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _cleanup = TxCleanup;
let src = scratch_path("replace-src.txt");
let dst = scratch_path("replace-dst.txt");
std::fs::write(&src, "new\n").unwrap();
std::fs::write(&dst, "old\n").unwrap();
iron_shell::tx::begin().unwrap();
call("file_move", vec![path_arg(&src), path_arg(&dst)]).unwrap();
iron_shell::tx::rollback().unwrap();
assert_eq!(std::fs::read_to_string(&src).unwrap(), "new\n");
assert_eq!(std::fs::read_to_string(&dst).unwrap(), "old\n");
let _ = std::fs::remove_file(&src);
let _ = std::fs::remove_file(&dst);
}
#[test]
fn save_json_is_undone_by_rollback() {
let _lock = TX_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _cleanup = TxCleanup;
let existing = scratch_path("save-existing.json");
let created = scratch_path("save-created.json");
std::fs::write(&existing, "\"before\"").unwrap();
let _ = std::fs::remove_file(&created);
iron_shell::tx::begin().unwrap();
call("save_json", vec![path_arg(&existing), text("after")]).unwrap();
call("save_json", vec![path_arg(&created), text("made")]).unwrap();
assert!(created.exists());
iron_shell::tx::rollback().unwrap();
assert_eq!(std::fs::read_to_string(&existing).unwrap(), "\"before\"");
assert!(!created.exists(), "rollback must remove the created file");
let _ = std::fs::remove_file(&existing);
}
#[test]
fn platform_db_export_over_a_file_is_undone_by_rollback() {
let _lock = TX_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _cleanup = TxCleanup;
let home = scratch_path("pdb-home");
std::fs::create_dir_all(&home).unwrap();
let saved_home = std::env::var_os("HOME");
std::env::set_var("HOME", &home);
let existing = scratch_path("pdb-existing.json");
std::fs::write(&existing, "before").unwrap();
iron_shell::tx::begin().unwrap();
let written = call("platform_db_export", vec![path_arg(&existing)]);
let replaced = std::fs::read_to_string(&existing).unwrap();
iron_shell::tx::rollback().unwrap();
match saved_home {
Some(h) => std::env::set_var("HOME", h),
None => std::env::remove_var("HOME"),
}
written.unwrap();
assert_eq!(replaced, "{}");
assert_eq!(std::fs::read_to_string(&existing).unwrap(), "before");
let _ = std::fs::remove_file(&existing);
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn db_sqlite_to_csv_over_a_file_is_undone_by_rollback() {
let have_sqlite3 = std::process::Command::new("sqlite3")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !have_sqlite3 {
eprintln!("skipping: sqlite3 not on PATH");
return;
}
let _lock = TX_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _cleanup = TxCleanup;
let db = scratch_path("csv-export.db");
let _ = std::fs::remove_file(&db);
let made = std::process::Command::new("sqlite3")
.arg(&db)
.arg("create table t(a integer); insert into t values (7);")
.output()
.unwrap();
assert!(made.status.success(), "{made:?}");
let existing = scratch_path("csv-existing.csv");
std::fs::write(&existing, "before").unwrap();
iron_shell::tx::begin().unwrap();
let written = call(
"db_sqlite_to_csv",
vec![path_arg(&db), text("t"), path_arg(&existing)],
);
let replaced = std::fs::read_to_string(&existing).unwrap();
iron_shell::tx::rollback().unwrap();
written.unwrap();
let rows: Vec<&str> = replaced.lines().map(|l| l.trim_end_matches('\r')).collect();
assert_eq!(rows, ["a", "7"], "{replaced:?}");
assert_eq!(std::fs::read_to_string(&existing).unwrap(), "before");
let _ = std::fs::remove_file(&existing);
let _ = std::fs::remove_file(&db);
}
#[test]
fn a_journal_directory_that_cannot_be_made_is_a_coded_error() {
let _lock = TX_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _cleanup = TxCleanup;
let file = scratch_path("not-a-workspace");
std::fs::write(&file, "x").unwrap();
std::env::set_var("IRONSHELL_WORKSPACE", &file);
let result = iron_shell::tx::begin();
workspace();
let _ = std::fs::remove_file(&file);
let err = result.expect_err("begin must fail under a file");
assert!(
err.downcast_ref::<iron_shell::safety::SafetyError>()
.is_some(),
"the failure must carry a code: {err}"
);
}
#[test]
fn zip_extract_is_refused_inside_a_transaction() {
let _lock = TX_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _cleanup = TxCleanup;
let dest = scratch_path("extract-dest");
iron_shell::tx::begin().unwrap();
let err = call(
"zip_extract",
vec![text("no-such-archive.zip"), path_arg(&dest)],
)
.expect_err("extraction cannot be rolled back, so it must be refused");
assert!(
format!("{err:#}").contains("cannot be rolled back"),
"{err:#}"
);
assert!(!dest.exists(), "a refused extraction must write nothing");
iron_shell::tx::rollback().unwrap();
}
#[test]
fn a_rollback_that_cannot_restore_says_so_and_keeps_its_journal() {
let _lock = TX_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _cleanup = TxCleanup;
let p = scratch_path("lost-backup.txt");
std::fs::write(
&p, "before
",
)
.unwrap();
let id = iron_shell::tx::begin().unwrap();
call(
"file_write",
vec![
path_arg(&p),
text(
"after
",
),
],
)
.unwrap();
let journal = workspace().join(".ironshell").join("tx").join(&id);
for entry in std::fs::read_dir(&journal).unwrap() {
std::fs::remove_file(entry.unwrap().path()).unwrap();
}
let err = iron_shell::tx::rollback().expect_err("nothing could be restored");
let code = err
.downcast_ref::<iron_shell::safety::SafetyError>()
.map(|s| s.code);
assert_eq!(code, Some(iron_shell::safety::ErrorCode::Io), "{err:#}");
assert!(format!("{err:#}").contains("lost-backup.txt"), "{err:#}");
assert!(journal.exists(), "the journal is kept for recovery");
assert!(!iron_shell::tx::is_active(), "the transaction still ended");
let _ = std::fs::remove_dir_all(&journal);
let _ = std::fs::remove_file(&p);
}
#[test]
fn gzip_decompress_output_created_in_a_transaction_is_removed_by_rollback() {
let _lock = TX_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _cleanup = TxCleanup;
let plain = scratch_path("gzrb.txt");
let gz = scratch_path("gzrb.txt.gz");
let _ = std::fs::remove_file(&gz);
std::fs::write(&plain, "payload\n").unwrap();
call("gzip_compress", vec![path_arg(&plain)]).unwrap();
assert!(gz.exists());
std::fs::remove_file(&plain).unwrap();
iron_shell::tx::begin().unwrap();
iron_shell::builtins::__test_gzip_decompress_capped(gz.to_str().unwrap(), 1 << 20).unwrap();
assert_eq!(std::fs::read_to_string(&plain).unwrap(), "payload\n");
iron_shell::tx::rollback().unwrap();
assert!(
!plain.exists(),
"rollback must remove the decompressed output"
);
assert!(gz.exists(), "the compressed input is not touched");
let _ = std::fs::remove_file(&gz);
}
#[cfg(unix)]
#[test]
fn a_rollback_that_cannot_clear_a_directory_reports_it() {
use std::os::unix::fs::PermissionsExt;
let _l = TX_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _c = TxCleanup;
let dir = scratch_path("rollback-dir-clear");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let keep = dir.join("keep.txt");
std::fs::write(&keep, "before").unwrap();
iron_shell::tx::begin().unwrap();
iron_shell::tx::snapshot(&dir.to_string_lossy());
std::fs::write(&keep, "after").unwrap();
let stuck = dir.join("added");
std::fs::create_dir(&stuck).unwrap();
std::fs::write(stuck.join("x"), "x").unwrap();
std::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o555)).unwrap();
let is_root = std::fs::write(stuck.join("probe"), "").is_ok();
let rolled = iron_shell::tx::rollback();
let _ = std::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o755));
let restored = std::fs::read_to_string(&keep).unwrap();
let _ = std::fs::remove_dir_all(&dir);
if is_root {
eprintln!("skipping: root ignores the directory's mode");
return;
}
let err = rolled.expect_err("the added file survived, so this is not a clean rollback");
assert!(
format!("{err:#}").contains("could not be cleared"),
"{err:#}"
);
assert_eq!(restored, "before", "what can be restored still is");
}
#[cfg(unix)]
#[test]
fn a_transaction_backup_of_a_private_file_is_private() {
use std::os::unix::fs::PermissionsExt;
let _l = TX_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _c = TxCleanup;
let p = scratch_path("private-backup.txt");
std::fs::write(&p, "secret").unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).unwrap();
let id = iron_shell::tx::begin().unwrap();
iron_shell::tx::snapshot(&p.to_string_lossy());
let journal = workspace().join(".ironshell").join("tx").join(&id);
let mode = |m: &std::fs::Metadata| m.permissions().mode() & 0o777;
assert_eq!(mode(&std::fs::metadata(&journal).unwrap()), 0o700);
let mut backups = 0;
for entry in std::fs::read_dir(&journal).unwrap() {
let meta = entry.unwrap().metadata().unwrap();
if meta.is_file() {
backups += 1;
assert_eq!(meta.permissions().mode() & 0o077, 0);
}
}
assert!(backups > 0, "the snapshot kept a backup");
std::fs::write(&p, "overwritten").unwrap();
iron_shell::tx::rollback().unwrap();
assert_eq!(std::fs::read_to_string(&p).unwrap(), "secret");
assert_eq!(mode(&std::fs::metadata(&p).unwrap()), 0o600);
let _ = std::fs::remove_file(&p);
}