agent-file-tools 0.35.3

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

use super::helpers::AftProcess;

fn assert_error_code(resp: &serde_json::Value, code: &str) {
    assert_eq!(
        resp["success"], false,
        "expected failure response: {resp:?}"
    );
    assert_eq!(resp["code"], code, "unexpected error response: {resp:?}");
}

fn normalize_path_text(text: &str) -> String {
    let normalized = text.replace(r"\\?\", "").replace('\\', "/");
    if cfg!(windows) {
        normalized.to_ascii_lowercase()
    } else {
        normalized
    }
}

fn assert_message_mentions_path(message: &str, path: &Path) {
    let normalized_message = normalize_path_text(message);
    let mut candidates = vec![path.to_path_buf()];
    if let Ok(canonical) = fs::canonicalize(path) {
        candidates.push(canonical);
    } else if let (Some(parent), Some(file_name)) = (path.parent(), path.file_name()) {
        if let Ok(canonical_parent) = fs::canonicalize(parent) {
            candidates.push(canonical_parent.join(file_name));
        }
    }

    assert!(
        candidates.iter().any(|candidate| normalized_message
            .contains(&normalize_path_text(&candidate.display().to_string()))),
        "message should mention {} (or its canonical form): {message}",
        path.display()
    );
}

fn set_read_only(path: &Path, read_only: bool) {
    let mut perms = fs::metadata(path).expect("metadata").permissions();

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;

        let mode = perms.mode();
        if read_only {
            perms.set_mode(mode & !0o222);
        } else {
            perms.set_mode(mode | 0o600);
        }
    }

    #[cfg(not(unix))]
    {
        perms.set_readonly(read_only);
    }

    fs::set_permissions(path, perms).expect("set permissions");
}

#[test]
fn write_fails_when_parent_directory_is_missing_and_create_dirs_is_false() {
    let mut aft = AftProcess::spawn();
    let dir = tempfile::tempdir().unwrap();
    let target = dir.path().join("missing").join("nested").join("new.txt");

    let req = serde_json::json!({
        "id": "write-missing-parent",
        "command": "write",
        "file": target.display().to_string(),
        "content": "hello",
        "create_dirs": false,
    });
    let resp = aft.send(&serde_json::to_string(&req).unwrap());

    assert_error_code(&resp, "invalid_request");
    assert!(resp["message"]
        .as_str()
        .unwrap()
        .contains("failed to write file"));
    assert!(
        !target.exists(),
        "write should not create missing directories"
    );

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

#[test]
fn write_rejects_paths_outside_the_configured_project_root() {
    let mut aft = AftProcess::spawn();
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path().join("project");
    let outside = dir.path().join("outside");
    fs::create_dir_all(&root).unwrap();
    fs::create_dir_all(&outside).unwrap();

    // Must opt into path restriction (default is false)
    let configure = aft.send(
        &serde_json::to_string(&serde_json::json!({
            "id": "cfg",
            "command": "configure",
            "harness": "opencode",
            "project_root": root.display().to_string(),
            "restrict_to_project_root": true,
        }))
        .unwrap(),
    );
    assert_eq!(
        configure["success"], true,
        "configure failed: {configure:?}"
    );

    let target = outside.join("new.txt");
    let req = serde_json::json!({
        "id": "write-outside-root",
        "command": "write",
        "file": target.display().to_string(),
        "content": "hello",
    });
    let resp = aft.send(&serde_json::to_string(&req).unwrap());

    assert_error_code(&resp, "path_outside_root");
    let message = resp["message"].as_str().unwrap();
    assert_message_mentions_path(message, &target);
    assert_message_mentions_path(message, &root);

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

#[test]
fn write_fails_for_read_only_files() {
    let mut aft = AftProcess::spawn();
    let dir = tempfile::tempdir().unwrap();
    let target = dir.path().join("readonly.txt");
    fs::write(&target, "before").unwrap();
    set_read_only(&target, true);

    let req = serde_json::json!({
        "id": "write-read-only",
        "command": "write",
        "file": target.display().to_string(),
        "content": "after",
    });
    let resp = aft.send(&serde_json::to_string(&req).unwrap());

    assert_error_code(&resp, "invalid_request");
    let message = resp["message"].as_str().unwrap().to_lowercase();
    assert!(message.contains("failed to write file"));
    assert!(message.contains("permission") || message.contains("denied"));

    set_read_only(&target, false);
    let status = aft.shutdown();
    assert!(status.success());
}

#[test]
fn edit_match_rejects_empty_match_strings() {
    let mut aft = AftProcess::spawn();
    let dir = tempfile::tempdir().unwrap();
    let target = dir.path().join("sample.txt");
    fs::write(&target, "hello world\n").unwrap();

    let req = serde_json::json!({
        "id": "edit-match-empty",
        "command": "edit_match",
        "file": target.display().to_string(),
        "match": "",
        "replacement": "updated",
    });
    let resp = aft.send(&serde_json::to_string(&req).unwrap());

    assert_error_code(&resp, "invalid_request");
    assert_eq!(
        resp["message"],
        "edit_match: 'match' must be a non-empty string"
    );

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

#[test]
fn edit_match_returns_file_not_found_for_missing_files() {
    let mut aft = AftProcess::spawn();
    let dir = tempfile::tempdir().unwrap();
    let target = dir.path().join("missing.txt");

    let req = serde_json::json!({
        "id": "edit-match-missing-file",
        "command": "edit_match",
        "file": target.display().to_string(),
        "match": "hello",
        "replacement": "updated",
    });
    let resp = aft.send(&serde_json::to_string(&req).unwrap());

    assert_error_code(&resp, "file_not_found");
    assert_eq!(
        resp["message"],
        format!("file not found: {}", target.display())
    );

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

#[test]
fn edit_match_rejects_occurrences_that_are_out_of_range() {
    let mut aft = AftProcess::spawn();
    let dir = tempfile::tempdir().unwrap();
    let target = dir.path().join("occurrence.txt");
    fs::write(&target, "hello world\n").unwrap();

    let req = serde_json::json!({
        "id": "edit-match-occurrence-range",
        "command": "edit_match",
        "file": target.display().to_string(),
        "match": "hello",
        "replacement": "updated",
        "occurrence": 5,
    });
    let resp = aft.send(&serde_json::to_string(&req).unwrap());

    assert_error_code(&resp, "invalid_request");
    assert_eq!(
        resp["message"],
        "edit_match: occurrence 5 out of range, file has 1 occurrence(s)"
    );

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

#[test]
fn delete_file_returns_file_not_found_for_missing_files() {
    let mut aft = AftProcess::spawn();
    let dir = tempfile::tempdir().unwrap();
    let target = dir.path().join("missing.txt");

    let req = serde_json::json!({
        "id": "delete-missing",
        "command": "delete_file",
        "file": target.display().to_string(),
    });
    let resp = aft.send(&serde_json::to_string(&req).unwrap());

    assert_error_code(&resp, "file_not_found");
    assert_eq!(
        resp["message"],
        format!("delete_file: file not found: {}", target.display())
    );

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

#[test]
fn delete_file_rejects_paths_outside_the_configured_project_root() {
    let mut aft = AftProcess::spawn();
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path().join("project");
    let outside = dir.path().join("outside");
    fs::create_dir_all(&root).unwrap();
    fs::create_dir_all(&outside).unwrap();

    let target = outside.join("delete-me.txt");
    fs::write(&target, "hello").unwrap();

    // Must opt into path restriction (default is false)
    let configure = aft.send(
        &serde_json::to_string(&serde_json::json!({
            "id": "cfg",
            "command": "configure",
            "harness": "opencode",
            "project_root": root.display().to_string(),
            "restrict_to_project_root": true,
        }))
        .unwrap(),
    );
    assert_eq!(
        configure["success"], true,
        "configure failed: {configure:?}"
    );

    let req = serde_json::json!({
        "id": "delete-outside-root",
        "command": "delete_file",
        "file": target.display().to_string(),
    });
    let resp = aft.send(&serde_json::to_string(&req).unwrap());

    assert_error_code(&resp, "path_outside_root");
    let message = resp["message"].as_str().unwrap();
    assert_message_mentions_path(message, &target);
    assert_message_mentions_path(message, &root);
    assert!(
        target.exists(),
        "delete_file should not remove files outside the project root"
    );

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