agent-file-tools 0.49.4

Agent File Tools — tree-sitter powered code analysis for AI agents
Documentation
use std::fs;
use std::path::Path;

use serde_json::{json, Value};

use super::helpers::{fixture_path, AftProcess};

const SESSION_ID: &str = "tool-call-preview-session";

#[test]
fn tool_call_top_level_preview_does_not_mutate_edit_or_write_modes() {
    let dir = tempfile::tempdir().expect("temp project");
    let root = dir.path();
    fs::create_dir_all(root.join("src")).expect("create src dir");

    let append_path = root.join("src/append.txt");
    fs::write(&append_path, "alpha\n").expect("write append fixture");

    let replace_path = root.join("src/replace.txt");
    fs::write(&replace_path, "replace old value\n").expect("write replace fixture");

    let symbol_path = root.join("src/symbol.ts");
    fs::copy(fixture_path("sample.ts"), &symbol_path).expect("copy symbol fixture");
    let symbol_original = fs::read_to_string(&symbol_path).expect("read symbol fixture");

    let batch_path = root.join("src/batch.txt");
    fs::write(&batch_path, "one two three\n").expect("write batch fixture");

    let write_new_path = root.join("src/write-new.txt");
    assert!(
        !write_new_path.exists(),
        "new write fixture should start absent"
    );

    let write_overwrite_path = root.join("src/write-overwrite.txt");
    fs::write(&write_overwrite_path, "before write\n").expect("write overwrite fixture");

    let apply_patch_path = root.join("src/apply-patch.txt");
    fs::write(&apply_patch_path, "before patch\n").expect("write apply_patch fixture");

    let mut aft = AftProcess::spawn();
    let configure = aft.configure(root);
    assert_eq!(
        configure["success"], true,
        "configure failed: {configure:#}"
    );

    assert_preview_does_not_mutate(
        &mut aft,
        "edit append",
        "edit",
        json!({"filePath": "src/append.txt", "appendContent": "beta\n"}),
        &append_path,
        Some("alpha\n"),
    );

    assert_preview_does_not_mutate(
        &mut aft,
        "edit oldString",
        "edit",
        json!({"filePath": "src/replace.txt", "oldString": "old", "newString": "new"}),
        &replace_path,
        Some("replace old value\n"),
    );

    assert_preview_does_not_mutate(
        &mut aft,
        "edit symbol",
        "edit",
        json!({
            "filePath": "src/symbol.ts",
            "symbol": "greet",
            "content": "function greet(name: string): string {\n  return `Preview, ${name}!`;\n}",
        }),
        &symbol_path,
        Some(&symbol_original),
    );

    assert_preview_does_not_mutate(
        &mut aft,
        "edit batch",
        "edit",
        json!({
            "filePath": "src/batch.txt",
            "edits": [
                {"oldString": "one", "newString": "ONE"},
                {"oldString": "three", "newString": "THREE"},
            ],
        }),
        &batch_path,
        Some("one two three\n"),
    );

    assert_preview_does_not_mutate(
        &mut aft,
        "write create",
        "write",
        json!({"filePath": "src/write-new.txt", "content": "new file contents\n"}),
        &write_new_path,
        None,
    );

    assert_preview_does_not_mutate(
        &mut aft,
        "write overwrite",
        "write",
        json!({"filePath": "src/write-overwrite.txt", "content": "after write\n"}),
        &write_overwrite_path,
        Some("before write\n"),
    );

    assert_preview_does_not_mutate(
        &mut aft,
        "apply_patch update",
        "apply_patch",
        json!({"patchText": "*** Begin Patch\n*** Update File: src/apply-patch.txt\n@@\n-before patch\n+after patch\n*** End Patch"}),
        &apply_patch_path,
        Some("before patch\n"),
    );

    let status = aft.shutdown();
    assert!(status.success());
}

#[test]
fn agent_supplied_argument_preview_is_ignored_and_normal_tool_call_mutates() {
    let dir = tempfile::tempdir().expect("temp project");
    let root = dir.path();
    fs::create_dir_all(root.join("src")).expect("create src dir");

    let smuggled_path = root.join("src/smuggled.txt");
    fs::write(&smuggled_path, "before smuggle\n").expect("write smuggle fixture");

    let normal_path = root.join("src/normal.txt");
    fs::write(&normal_path, "before normal\n").expect("write normal fixture");

    let mut aft = AftProcess::spawn();
    let configure = aft.configure(root);
    assert_eq!(
        configure["success"], true,
        "configure failed: {configure:#}"
    );

    let smuggled = send_tool_call(
        &mut aft,
        "smuggled-argument-preview",
        "edit_match",
        json!({
            "file": smuggled_path.to_string_lossy(),
            "match": "before smuggle",
            "replacement": "after smuggle",
            "preview": true,
        }),
        false,
    );
    assert_eq!(
        smuggled["success"], true,
        "smuggled preview failed: {smuggled:#}"
    );
    assert_ne!(
        smuggled.get("preview").and_then(Value::as_bool),
        Some(true),
        "arguments.preview must not put the leaf in preview mode: {smuggled:#}"
    );
    assert_eq!(
        fs::read_to_string(&smuggled_path).expect("read smuggled file"),
        "after smuggle\n"
    );

    let normal = send_tool_call(
        &mut aft,
        "normal-no-preview",
        "edit",
        json!({"filePath": "src/normal.txt", "oldString": "before normal", "newString": "after normal"}),
        false,
    );
    assert_eq!(
        normal["success"], true,
        "normal tool_call failed: {normal:#}"
    );
    assert_ne!(
        normal.get("preview").and_then(Value::as_bool),
        Some(true),
        "normal tool_call must not be a preview: {normal:#}"
    );
    assert_eq!(
        fs::read_to_string(&normal_path).expect("read normal file"),
        "after normal\n"
    );

    let status = aft.shutdown();
    assert!(status.success());
}

#[test]
fn tool_call_bash_wait_rejects_stringified_background_and_pty() {
    let dir = tempfile::tempdir().expect("temp project");
    let root = dir.path();

    let mut aft = AftProcess::spawn();
    let configure = aft.configure(root);
    assert_eq!(
        configure["success"], true,
        "configure failed: {configure:#}"
    );

    let background = send_tool_call(
        &mut aft,
        "bash-wait-background-reject",
        "bash",
        json!({
            "command": "echo should-not-run",
            "wait": "true",
            "background": "true",
        }),
        false,
    );
    assert_eq!(background["success"], false, "response: {background:#}");
    assert_eq!(background["code"], "invalid_request");
    assert!(background["message"]
        .as_str()
        .unwrap()
        .contains("wait:true cannot be used with background:true"));

    let pty = send_tool_call(
        &mut aft,
        "bash-wait-pty-reject",
        "bash",
        json!({
            "command": "echo should-not-run",
            "wait": "true",
            "pty": "true",
        }),
        false,
    );
    assert_eq!(pty["success"], false, "response: {pty:#}");
    assert_eq!(pty["code"], "invalid_request");
    assert!(pty["message"]
        .as_str()
        .unwrap()
        .contains("wait:true cannot be used with pty:true"));

    let status = aft.shutdown();
    assert!(status.success());
}

fn assert_preview_does_not_mutate(
    aft: &mut AftProcess,
    label: &str,
    tool: &str,
    arguments: Value,
    path: &Path,
    expected_content: Option<&str>,
) {
    let response = send_tool_call(
        aft,
        &format!("preview-{}", label.replace(' ', "-")),
        tool,
        arguments,
        true,
    );

    assert_eq!(
        response["success"], true,
        "preview {label} should succeed: {response:#}"
    );
    assert_eq!(
        response["preview"], true,
        "preview {label} should mark the response as a preview: {response:#}"
    );
    let preview_diff = response["preview_diff"]
        .as_str()
        .unwrap_or_else(|| panic!("preview {label} missing preview_diff: {response:#}"));
    assert!(
        !preview_diff.is_empty(),
        "preview {label} returned an empty preview_diff"
    );
    if tool == "apply_patch" {
        assert!(
            response["affected_paths"].is_array(),
            "preview {label} missing affected_paths: {response:#}"
        );
    } else {
        assert!(
            response["diff"].is_object(),
            "preview {label} missing structured diff: {response:#}"
        );
    }

    match expected_content {
        Some(content) => assert_eq!(
            fs::read_to_string(path).expect("read preview target"),
            content,
            "preview {label} must leave existing file content unchanged"
        ),
        None => assert!(
            !path.exists(),
            "preview {label} must not create the missing file {}",
            path.display()
        ),
    }
    assert_no_undo_entry(aft, label, path);
}

fn assert_no_undo_entry(aft: &mut AftProcess, label: &str, path: &Path) {
    let history = aft.send(
        &json!({
            "id": format!("history-after-{label}"),
            "command": "edit_history",
            "file": path.to_string_lossy(),
        })
        .to_string(),
    );
    assert_eq!(
        history["success"], true,
        "history failed for {label}: {history:#}"
    );
    let entries = history["entries"]
        .as_array()
        .unwrap_or_else(|| panic!("history entries missing for {label}: {history:#}"));
    assert!(
        entries.is_empty(),
        "preview {label} must not create backup history: {history:#}"
    );

    let undo_preview = aft.send(
        &json!({
            "id": format!("undo-preview-after-{label}"),
            "command": "undo_preview",
            "file": path.to_string_lossy(),
        })
        .to_string(),
    );
    assert_eq!(
        undo_preview["success"], false,
        "preview {label} must not create undo state: {undo_preview:#}"
    );
    assert_eq!(undo_preview["code"], "no_undo_history");
}

fn send_tool_call(
    aft: &mut AftProcess,
    id: &str,
    name: &str,
    arguments: Value,
    preview: bool,
) -> Value {
    let mut request = json!({
        "id": id,
        "command": "tool_call",
        "session_id": SESSION_ID,
        "name": name,
        "arguments": arguments,
    });
    if preview {
        request["preview"] = json!(true);
    }
    aft.send(&request.to_string())
}