agent-file-tools 0.44.0

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

use serde_json::{json, Value};

use super::helpers::AftProcess;

fn configured_aft(project: &Path) -> AftProcess {
    let mut aft = AftProcess::spawn();
    let configure = aft.send(
        &json!({
            "id": "cfg-apply-patch",
            "command": "configure",
            "harness": "opencode",
            "project_root": project,
        })
        .to_string(),
    );
    assert_eq!(
        configure["success"], true,
        "configure failed: {configure:?}"
    );
    aft
}

fn write_file(root: &Path, relative: &str, content: &str) -> PathBuf {
    let path = root.join(relative);
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).unwrap();
    }
    fs::write(&path, content).unwrap();
    path
}

fn apply(aft: &mut AftProcess, id: &str, patch_text: &str) -> Value {
    aft.send(
        &json!({
            "id": id,
            "command": "apply_patch",
            "params": { "patch_text": patch_text },
        })
        .to_string(),
    )
}

fn preview(aft: &mut AftProcess, id: &str, patch_text: &str) -> Value {
    aft.send(
        &json!({
            "id": id,
            "command": "apply_patch",
            "params": { "patch_text": patch_text, "preview": true },
        })
        .to_string(),
    )
}

#[test]
fn apply_patch_full_multifile_and_single_undo_reverts_all() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path();
    write_file(root, "update.txt", "alpha\nold\nomega\n");
    write_file(root, "delete.txt", "remove me\n");
    let mut aft = configured_aft(root);

    let patch = r#"*** Begin Patch
*** Add File: add.txt
+created
*** Update File: update.txt
@@
 alpha
-old
+new
 omega
*** Delete File: delete.txt
*** End Patch"#;

    let resp = apply(&mut aft, "apply-full", patch);
    assert_eq!(resp["success"], true, "patch failed: {resp:?}");
    assert_eq!(resp["complete"], true);
    assert_eq!(resp["partial"], false);
    assert_eq!(resp["metadata"]["files"].as_array().unwrap().len(), 3);
    assert_eq!(
        fs::read_to_string(root.join("add.txt")).unwrap(),
        "created\n"
    );
    assert_eq!(
        fs::read_to_string(root.join("update.txt")).unwrap(),
        "alpha\nnew\nomega\n"
    );
    assert!(!root.join("delete.txt").exists());

    let undo = aft.send(&json!({ "id": "undo-full", "command": "undo" }).to_string());
    assert_eq!(undo["success"], true, "undo failed: {undo:?}");
    assert_eq!(undo["operation"], true);
    assert_eq!(
        undo["restored_count"], 2,
        "content backups restored: {undo:?}"
    );
    assert!(!root.join("add.txt").exists());
    assert_eq!(
        fs::read_to_string(root.join("update.txt")).unwrap(),
        "alpha\nold\nomega\n"
    );
    assert_eq!(
        fs::read_to_string(root.join("delete.txt")).unwrap(),
        "remove me\n"
    );

    assert!(aft.shutdown().success());
}

#[test]
fn apply_patch_update_uses_reflow_fuzzy_hunk() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path();
    write_file(
        root,
        "reflow.txt",
        "const message = first + second + third;\n",
    );
    let mut aft = configured_aft(root);

    let patch = r#"*** Begin Patch
*** Update File: reflow.txt
@@
-const message = first +
-  second +
-  third;
+const message = first + second + fourth;
*** End Patch"#;

    let resp = apply(&mut aft, "apply-reflow", patch);
    assert_eq!(resp["success"], true, "reflow patch failed: {resp:?}");
    assert_eq!(
        fs::read_to_string(root.join("reflow.txt")).unwrap(),
        "const message = first + second + fourth;\n"
    );

    assert!(aft.shutdown().success());
}

#[test]
fn apply_patch_move_happy_path_and_undo_restores_source() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path();
    write_file(root, "source.txt", "before\nkeep\n");
    let mut aft = configured_aft(root);

    let patch = r#"*** Begin Patch
*** Update File: source.txt
*** Move to: nested/dest.txt
@@
-before
+after
 keep
*** End Patch"#;

    let resp = apply(&mut aft, "apply-move", patch);
    assert_eq!(resp["success"], true, "move failed: {resp:?}");
    let file = &resp["metadata"]["files"][0];
    assert_eq!(file["type"], "move");
    assert_eq!(file["relativePath"], "nested/dest.txt");
    assert!(file["movePath"]
        .as_str()
        .unwrap()
        .ends_with("nested/dest.txt"));
    assert!(!root.join("source.txt").exists());
    assert_eq!(
        fs::read_to_string(root.join("nested/dest.txt")).unwrap(),
        "after\nkeep\n"
    );

    let undo = aft.send(&json!({ "id": "undo-move", "command": "undo" }).to_string());
    assert_eq!(undo["success"], true, "undo failed: {undo:?}");
    assert_eq!(
        fs::read_to_string(root.join("source.txt")).unwrap(),
        "before\nkeep\n"
    );
    assert!(!root.join("nested/dest.txt").exists());

    assert!(aft.shutdown().success());
}

#[test]
fn apply_patch_partial_keeps_successful_hunks_and_reports_failure() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path();
    write_file(root, "one.txt", "old\n");
    write_file(root, "two.txt", "actual\n");
    let mut aft = configured_aft(root);

    let patch = r#"*** Begin Patch
*** Update File: one.txt
@@
-old
+new
*** Update File: two.txt
@@
-missing
+replacement
*** End Patch"#;

    let resp = apply(&mut aft, "apply-partial", patch);
    assert_eq!(
        resp["success"], true,
        "partial should be success envelope: {resp:?}"
    );
    assert_eq!(resp["complete"], false);
    assert_eq!(resp["partial"], true);
    assert_eq!(resp["all_failed"], false);
    assert_eq!(fs::read_to_string(root.join("one.txt")).unwrap(), "new\n");
    assert_eq!(
        fs::read_to_string(root.join("two.txt")).unwrap(),
        "actual\n"
    );
    assert!(resp["failures"][0]["error"]
        .as_str()
        .unwrap()
        .contains("Failed to find expected lines"));

    assert!(aft.shutdown().success());
}

#[test]
fn apply_patch_total_failure_returns_error_envelope_without_writes() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path();
    write_file(root, "target.txt", "actual\n");
    let mut aft = configured_aft(root);

    let patch = r#"*** Begin Patch
*** Update File: target.txt
@@
-missing
+replacement
*** End Patch"#;

    let resp = apply(&mut aft, "apply-total-failure", patch);
    assert_eq!(
        resp["success"], false,
        "total failure should be error: {resp:?}"
    );
    assert_eq!(resp["code"], "apply_patch_failed");
    assert_eq!(resp["complete"], false);
    assert_eq!(resp["all_failed"], true);
    assert_eq!(resp["partial"], false);
    assert_eq!(resp["metadata"]["files"].as_array().unwrap().len(), 0);
    assert_eq!(
        fs::read_to_string(root.join("target.txt")).unwrap(),
        "actual\n"
    );

    assert!(aft.shutdown().success());
}

#[test]
fn apply_patch_preview_reports_diff_and_leaves_disk_unchanged() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path();
    write_file(root, "existing.txt", "old\n");
    let mut aft = configured_aft(root);

    let patch = r#"*** Begin Patch
*** Add File: preview-new.txt
+created
*** Update File: existing.txt
@@
-old
+new
*** End Patch"#;

    let resp = preview(&mut aft, "preview-valid", patch);
    assert_eq!(resp["success"], true, "preview failed: {resp:?}");
    assert_eq!(resp["preview"], true);
    assert!(resp["preview_diff"]
        .as_str()
        .unwrap()
        .contains("preview-new.txt"));
    assert_eq!(resp["affected_rel_paths"].as_array().unwrap().len(), 2);
    assert!(!root.join("preview-new.txt").exists());
    assert_eq!(
        fs::read_to_string(root.join("existing.txt")).unwrap(),
        "old\n"
    );

    let invalid = r#"*** Begin Patch
*** Update File: existing.txt
@@
-missing
+new
*** End Patch"#;
    let err = preview(&mut aft, "preview-invalid", invalid);
    assert_eq!(
        err["success"], false,
        "invalid preview should fail: {err:?}"
    );
    assert_eq!(
        fs::read_to_string(root.join("existing.txt")).unwrap(),
        "old\n"
    );

    assert!(aft.shutdown().success());
}

#[test]
fn apply_patch_add_existing_fails_and_syntax_rollback_does_not_poison_undo() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path();
    write_file(root, "exists.txt", "already\n");
    write_file(root, "good.ts", "const good = 1;\n");
    write_file(root, "bad.ts", "const value = 1;\n");
    let mut aft = configured_aft(root);

    let add_existing = r#"*** Begin Patch
*** Add File: exists.txt
+replacement
*** End Patch"#;
    let add_resp = apply(&mut aft, "add-existing", add_existing);
    assert_eq!(
        add_resp["success"], false,
        "add existing should fail: {add_resp:?}"
    );
    assert!(add_resp["failures"][0]["error"]
        .as_str()
        .unwrap()
        .contains("file already exists"));
    assert_eq!(
        fs::read_to_string(root.join("exists.txt")).unwrap(),
        "already\n"
    );

    let mixed = r#"*** Begin Patch
*** Update File: good.ts
@@
-const good = 1;
+const good = 2;
*** Update File: bad.ts
@@
-const value = 1;
+const value = {;
*** End Patch"#;
    let resp = apply(&mut aft, "syntax-rollback", mixed);
    assert_eq!(
        resp["success"], true,
        "mixed patch should be partial: {resp:?}"
    );
    assert_eq!(resp["partial"], true);
    assert_eq!(
        fs::read_to_string(root.join("good.ts")).unwrap(),
        "const good = 2;\n"
    );
    assert_eq!(
        fs::read_to_string(root.join("bad.ts")).unwrap(),
        "const value = 1;\n"
    );

    let undo = aft.send(&json!({ "id": "undo-syntax", "command": "undo" }).to_string());
    assert_eq!(
        undo["success"], true,
        "undo should restore only successful hunk: {undo:?}"
    );
    assert_eq!(
        fs::read_to_string(root.join("good.ts")).unwrap(),
        "const good = 1;\n"
    );
    assert_eq!(
        fs::read_to_string(root.join("bad.ts")).unwrap(),
        "const value = 1;\n"
    );

    assert!(aft.shutdown().success());
}