iron-shell 14.0.2

The world's first multi-agent shell with typed functional pipelines and multi-modal AI
//! A rollback undoes file_patch, file_insert, file_move, save_json,
//! platform_db_export and db_sqlite_to_csv, and an archive extraction, which it could not undo, is
//! refused inside a transaction. These run in their own process because a transaction is
//! process-wide state: in the library's unit tests, an open one would have
//! journaled, and rolled back, the writes of tests running beside it.

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)
}
/// Transactions are process-wide state, so these tests take one at a time.
static TX_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

/// Ends whatever transaction a failing assertion left open, so a panic in
/// one test cannot leak into the next. A clean run has none to end.
struct TxCleanup;

impl Drop for TxCleanup {
    fn drop(&mut self) {
        while iron_shell::tx::rollback().is_ok() {}
    }
}

/// The workspace these tests run in. The journal lives under
/// `<workspace>/.ironshell/tx`, so without this it went into whatever directory the
/// binary was started from, the repository included.
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;
    // The store is read from HOME; point it at an empty one, never the user's.
    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);
}

/// db_sqlite_to_csv wrote its output with a bare fs::write: a transaction
/// could not restore the file it replaced.
#[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;
    // A workspace that is a file: the journal directory beneath it cannot exist.
    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();
    // Lose the backup, as a full disk or a mount that refused the copy would.
    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);
}

/// gzip_decompress's output comes out of create_new_output, which snapshots the
/// path it creates. The builtin snapshots that path itself first, which would
/// hide that line, so this goes through the test hook: gzip_decompress without
/// the builtin wrapper. Nothing else records the output's absence, so the
/// rollback removes it only because create_new_output did.
#[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);
}

/// A file the transaction added beneath a directory that cannot be cleared
/// survives the rollback, so the rollback must not report success.
#[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");
}

/// A transaction's backups hold prior file contents, so they are private to the
/// user: the backup directory is 0700 and no backup file is open to group or
/// others. A rollback puts the content back, under the file's 0600 mode.
#[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);
}