atomwrite 0.1.38

Atomic file operations CLI for LLM agents — read, write, edit, search, replace with NDJSON output
// SPDX-License-Identifier: MIT OR Apache-2.0

mod common;

use std::sync::Mutex;

/// Tests for G119 (WAL cleanup) and G120 (empty-stdin guard).
///
/// Process-wide test isolation for `ATOMWRITE_WAL` and journal sidecars.
/// Uses `static Mutex` with the const constructor (`Mutex::new` since 1.63),
/// not `LazyLock` / `lazy_static!`. Poisoning is treated as fatal via
/// `.expect` so a panicking test does not silently skip isolation.
static WAL_ENV_LOCK: Mutex<()> = Mutex::new(());

fn journal_path_for(target: &std::path::Path) -> std::path::PathBuf {
    let dir = target.parent().unwrap_or_else(|| std::path::Path::new("."));
    let basename = target
        .file_name()
        .and_then(|s| s.to_str())
        .unwrap_or("unknown");
    dir.join(format!(
        ".atomwrite.journal.{}{}",
        basename, ".atomwrite.journal.json"
    ))
}

/// G119 L2: a successful `write` with `ATOMWRITE_WAL=1` must NOT leave
/// a sidecar behind (the Drop guard removes the sidecar on scope exit
/// after the `Committed` entry is appended).
#[test]
fn g119_l2_write_with_wal_leaves_no_sidecar_on_success() {
    let _guard = WAL_ENV_LOCK.lock().expect("lock");
    let dir = tempfile::tempdir().expect("tempdir");
    let target = dir.path().join("target.txt");
    std::fs::write(&target, "seed").expect("seed");

    let output = common::atomwrite()
        .args(["--workspace", dir.path().to_str().unwrap(), "write"])
        .arg(&target)
        .env("ATOMWRITE_WAL", "1")
        .write_stdin("new content")
        .output()
        .expect("run");
    assert!(output.status.success(), "exit: {:?}", output.status);

    let sidecar = journal_path_for(&target);
    assert!(
        !sidecar.exists(),
        "sidecar must be removed by L2 Drop guard after success, found: {}",
        sidecar.display()
    );
}

/// G119 L2 (v0.1.38): a directory that denies new entries no longer fails the
/// write. The rename path needs a tempfile beside the target, so the write
/// switches to in-place and succeeds on the file's own permission, while the
/// backup that could not be created is reported instead of answering exit 13.
#[test]
fn g119_l2_write_in_readonly_dir_succeeds_in_place() {
    let _guard = WAL_ENV_LOCK.lock().expect("lock");
    let dir = tempfile::tempdir().expect("tempdir");
    let target = dir.path().join("target.txt");
    std::fs::write(&target, "seed").expect("seed");

    // A directory that denies new entries: no tempfile and no backup can be
    // created beside the target, so the write has to take the in-place path.
    let mut perms = std::fs::metadata(dir.path()).expect("meta").permissions();
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        perms.set_mode(0o555);
    }
    std::fs::set_permissions(dir.path(), perms).expect("chmod 0555");

    let output = common::atomwrite()
        .args(["--workspace", dir.path().to_str().unwrap(), "write"])
        .arg(&target)
        .env("ATOMWRITE_WAL", "1")
        .write_stdin("new content")
        .output()
        .expect("run");

    // Restore permissions so tempdir cleanup can run.
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mut p = std::fs::metadata(dir.path()).expect("meta").permissions();
        p.set_mode(0o755);
        std::fs::set_permissions(dir.path(), p).expect("restore");
    }

    assert!(
        output.status.success(),
        "write must succeed in a 0555 dir through the in-place path, got: {:?}; stderr: {}",
        output.status,
        String::from_utf8_lossy(&output.stderr)
    );
    assert_eq!(
        std::fs::read_to_string(&target).expect("read back"),
        "new content",
        "the write must reach the file even when the rename path is unavailable"
    );

    // The backup could not be created beside a target in a directory that
    // denies new entries, so the run has to say so on stderr instead of
    // replacing the file's content with a refusal.
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("backup skipped"),
        "the skipped backup must be reported on stderr, got: {stderr}"
    );

    // Journaling may not have created a sidecar at all under these
    // permissions; what this asserts is that a successful write leaves none
    // behind, which is the L2 Drop guard's contract.
    let sidecar = journal_path_for(&target);
    assert!(
        !sidecar.exists(),
        "a successful write must leave no sidecar, found: {}",
        sidecar.display()
    );
}

/// G119 L5: `wal-stats` returns a NDJSON snapshot with the documented
/// fields and counts existing sidecars correctly.
#[test]
fn g119_l5_wal_stats_reports_journal_state() {
    let _guard = WAL_ENV_LOCK.lock().expect("lock");
    let dir = tempfile::tempdir().expect("tempdir");
    let target = dir.path().join("target.txt");
    std::fs::write(&target, "seed").expect("seed");

    // Run a write with WAL to create one Committed sidecar (which L2
    // will then auto-remove). For this test we want a sidecar that
    // survives, so write a sidecar manually.
    let sidecar = journal_path_for(&target);
    std::fs::write(
        &sidecar,
        "{\"phase\":\"started\",\"op_id\":\"abc123\",\"started_at_unix\":1700000000}\n\
         {\"phase\":\"committed\",\"op_id\":\"abc123\",\"committed_at_unix\":1700000001}\n",
    )
    .expect("seed sidecar");

    let output = common::atomwrite()
        .args([
            "--workspace",
            dir.path().to_str().unwrap(),
            "--no-auto-heal",
            "wal-stats",
        ])
        .output()
        .expect("run");
    assert!(output.status.success(), "exit: {:?}", output.status);

    let events = common::parse_ndjson(&output.stdout);
    assert_eq!(events.len(), 1);
    let stats = &events[0];
    assert!(stats["total_journals"].is_u64());
    assert!(stats["by_state"].is_object());
    assert!(stats["by_state"]["committed"].is_u64());
    assert!(stats["by_directory"].is_array());
    assert!(stats["auto_heal_recommended"].is_boolean());

    let committed = stats["by_state"]["committed"].as_u64().unwrap();
    assert!(
        committed >= 1,
        "expected at least 1 committed, got {committed}"
    );
}

/// G119 L5: `wal-stats` on an empty workspace reports zeros.
#[test]
fn g119_l5_wal_stats_reports_zeros_on_empty_workspace() {
    let _guard = WAL_ENV_LOCK.lock().expect("lock");
    let dir = tempfile::tempdir().expect("tempdir");

    let output = common::atomwrite()
        .args([
            "--workspace",
            dir.path().to_str().unwrap(),
            "--no-auto-heal",
            "wal-stats",
        ])
        .output()
        .expect("run");
    assert!(output.status.success());

    let events = common::parse_ndjson(&output.stdout);
    assert_eq!(events[0]["total_journals"], 0);
    assert_eq!(events[0]["by_state"]["committed"], 0);
    assert_eq!(events[0]["by_state"]["started"], 0);
    assert_eq!(events[0]["oldest_journal_age_secs"], 0);
    assert_eq!(events[0]["total_size_bytes"], 0);
    assert_eq!(events[0]["auto_heal_recommended"], false);
}

/// G119 L3 (v0.1.17): the startup `wal-heal` pass reaps stale `Committed`
/// sidecars WITHOUT requiring an explicit `wal-heal` subcommand. The
/// workspace is seeded with a stale journal beside the target, the binary
/// is invoked with a mutating subcommand (`write`), and we assert the
/// sidecar is gone afterwards. Since v0.1.37 a read-only verb no longer
/// runs the pass (see `cli_v0137_wal_startup_heal.rs`).
#[test]
fn g119_l3_startup_auto_heal_reaps_stale_committed() {
    let _guard = WAL_ENV_LOCK.lock().expect("lock");
    let dir = tempfile::tempdir().expect("tempdir");
    let target = dir.path().join("target.txt");
    std::fs::write(&target, "seed").expect("seed");
    let sidecar = journal_path_for(&target);
    // Committed_at_unix = 1 (1970-01-01) — trivially older than any
    // reasonable threshold. The L3 pass with the default 3600s
    // threshold will reap this on startup.
    std::fs::write(
        &sidecar,
        "{\"phase\":\"started\",\"op_id\":\"abc\",\"started_at_unix\":1}\n\
         {\"phase\":\"committed\",\"op_id\":\"abc\",\"committed_at_unix\":1}\n",
    )
    .expect("seed");

    let output = common::atomwrite()
        .args([
            "--workspace",
            dir.path().to_str().unwrap(),
            "write",
            "--no-backup",
        ])
        .arg(&target)
        .write_stdin("rewritten")
        .output()
        .expect("run");
    assert!(
        output.status.success(),
        "write must succeed even when L3 runs first; stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    assert!(
        !sidecar.exists(),
        "L3 startup pass must reap the stale Committed sidecar"
    );
}

/// G119 L3 (v0.1.17): `--no-auto-heal` disables the startup pass. A
/// stale `Committed` sidecar survives when the flag is set.
#[test]
fn g119_l3_no_auto_heal_preserves_stale_committed() {
    let _guard = WAL_ENV_LOCK.lock().expect("lock");
    let dir = tempfile::tempdir().expect("tempdir");
    let target = dir.path().join("target.txt");
    std::fs::write(&target, "seed").expect("seed");
    let sidecar = journal_path_for(&target);
    std::fs::write(
        &sidecar,
        "{\"phase\":\"started\",\"op_id\":\"abc\",\"started_at_unix\":1}\n\
         {\"phase\":\"committed\",\"op_id\":\"abc\",\"committed_at_unix\":1}\n",
    )
    .expect("seed");

    let output = common::atomwrite()
        .args([
            "--workspace",
            dir.path().to_str().unwrap(),
            "--no-auto-heal",
            "read",
        ])
        .arg(&target)
        .output()
        .expect("run");
    assert!(output.status.success());

    assert!(
        sidecar.exists(),
        "--no-auto-heal must leave the stale sidecar untouched"
    );
}

/// G119 L4 (v0.1.17): the L4 `h4_sentinel` heuristic preserves the
/// sidecar when `.atomwrite_no_wal` exists in the parent directory.
/// A successful write under a sentinel directory does NOT remove the
/// sidecar (L2 respects the L4 vote).
#[test]
fn g119_l4_sentinel_preserves_sidecar_on_successful_write() {
    let _guard = WAL_ENV_LOCK.lock().expect("lock");
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join(".atomwrite_no_wal"), "").expect("sentinel");

    let target = dir.path().join("with_sentinel.txt");
    std::fs::write(&target, "seed").expect("seed");

    // Use `--wal-policy always` to force the sidecar (otherwise L1
    // Auto would suppress it for a small file in a fresh tmp dir).
    let output = common::atomwrite()
        .args([
            "--workspace",
            dir.path().to_str().unwrap(),
            "--no-auto-heal",
            "write",
            "--wal-policy",
            "always",
        ])
        .arg(&target)
        .write_stdin("new content")
        .output()
        .expect("run");
    assert!(
        output.status.success(),
        "write must succeed; stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let sidecar = journal_path_for(&target);
    assert!(
        sidecar.exists(),
        "L4 h4_sentinel must preserve the sidecar even after a successful write"
    );
}