a-agent 0.2.1

Fast, terminal-native coding agent with progressive context
Documentation
use std::fs;
use std::path::Path;

use a_agent::config::{Config, ProviderKind};
use a_agent::context::{
    ContextInput, SkillMetadata, bound_stdin, build_system_prompt, discover_agents,
    discover_skills, parse_skill_metadata, skill_roots,
};
use tempfile::tempdir;

#[test]
fn rejects_legacy_single_provider_configuration() {
    let temp = tempdir().unwrap();
    let home = temp.path().join("home");
    let cwd = temp.path().join("repo");
    fs::create_dir_all(home.join(".config/a")).unwrap();
    fs::create_dir_all(&cwd).unwrap();
    fs::write(
        home.join(".config/a/config.toml"),
        r#"
[provider]
type = "responses"
model = "old-model"
"#,
    )
    .unwrap();
    let error = Config::load_from(&cwd, &home).unwrap_err().to_string();
    assert!(error.contains("legacy [provider]"), "{error}");
}

#[test]
fn resolves_multiple_provider_and_model_profiles_with_effort() {
    let temp = tempdir().unwrap();
    let home = temp.path().join("home");
    let cwd = temp.path().join("repo");
    fs::create_dir_all(home.join(".config/a")).unwrap();
    fs::create_dir_all(cwd.join(".a")).unwrap();
    fs::write(
        home.join(".config/a/config.toml"),
        r#"
default_model = "fast"

[providers.openai]
type = "responses"
base_url = "https://openai.example/v1"
api_key = "openai-secret"

[providers.claude]
type = "anthropic"
base_url = "https://anthropic.example"
api_key = "claude-secret"

[models.fast]
provider = "openai"
model = "gpt-fast"
effort = "low"
efforts = ["low", "medium", "high"]
context_window = 128000
max_tokens = 4096

[models.deep]
provider = "openai"
model = "gpt-deep"
effort = "high"
efforts = ["medium", "high", "max"]

[models.deep.request]
service_tier = "priority"

[models.claude]
provider = "claude"
model = "claude-deep"
effort = "high"
efforts = ["low", "medium", "high", "max"]
"#,
    )
    .unwrap();
    fs::write(
        cwd.join(".a/config.toml"),
        "[models.deep]\neffort = \"medium\"\n",
    )
    .unwrap();

    let config = Config::load_from(&cwd, &home).unwrap();
    assert_eq!(config.model_names(), ["claude", "deep", "fast"]);

    let fast = config.resolve_model(None, None).unwrap();
    assert_eq!(fast.name, "fast");
    assert_eq!(fast.provider_name, "openai");
    assert_eq!(fast.provider.kind, ProviderKind::Responses);
    assert_eq!(fast.provider.model, "gpt-fast");
    assert_eq!(fast.provider.max_tokens, 4096);
    assert_eq!(fast.context_window, Some(128000));
    assert_eq!(fast.effort.as_deref(), Some("low"));
    assert_eq!(fast.provider.request["reasoning"]["effort"], "low");

    let deep = config.resolve_model(Some("deep"), Some("high")).unwrap();
    assert_eq!(deep.provider.model, "gpt-deep");
    assert_eq!(deep.effort.as_deref(), Some("high"));
    assert_eq!(deep.provider.request["reasoning"]["effort"], "high");
    assert_eq!(deep.provider.request["service_tier"], "priority");

    let claude = config.resolve_model(Some("claude"), Some("max")).unwrap();
    assert_eq!(claude.provider.kind, ProviderKind::Anthropic);
    assert_eq!(claude.provider.request["output_config"]["effort"], "max");
}

#[test]
fn model_without_effort_does_not_inject_reasoning_configuration() {
    let temp = tempdir().unwrap();
    let home = temp.path().join("home");
    let cwd = temp.path().join("repo");
    fs::create_dir_all(home.join(".config/a")).unwrap();
    fs::create_dir_all(&cwd).unwrap();
    fs::write(
        home.join(".config/a/config.toml"),
        r#"
default_model = "plain"
[providers.internal]
type = "responses"
api_key = "secret"
[models.plain]
provider = "internal"
model = "provider-model"
"#,
    )
    .unwrap();
    let config = Config::load_from(&cwd, &home).unwrap();
    let model = config.resolve_model(None, None).unwrap();
    assert_eq!(model.effort, None);
    assert!(model.efforts.is_empty());
    assert!(!model.provider.request.contains_key("reasoning"));
    assert!(config.resolve_model(None, Some("high")).is_err());
}

#[test]
fn context_window_must_leave_room_for_model_output() {
    let temp = tempdir().unwrap();
    let home = temp.path().join("home");
    let cwd = temp.path().join("repo");
    fs::create_dir_all(home.join(".config/a")).unwrap();
    fs::create_dir_all(&cwd).unwrap();
    fs::write(
        home.join(".config/a/config.toml"),
        r#"
default_model = "invalid"
[providers.test]
type = "responses"
api_key = "secret"
max_tokens = 4096
[models.invalid]
provider = "test"
model = "test-model"
context_window = 4096
"#,
    )
    .unwrap();

    let error = Config::load_from(&cwd, &home).unwrap_err().to_string();
    assert!(error.contains("context_window"), "{error}");
    assert!(error.contains("max_tokens"), "{error}");
}

#[test]
fn first_run_creates_a_documented_global_config_without_overwriting_it() {
    let temp = tempdir().unwrap();
    let home = temp.path().join("home");
    let path = Config::ensure_user_config(&home).unwrap().unwrap();
    assert_eq!(path, home.join(".config/a/config.toml"));
    let source = fs::read_to_string(&path).unwrap();
    assert!(source.contains("default_model = \"codex\""));
    assert!(source.contains("[providers.openai]"));
    assert!(source.contains("[models.codex]"));
    assert!(source.contains("type = \"responses\""));
    assert!(source.contains("api_key_env = \"OPENAI_API_KEY\""));
    assert!(source.contains("# api_key = \"sk-...\""));
    assert!(source.contains("# base_url = \"https://gateway.example/v1\""));
    assert!(source.contains("# [providers.anthropic]"));
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        assert_eq!(
            fs::metadata(&path).unwrap().permissions().mode() & 0o777,
            0o600
        );
    }

    fs::write(&path, "# user config\n").unwrap();
    assert!(Config::ensure_user_config(&home).unwrap().is_none());
    assert_eq!(fs::read_to_string(path).unwrap(), "# user config\n");
}

#[test]
fn stdin_is_bounded_by_bytes_and_keeps_the_tail() {
    let result = bound_stdin(b"header\nline one\nimportant failure\n", 20);
    assert!(result.starts_with("[stdin truncated; showing last 20 bytes]\n"));
    assert!(result.ends_with("important failure\n"));
}

#[test]
fn skill_frontmatter_is_parsed_without_loading_the_body() {
    let source = "---\nname: rust-review\ndescription: Review Rust safely.\n---\nSECRET BODY";
    let parsed = parse_skill_metadata(source, Path::new("/skills/rust-review/SKILL.md")).unwrap();
    assert_eq!(parsed.name, "rust-review");
    assert_eq!(parsed.description, "Review Rust safely.");
    assert!(!parsed.description.contains("SECRET"));
}

#[test]
fn skill_roots_follow_the_agent_skills_conventions() {
    let home = Path::new("/home/user");
    let project = Path::new("/repo");
    let roots = skill_roots(home, project);
    let shown = roots
        .iter()
        .map(|root| root.display().to_string())
        .collect::<Vec<_>>();
    assert_eq!(
        shown,
        vec!["/home/user/.agents/skills", "/repo/.agents/skills"],
        "only the shared convention is scanned, user scope first so project wins"
    );
}

#[test]
fn project_skill_overrides_user_skill_metadata() {
    let temp = tempdir().unwrap();
    let home = temp.path().join("home");
    let project = temp.path().join("repo");
    let user_root = home.join(".agents/skills/review");
    let project_root = project.join(".agents/skills/review");
    fs::create_dir_all(&user_root).unwrap();
    fs::create_dir_all(&project_root).unwrap();
    fs::write(
        user_root.join("SKILL.md"),
        "---\nname: review\ndescription: user\n---\nbody",
    )
    .unwrap();
    fs::write(
        project_root.join("SKILL.md"),
        "---\nname: review\ndescription: project\n---\nbody",
    )
    .unwrap();

    let skills = discover_skills(&skill_roots(&home, &project)).unwrap();
    assert_eq!(skills.len(), 1);
    assert_eq!(skills[0].description, "project");
}

#[test]
fn user_and_project_skills_are_merged_and_client_directories_are_ignored() {
    let temp = tempdir().unwrap();
    let home = temp.path().join("home");
    let project = temp.path().join("repo");
    for (root, name) in [
        (home.join(".agents/skills/shared-user"), "shared-user"),
        (
            project.join(".agents/skills/shared-project"),
            "shared-project",
        ),
        (home.join(".claude/skills/claude-user"), "claude-user"),
        (project.join(".a/skills/native-project"), "native-project"),
    ] {
        fs::create_dir_all(&root).unwrap();
        fs::write(
            root.join("SKILL.md"),
            format!("---\nname: {name}\ndescription: {name} description\n---\nbody"),
        )
        .unwrap();
    }

    let skills = discover_skills(&skill_roots(&home, &project)).unwrap();
    let names = skills
        .iter()
        .map(|skill| skill.name.as_str())
        .collect::<Vec<_>>();
    assert_eq!(names, vec!["shared-project", "shared-user"], "{skills:?}");
}

#[test]
fn skill_metadata_follows_the_specification() {
    // A nested metadata map must not be mistaken for the top-level name, and a
    // block scalar description has to survive as one line of text.
    let source = concat!(
        "---\n",
        "name: pdf-processing\n",
        "description: >-\n",
        "  Extract PDF text, fill forms, merge files.\n",
        "  Use when handling PDFs.\n",
        "license: Apache-2.0\n",
        "metadata:\n",
        "  name: not-the-skill-name\n",
        "  version: \"1.0\"\n",
        "---\n",
        "SECRET BODY"
    );
    let parsed =
        parse_skill_metadata(source, Path::new("/skills/pdf-processing/SKILL.md")).unwrap();
    assert_eq!(parsed.name, "pdf-processing");
    assert_eq!(
        parsed.description,
        "Extract PDF text, fill forms, merge files. Use when handling PDFs."
    );
    assert!(!parsed.description.contains("SECRET"));
}

#[test]
fn unquoted_colons_in_a_description_are_tolerated() {
    let source = "---\nname: review\ndescription: Use this skill when: the user asks\n---\nbody";
    let parsed = parse_skill_metadata(source, Path::new("/skills/review/SKILL.md")).unwrap();
    assert_eq!(parsed.description, "Use this skill when: the user asks");
}

#[test]
fn a_skill_without_a_description_is_skipped() {
    let temp = tempdir().unwrap();
    let root = temp.path().join(".agents/skills");
    fs::create_dir_all(root.join("broken")).unwrap();
    fs::create_dir_all(root.join("valid")).unwrap();
    fs::write(root.join("broken/SKILL.md"), "---\nname: broken\n---\nbody").unwrap();
    fs::write(
        root.join("valid/SKILL.md"),
        "---\nname: valid\ndescription: usable\n---\nbody",
    )
    .unwrap();

    let skills = discover_skills(std::slice::from_ref(&root)).unwrap();
    assert_eq!(skills.len(), 1, "{skills:?}");
    assert_eq!(skills[0].name, "valid");
}

#[test]
fn agents_discovery_is_ancestry_only_and_broad_to_specific() {
    let temp = tempdir().unwrap();
    let repo = temp.path().join("repo");
    let cwd = repo.join("src/parser");
    fs::create_dir_all(&cwd).unwrap();
    fs::create_dir(repo.join(".git")).unwrap();
    fs::write(repo.join("AGENTS.md"), "repo rules").unwrap();
    fs::write(repo.join("src/AGENTS.md"), "src rules").unwrap();

    let files = discover_agents(&cwd, None).unwrap();
    assert_eq!(
        files
            .iter()
            .map(|item| item.content.as_str())
            .collect::<Vec<_>>(),
        ["repo rules", "src rules"]
    );
}

#[test]
fn global_agents_stays_first_even_when_its_path_is_deeper() {
    let temp = tempdir().unwrap();
    let repo = temp.path().join("repo");
    let cwd = repo.join("src");
    let global = temp.path().join("very/deep/home/.config/a/AGENTS.md");
    fs::create_dir_all(&cwd).unwrap();
    fs::create_dir(repo.join(".git")).unwrap();
    fs::create_dir_all(global.parent().unwrap()).unwrap();
    fs::write(&global, "global rules").unwrap();
    fs::write(repo.join("AGENTS.md"), "repo rules").unwrap();
    let files = a_agent::context::discover_agents_for_targets(&cwd, Some(&global), &[]).unwrap();
    assert_eq!(
        files
            .iter()
            .map(|item| item.content.as_str())
            .collect::<Vec<_>>(),
        ["global rules", "repo rules"]
    );
}

#[test]
fn system_prompt_has_active_rules_and_skill_references_only() {
    let prompt = build_system_prompt(&ContextInput {
        cwd: Path::new("/repo").to_path_buf(),
        agents: vec![a_agent::context::AgentsFile {
            path: Path::new("/repo/AGENTS.md").to_path_buf(),
            content: "Run tests.".into(),
        }],
        skills: vec![SkillMetadata {
            name: "review".into(),
            description: "Review code.".into(),
            path: Path::new("/repo/.agents/skills/review/SKILL.md").to_path_buf(),
        }],
        platform: "linux".into(),
        shell: "/bin/fish".into(),
    });
    assert!(prompt.contains("Run tests."));
    assert!(prompt.contains("Review code."));
    assert!(prompt.contains("/repo/.agents/skills/review/SKILL.md"));
    assert!(!prompt.contains("Targeted files"));
    assert!(prompt.contains("Relative read and apply_patch paths are resolved from the cwd"));
    assert!(prompt.contains("Before any git command, first confirm that .git exists"));
    assert!(prompt.contains("Never inspect the workspace merely because it is available"));
    assert!(prompt.contains("Rust runtime injects recent records"));
    assert!(prompt.contains("integration lacks context injection"));
    assert!(!prompt.contains("SECRET BODY"));
}