atomwrite 0.1.2

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

mod common;

#[test]
fn read_returns_content_and_checksum() {
    let dir = tempfile::tempdir().expect("tempdir");
    let path = common::create_test_file(dir.path(), "hello.txt", "hello world\n");

    let output = common::atomwrite()
        .args(["--workspace", dir.path().to_str().unwrap(), "read"])
        .arg(&path)
        .output()
        .expect("run");

    assert!(output.status.success());

    let events = common::parse_ndjson(&output.stdout);
    assert_eq!(events.len(), 1);
    assert_eq!(events[0]["type"], "read");
    assert_eq!(events[0]["content"], "hello world\n");
    assert_eq!(events[0]["bytes"], 12);
    assert_eq!(events[0]["lines"], 1);
    assert!(events[0]["checksum"].is_string());
    assert_eq!(events[0]["binary"], false);
}

#[test]
fn read_stat_omits_content() {
    let dir = tempfile::tempdir().expect("tempdir");
    let path = common::create_test_file(dir.path(), "stat.txt", "data\n");

    let output = common::atomwrite()
        .args([
            "--workspace",
            dir.path().to_str().unwrap(),
            "read",
            "--stat",
        ])
        .arg(&path)
        .output()
        .expect("run");

    assert!(output.status.success());
    let events = common::parse_ndjson(&output.stdout);
    assert!(events[0]["content"].is_null());
    assert_eq!(events[0]["bytes"], 5);
}

#[test]
fn read_lines_range() {
    let dir = tempfile::tempdir().expect("tempdir");
    let path = common::create_test_file(dir.path(), "lines.txt", "a\nb\nc\nd\ne\n");

    let output = common::atomwrite()
        .args([
            "--workspace",
            dir.path().to_str().unwrap(),
            "read",
            "--lines",
            "2:4",
        ])
        .arg(&path)
        .output()
        .expect("run");

    assert!(output.status.success());
    let events = common::parse_ndjson(&output.stdout);
    assert_eq!(events[0]["content"], "b\nc\nd\n");
}

#[test]
fn read_format_raw_emits_bytes() {
    let dir = tempfile::tempdir().expect("tempdir");
    let path = common::create_test_file(dir.path(), "raw.txt", "raw content\n");

    let output = common::atomwrite()
        .args([
            "--workspace",
            dir.path().to_str().unwrap(),
            "read",
            "--format",
            "raw",
        ])
        .arg(&path)
        .output()
        .expect("run");

    assert!(output.status.success());
    assert_eq!(String::from_utf8_lossy(&output.stdout), "raw content\n");
}

#[test]
fn read_not_found_exits_4() {
    let dir = tempfile::tempdir().expect("tempdir");

    let output = common::atomwrite()
        .args(["--workspace", dir.path().to_str().unwrap(), "read"])
        .arg(dir.path().join("nonexistent.txt"))
        .output()
        .expect("run");

    assert_eq!(output.status.code(), Some(4));
    let events = common::parse_ndjson(&output.stdout);
    assert_eq!(events[0]["code"], "FILE_NOT_FOUND");
}

#[test]
fn read_binary_file_detected() {
    let dir = tempfile::tempdir().expect("tempdir");
    let path = dir.path().join("binary.bin");
    std::fs::write(&path, b"\x00\x01\x02\x03binary").expect("write");

    let output = common::atomwrite()
        .args(["--workspace", dir.path().to_str().unwrap(), "read"])
        .arg(&path)
        .output()
        .expect("run");

    assert!(output.status.success());
    let events = common::parse_ndjson(&output.stdout);
    assert_eq!(events[0]["binary"], true);
    assert!(events[0]["content"].is_null());
}

// --- GAP 01: --json flag aceita como no-op ---

#[test]
fn read_with_json_flag_is_noop() {
    let dir = tempfile::tempdir().expect("tempdir");
    let path = common::create_test_file(dir.path(), "json_test.txt", "data\n");

    let with_json = common::atomwrite()
        .args([
            "--workspace",
            dir.path().to_str().unwrap(),
            "read",
            "--json",
        ])
        .arg(&path)
        .output()
        .expect("run");

    assert!(with_json.status.success(), "exit: {:?}", with_json.status);

    let without_json = common::atomwrite()
        .args(["--workspace", dir.path().to_str().unwrap(), "read"])
        .arg(&path)
        .output()
        .expect("run");

    assert!(without_json.status.success());
    let events_with = common::parse_ndjson(&with_json.stdout);
    let events_without = common::parse_ndjson(&without_json.stdout);
    assert_eq!(events_with[0]["type"], events_without[0]["type"]);
    assert_eq!(events_with[0]["content"], events_without[0]["content"]);
    assert_eq!(events_with[0]["checksum"], events_without[0]["checksum"]);
}

// --- GAP 07: erros Clap emitem JSON estruturado ---

#[test]
fn invalid_arg_emits_json_error() {
    let dir = tempfile::tempdir().expect("tempdir");
    common::create_test_file(dir.path(), "dummy.txt", "x");

    let output = common::atomwrite()
        .args([
            "--workspace",
            dir.path().to_str().unwrap(),
            "read",
            "--nonexistent-flag",
        ])
        .arg(dir.path().join("dummy.txt"))
        .output()
        .expect("run");

    assert_eq!(output.status.code(), Some(2));
    let events = common::parse_ndjson(&output.stdout);
    assert!(!events.is_empty(), "stdout should contain JSON error");
    assert_eq!(events[0]["error"], true);
    assert_eq!(events[0]["code"], "ARGUMENT_PARSE_ERROR");
    assert_eq!(events[0]["exit"], 2);
    assert_eq!(events[0]["retryable"], false);
    assert_eq!(events[0]["error_class"], "permanent");
}

#[test]
fn help_flag_still_works() {
    let output = common::atomwrite().args(["--help"]).output().expect("run");

    assert!(output.status.success());
    let stderr = String::from_utf8_lossy(&output.stderr);
    let stdout = String::from_utf8_lossy(&output.stdout);
    let combined = format!("{stdout}{stderr}");
    assert!(
        combined.contains("atomwrite") || combined.contains("Usage"),
        "help should mention atomwrite or Usage"
    );
}

// --- GAP 10: caminhos relativos com workspace ---

#[test]
fn read_relative_path_with_workspace() {
    let dir = tempfile::tempdir().expect("tempdir");
    common::create_test_file(dir.path(), "rel_test.txt", "relative content\n");

    let output = common::atomwrite()
        .args([
            "--workspace",
            dir.path().to_str().unwrap(),
            "read",
            "rel_test.txt",
        ])
        .output()
        .expect("run");

    assert!(
        output.status.success(),
        "relative path should work with absolute workspace, stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let events = common::parse_ndjson(&output.stdout);
    assert_eq!(events[0]["content"], "relative content\n");
}

// --- GAP 08: --json-schema sem argumentos obrigatórios ---

#[test]
fn json_schema_write_without_args() {
    let output = common::atomwrite()
        .args(["write", "--json-schema"])
        .output()
        .expect("run");

    assert!(
        output.status.success(),
        "write --json-schema should work without <TARGET>, stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let stdout = String::from_utf8_lossy(&output.stdout);
    let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON schema");
    assert!(parsed.is_object());
}

#[test]
fn json_schema_transform_without_args() {
    let output = common::atomwrite()
        .args(["transform", "--json-schema"])
        .output()
        .expect("run");

    assert!(
        output.status.success(),
        "transform --json-schema should work without --pattern/--rewrite/--language"
    );
    let stdout = String::from_utf8_lossy(&output.stdout);
    let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON schema");
    assert!(parsed.is_object());
}

#[test]
fn json_schema_all_subcommands_no_args() {
    let subcommands = [
        "read",
        "write",
        "edit",
        "search",
        "replace",
        "hash",
        "delete",
        "count",
        "diff",
        "move",
        "copy",
        "list",
        "extract",
        "calc",
        "regex",
        "transform",
        "scope",
        "batch",
        "backup",
        "rollback",
        "apply",
        "completions",
    ];

    for cmd in &subcommands {
        let output = common::atomwrite()
            .args([cmd, "--json-schema"])
            .output()
            .unwrap_or_else(|e| panic!("{cmd} --json-schema failed to run: {e}"));

        assert!(
            output.status.success(),
            "{cmd} --json-schema exited with {:?}",
            output.status.code()
        );
        let stdout = String::from_utf8_lossy(&output.stdout);
        let parsed: Result<serde_json::Value, _> = serde_json::from_str(&stdout);
        assert!(
            parsed.is_ok(),
            "{cmd} --json-schema produced invalid JSON: {stdout}"
        );
    }
}

#[test]
fn lang_flag_does_not_alter_json_output() {
    let dir = tempfile::tempdir().expect("tempdir");
    let path = common::create_test_file(dir.path(), "lang.txt", "test content\n");
    let ws = dir.path().to_str().unwrap();

    let out_en = common::atomwrite()
        .args(["--lang", "en", "--workspace", ws, "read"])
        .arg(&path)
        .output()
        .expect("en");

    let out_pt = common::atomwrite()
        .args(["--lang", "pt-BR", "--workspace", ws, "read"])
        .arg(&path)
        .output()
        .expect("pt");

    assert!(out_en.status.success());
    assert!(out_pt.status.success());

    let events_en = common::parse_ndjson(&out_en.stdout);
    let events_pt = common::parse_ndjson(&out_pt.stdout);

    assert_eq!(
        events_en[0]["checksum"], events_pt[0]["checksum"],
        "checksum must be identical regardless of --lang"
    );
    assert_eq!(
        events_en[0]["content"], events_pt[0]["content"],
        "content must be identical regardless of --lang"
    );
    assert_eq!(
        events_en[0]["type"], events_pt[0]["type"],
        "type must be identical regardless of --lang"
    );
}