agent-file-tools 0.56.0

Agent File Tools — tree-sitter powered code analysis for AI agents
Documentation
#![cfg(unix)]

use std::env;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};

use serde_json::json;

use super::helpers::AftProcess;

fn write_fake_gh(path: &Path) {
    fs::write(
        path,
        r#"#!/bin/sh
if [ "$1 $2" = "gh-shim --shim-version" ]; then
  printf '%s\n' '{"shim_version":"fixture","gh_routing_schema_floor":1}'
  exit 0
fi
if [ "$1 $2" = "issue comment" ]; then
  printf '%s\n' "$@" > "$AFT_GITHUB_WRITE_ARGV"
  cat > "$AFT_GITHUB_WRITE_STDIN"
  if [ "${AFT_GITHUB_WRITE_REFUSE:-}" = "1" ]; then
    printf '%s\n' 'fixture shim policy refused this comment' >&2
    exit 86
  fi
  printf '%s\n' 'https://github.com/owner/repo/issues/7#issuecomment-901'
  exit 0
fi
if [ "$1 $2" = "issue view" ]; then
  printf '%s\n' '{"number":7,"title":"Fixture issue","state":"OPEN","body":"body","url":"https://github.com/owner/repo/issues/7","comments":[{"author":{"login":"aft-bot"},"body":"exact body with stale target","createdAt":"2026-09-10T12:00:00Z","updatedAt":"2026-09-10T12:00:00Z","url":"https://github.com/owner/repo/issues/7#issuecomment-901"}]}'
  exit 0
fi
if [ "$1 $2" = "api --method" ]; then
  printf '%s\n' "$@" > "$AFT_GITHUB_EDIT_ARGV"
  cat > "$AFT_GITHUB_EDIT_STDIN"
  if [ "${AFT_GITHUB_WRITE_REFUSE:-}" = "1" ]; then
    printf '%s\n' 'fixture shim says bot cannot edit this comment' >&2
    exit 86
  fi
  printf '%s\n' '{}'
  exit 0
fi
if [ "$1" = "api" ]; then
  printf '%s\n' '[]'
  exit 0
fi
printf '%s\n' "unexpected fake gh invocation: $*" >&2
exit 2
"#,
    )
    .expect("write fake gh");
    let mut permissions = fs::metadata(path).expect("stat fake gh").permissions();
    permissions.set_mode(0o755);
    fs::set_permissions(path, permissions).expect("make fake gh executable");
}

fn path_with(bin: &Path) -> std::ffi::OsString {
    env::join_paths(
        std::iter::once(bin.to_path_buf())
            .chain(env::split_paths(&env::var_os("PATH").unwrap_or_default())),
    )
    .expect("join fake gh PATH")
}

struct Fixture {
    _root: tempfile::TempDir,
    project: PathBuf,
    user_config: PathBuf,
    argv: PathBuf,
    stdin: PathBuf,
    edit_argv: PathBuf,
    edit_stdin: PathBuf,
    state: PathBuf,
    home: PathBuf,
    xdg_config: PathBuf,
    xdg_data: PathBuf,
    xdg_cache: PathBuf,
    path: std::ffi::OsString,
}

impl Fixture {
    fn new(github: serde_json::Value) -> Self {
        let root = tempfile::tempdir().expect("create GitHub write fixture");
        let project = root.path().join("project");
        let bin = root.path().join("bin");
        let fake_gh = bin.join("fake-aft-shim");
        let user_config = root.path().join("user-aft.jsonc");
        fs::create_dir_all(&project).expect("create project");
        fs::create_dir_all(&bin).expect("create bin");
        write_fake_gh(&fake_gh);
        std::os::unix::fs::symlink(&fake_gh, bin.join("gh")).expect("link fake gh on PATH");
        fs::write(
            &user_config,
            serde_json::to_vec(&json!({
                "github": github,
                "gh_shim": { "binary_path": fake_gh },
            }))
            .expect("serialize config"),
        )
        .expect("write user config");
        Self {
            argv: root.path().join("argv.log"),
            stdin: root.path().join("stdin.log"),
            edit_argv: root.path().join("edit-argv.log"),
            edit_stdin: root.path().join("edit-stdin.log"),
            state: root.path().join("shim-state"),
            home: root.path().join("home"),
            xdg_config: root.path().join("xdg-config"),
            xdg_data: root.path().join("xdg-data"),
            xdg_cache: root.path().join("xdg-cache"),
            path: path_with(&bin),
            project,
            user_config,
            _root: root,
        }
    }

    fn spawn(&self, refuse: bool) -> AftProcess {
        let mut envs = vec![
            ("PATH", self.path.as_os_str()),
            ("HOME", self.home.as_os_str()),
            ("XDG_CONFIG_HOME", self.xdg_config.as_os_str()),
            ("XDG_DATA_HOME", self.xdg_data.as_os_str()),
            ("XDG_CACHE_HOME", self.xdg_cache.as_os_str()),
            ("AFT_GH_SHIM_STATE_DIR", self.state.as_os_str()),
            ("AFT_GITHUB_WRITE_ARGV", self.argv.as_os_str()),
            ("AFT_GITHUB_WRITE_STDIN", self.stdin.as_os_str()),
            ("AFT_GITHUB_EDIT_ARGV", self.edit_argv.as_os_str()),
            ("AFT_GITHUB_EDIT_STDIN", self.edit_stdin.as_os_str()),
        ];
        if refuse {
            envs.push(("AFT_GITHUB_WRITE_REFUSE", std::ffi::OsStr::new("1")));
        }
        let mut aft = AftProcess::spawn_with_env(&envs);
        let configured = aft.send(
            &json!({
                "id": "configure-github-write",
                "command": "configure",
                "harness": "runner",
                "project_root": self.project,
                "cortexkit_user_config_path": self.user_config,
            })
            .to_string(),
        );
        assert_eq!(
            configured["success"], true,
            "configure failed: {configured:#}"
        );
        aft
    }
}

#[test]
fn write_posts_exact_stdin_through_managed_shim_and_reports_live_ordinal() {
    let fixture = Fixture::new(json!({ "write": true }));
    let mut aft = fixture.spawn(false);
    let response = aft.send(
        &json!({
            "id": "github-write",
            "command": "write",
            "file": "issue://owner/repo/7",
            "content": "exact body",
        })
        .to_string(),
    );

    assert_eq!(response["success"], true, "write failed: {response:#}");
    assert_eq!(response["ordinal"], 1);
    assert_eq!(
        response["comment_url"],
        "https://github.com/owner/repo/issues/7#issuecomment-901"
    );
    assert!(response["text"]
        .as_str()
        .expect("response text")
        .contains("Comments cannot be undone with aft_safety"));
    assert_eq!(
        fs::read_to_string(&fixture.stdin).expect("read stdin"),
        "exact body"
    );
    assert_eq!(
        fs::read_to_string(&fixture.argv).expect("read argv"),
        "issue\ncomment\n7\n-R\nowner/repo\n--body-file\n-\n"
    );
    assert!(aft.shutdown().success());
}

#[test]
fn write_surfaces_exit_86_as_typed_shim_refusal() {
    let fixture = Fixture::new(json!({ "write": true }));
    let mut aft = fixture.spawn(true);
    let response = aft.send(
        &json!({
            "id": "github-write-refused",
            "command": "write",
            "file": "issue://7",
            "content": "blocked body",
        })
        .to_string(),
    );

    assert_eq!(response["success"], false);
    assert_eq!(response["code"], "gh_shim_refused");
    assert!(response["message"]
        .as_str()
        .unwrap_or_default()
        .contains("fixture shim policy refused this comment"));
    assert!(aft.shutdown().success());
}

#[test]
fn write_gate_and_master_off_refuse_before_any_gh_traffic() {
    for github in [
        json!({ "write": false }),
        json!({
            "enabled": false,
            "write": true,
            "read": true,
            "shim": true
        }),
    ] {
        let fixture = Fixture::new(github);
        let mut aft = fixture.spawn(false);
        let response = aft.send(
            &json!({
                "id": "github-write-off",
                "command": "write",
                "file": "issue://7",
                "content": "must not post",
            })
            .to_string(),
        );
        assert_eq!(response["success"], false);
        assert_eq!(response["code"], "github_write_disabled");
        assert!(response.to_string().contains("github.write"));
        assert!(!fixture.argv.exists(), "disabled write reached gh");
        assert!(!fixture.stdin.exists(), "disabled write sent a body");
        assert!(aft.shutdown().success());
    }
}

#[test]
fn edit_fetches_matches_and_patches_the_selected_comment_id() {
    let fixture = Fixture::new(json!({ "write": true, "read": true }));
    let mut aft = fixture.spawn(false);
    let response = aft.send(
        &json!({
            "id": "github-edit",
            "command": "edit_match",
            "file": "issue://owner/repo/7/comments/1",
            "match": "stale target",
            "replacement": "fresh target",
        })
        .to_string(),
    );

    assert_eq!(response["success"], true, "edit failed: {response:#}");
    assert_eq!(response["ordinal"], 1);
    assert_eq!(response["replacements"], 1);
    assert_eq!(
        fs::read_to_string(&fixture.edit_argv).expect("read edit argv"),
        "api\n--method\nPATCH\nrepos/owner/repo/issues/comments/901\n--input\n-\n"
    );
    assert_eq!(
        fs::read_to_string(&fixture.edit_stdin).expect("read edit stdin"),
        r#"{"body":"exact body with fresh target"}"#
    );
    assert!(aft.shutdown().success());
}

#[test]
fn edit_stale_text_fails_at_match_time_without_posting() {
    let fixture = Fixture::new(json!({ "write": true, "read": true }));
    let mut aft = fixture.spawn(false);
    let response = aft.send(
        &json!({
            "id": "github-edit-stale",
            "command": "edit_match",
            "file": "issue://7/comments/1",
            "match": "already changed elsewhere",
            "replacement": "must not clobber",
        })
        .to_string(),
    );

    assert_eq!(response["success"], false);
    assert_eq!(response["code"], "match_not_found");
    assert!(!fixture.edit_argv.exists(), "stale edit reached gh PATCH");
    assert!(!fixture.edit_stdin.exists(), "stale edit sent a body");
    assert!(aft.shutdown().success());
}

#[test]
fn edit_surfaces_bot_ownership_refusal_from_the_shim() {
    let fixture = Fixture::new(json!({ "write": true, "read": true }));
    let mut aft = fixture.spawn(true);
    let response = aft.send(
        &json!({
            "id": "github-edit-refused",
            "command": "edit_match",
            "file": "issue://7/comments/1",
            "match": "stale target",
            "replacement": "fresh target",
        })
        .to_string(),
    );

    assert_eq!(response["success"], false);
    assert_eq!(response["code"], "gh_shim_refused");
    assert!(response["message"]
        .as_str()
        .unwrap_or_default()
        .contains("bot cannot edit this comment"));
    assert!(aft.shutdown().success());
}