agent-config 0.4.0

Install hooks/integrations into AI coding harnesses (Claude Code, Cursor, Gemini CLI, OpenCode, Codex CLI, Cline, Windsurf, ...) without learning each one's filesystem layout.
Documentation
#![allow(unused_must_use)]

//! Public-API acceptance tests for drift validation.

use agent_config::{
    by_id, mcp_by_id, skill_by_id, DriftIssue, Event, HookSpec, McpSpec, Scope, SkillSpec,
};

fn mcp_spec(name: &str, owner: &str) -> McpSpec {
    McpSpec::builder(name)
        .owner(owner)
        .stdio("npx", ["-y", "@example/server"])
        .build()
}

fn skill_spec(name: &str, owner: &str) -> SkillSpec {
    SkillSpec::builder(name)
        .owner(owner)
        .description("A test skill for validation checks.")
        .body("## Goal\nDo the thing.\n")
        .build()
}

fn hook_spec(tag: &str) -> HookSpec {
    HookSpec::builder(tag)
        .command_program("echo", ["ok"])
        .event(Event::PreToolUse)
        .build()
}

#[test]
fn mcp_validation_reports_ledger_only_when_config_missing() {
    let dir = tempfile::tempdir().unwrap();
    let scope = Scope::Local(dir.path().to_path_buf());
    let agent = mcp_by_id("claude").unwrap();
    agent
        .install_mcp(&scope, &mcp_spec("smoketest-server", "myapp"))
        .unwrap();

    std::fs::remove_file(dir.path().join(".mcp.json")).unwrap();

    let report = agent.validate_mcp(&scope, "smoketest-server").unwrap();
    assert!(!report.ok);
    assert!(
        report
            .issues
            .iter()
            .any(|issue| matches!(issue, DriftIssue::LedgerOnly { owner: Some(owner), .. } if owner == "myapp")),
        "expected LedgerOnly, got {:?}",
        report.issues
    );
}

#[test]
fn mcp_validation_reports_config_only_when_ledger_missing() {
    let dir = tempfile::tempdir().unwrap();
    let scope = Scope::Local(dir.path().to_path_buf());
    std::fs::write(
        dir.path().join(".mcp.json"),
        r#"{ "mcpServers": { "user-thing": { "command": "user-cmd" } } }"#,
    )
    .unwrap();

    let report = mcp_by_id("claude")
        .unwrap()
        .validate_mcp(&scope, "user-thing")
        .unwrap();
    assert!(!report.ok);
    assert!(
        report
            .issues
            .iter()
            .any(|issue| matches!(issue, DriftIssue::ConfigOnly { .. })),
        "expected ConfigOnly, got {:?}",
        report.issues
    );
}

#[test]
fn mcp_validation_reports_owner_mismatch_when_owner_requested() {
    let dir = tempfile::tempdir().unwrap();
    let scope = Scope::Local(dir.path().to_path_buf());
    let agent = mcp_by_id("claude").unwrap();
    agent
        .install_mcp(&scope, &mcp_spec("smoketest-server", "app-a"))
        .unwrap();

    let report = agent
        .validate_mcp_for_owner(&scope, "smoketest-server", Some("app-b"))
        .unwrap();
    assert!(!report.ok);
    assert!(
        report.issues.iter().any(|issue| {
            matches!(
                issue,
                DriftIssue::OwnerMismatch { expected, actual: Some(actual), .. }
                    if expected == "app-b" && actual == "app-a"
            )
        }),
        "expected OwnerMismatch, got {:?}",
        report.issues
    );

    let ownerless = agent.validate_mcp(&scope, "smoketest-server").unwrap();
    assert!(
        ownerless.ok,
        "ownerless consistency validation should pass, got {:?}",
        ownerless.issues
    );
}

#[test]
fn mcp_validation_reports_malformed_config() {
    let dir = tempfile::tempdir().unwrap();
    let scope = Scope::Local(dir.path().to_path_buf());
    std::fs::write(dir.path().join(".mcp.json"), b"{not valid json").unwrap();

    let report = mcp_by_id("claude")
        .unwrap()
        .validate_mcp(&scope, "smoketest-server")
        .unwrap();
    assert!(!report.ok);
    assert!(
        report
            .issues
            .iter()
            .any(|issue| matches!(issue, DriftIssue::MalformedConfig { .. })),
        "expected MalformedConfig, got {:?}",
        report.issues
    );
}

#[test]
fn mcp_validation_reports_malformed_ledger_without_modifying_it() {
    let dir = tempfile::tempdir().unwrap();
    let scope = Scope::Local(dir.path().to_path_buf());
    std::fs::write(
        dir.path().join(".mcp.json"),
        r#"{ "mcpServers": { "smoketest-server": { "command": "npx" } } }"#,
    )
    .unwrap();
    let ledger = dir.path().join(".agent-config-mcp.json");
    std::fs::write(&ledger, b"{not valid ledger").unwrap();
    let before = std::fs::read(&ledger).unwrap();

    let report = mcp_by_id("claude")
        .unwrap()
        .validate_mcp(&scope, "smoketest-server")
        .unwrap();
    let after = std::fs::read(&ledger).unwrap();

    assert_eq!(before, after, "validation must not repair ledgers");
    assert!(!report.ok);
    assert!(
        report
            .issues
            .iter()
            .any(|issue| matches!(issue, DriftIssue::MalformedLedger { .. })),
        "expected MalformedLedger, got {:?}",
        report.issues
    );
}

#[test]
fn mcp_validation_reports_malformed_ledger_shape() {
    let dir = tempfile::tempdir().unwrap();
    let scope = Scope::Local(dir.path().to_path_buf());
    std::fs::write(
        dir.path().join(".mcp.json"),
        r#"{ "mcpServers": { "smoketest-server": { "command": "npx" } } }"#,
    )
    .unwrap();
    std::fs::write(
        dir.path().join(".agent-config-mcp.json"),
        r#"{ "version": 1, "entries": [] }"#,
    )
    .unwrap();

    let report = mcp_by_id("claude")
        .unwrap()
        .validate_mcp(&scope, "smoketest-server")
        .unwrap();

    assert!(
        report
            .issues
            .iter()
            .any(|issue| matches!(issue, DriftIssue::MalformedLedger { .. })),
        "expected MalformedLedger, got {:?}",
        report.issues
    );
}

#[test]
fn skill_validation_reports_empty_directory_and_missing_skill_md() {
    let dir = tempfile::tempdir().unwrap();
    let scope = Scope::Local(dir.path().to_path_buf());
    std::fs::create_dir_all(dir.path().join(".claude/skills/empty-skill")).unwrap();

    let report = skill_by_id("claude")
        .unwrap()
        .validate_skill(&scope, "empty-skill")
        .unwrap();
    assert!(!report.ok);
    assert!(
        report
            .issues
            .iter()
            .any(|issue| matches!(issue, DriftIssue::SkillMissingSkillMd { .. })),
        "expected SkillMissingSkillMd, got {:?}",
        report.issues
    );
}

#[test]
fn skill_validation_reports_directory_without_skill_md() {
    let dir = tempfile::tempdir().unwrap();
    let scope = Scope::Local(dir.path().to_path_buf());
    let skill_dir = dir.path().join(".claude/skills/no-manifest");
    std::fs::create_dir_all(skill_dir.join("assets")).unwrap();
    std::fs::write(skill_dir.join("assets/readme.txt"), b"asset").unwrap();

    let report = skill_by_id("claude")
        .unwrap()
        .validate_skill(&scope, "no-manifest")
        .unwrap();
    assert!(
        report
            .issues
            .iter()
            .any(|issue| matches!(issue, DriftIssue::SkillMissingSkillMd { .. })),
        "expected SkillMissingSkillMd, got {:?}",
        report.issues
    );
}

#[cfg(unix)]
#[test]
fn skill_validation_reports_symlink_escape() {
    use std::os::unix::fs::symlink;

    let dir = tempfile::tempdir().unwrap();
    let outside = tempfile::tempdir().unwrap();
    let scope = Scope::Local(dir.path().to_path_buf());
    let agent = skill_by_id("claude").unwrap();
    agent
        .install_skill(&scope, &skill_spec("escape-skill", "myapp"))
        .unwrap();
    let link = dir
        .path()
        .join(".claude/skills/escape-skill/assets/outside");
    std::fs::create_dir_all(link.parent().unwrap()).unwrap();
    symlink(outside.path(), &link).unwrap();

    let report = agent.validate_skill(&scope, "escape-skill").unwrap();
    assert!(
        report
            .issues
            .iter()
            .any(|issue| matches!(issue, DriftIssue::SkillAssetEscapesRoot { .. })),
        "expected SkillAssetEscapesRoot, got {:?}",
        report.issues
    );
}

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

    let dir = tempfile::tempdir().unwrap();
    let scope = Scope::Local(dir.path().to_path_buf());
    let agent = by_id("cline").unwrap();
    agent.install(&scope, &hook_spec("script-owner")).unwrap();

    let script = dir.path().join(".cline/hooks/PreToolUse");
    let mut permissions = std::fs::metadata(&script).unwrap().permissions();
    permissions.set_mode(0o644);
    std::fs::set_permissions(&script, permissions).unwrap();

    let report = agent.validate(&scope, "script-owner").unwrap();
    assert!(
        report.issues.iter().any(|issue| {
            matches!(
                issue,
                DriftIssue::UnexpectedDirectoryShape { reason, .. }
                    if reason.contains("not executable")
            )
        }),
        "expected non-executable hook issue, got {:?}",
        report.issues
    );
}

#[test]
fn hook_validation_reports_malformed_markdown_fence() {
    let dir = tempfile::tempdir().unwrap();
    let scope = Scope::Local(dir.path().to_path_buf());
    std::fs::write(
        dir.path().join("AGENTS.md"),
        "<!-- BEGIN AGENT-CONFIG:broken -->\nmissing end\n",
    )
    .unwrap();

    let report = by_id("openclaw")
        .unwrap()
        .validate(&scope, "broken")
        .unwrap();
    assert!(
        report
            .issues
            .iter()
            .any(|issue| matches!(issue, DriftIssue::MalformedConfig { .. })),
        "expected MalformedConfig for malformed fence, got {:?}",
        report.issues
    );
}

#[test]
fn validation_does_not_create_missing_files() {
    let dir = tempfile::tempdir().unwrap();
    let scope = Scope::Local(dir.path().to_path_buf());
    let report = mcp_by_id("claude")
        .unwrap()
        .validate_mcp(&scope, "missing-server")
        .unwrap();

    assert!(
        report.ok,
        "expected clean absent state, got {:?}",
        report.issues
    );
    assert!(!dir.path().join(".mcp.json").exists());
    assert!(!dir.path().join(".agent-config-mcp.json").exists());
}

#[test]
fn validation_accepts_valid_empty_ledger() {
    let dir = tempfile::tempdir().unwrap();
    let scope = Scope::Local(dir.path().to_path_buf());
    std::fs::write(
        dir.path().join(".agent-config-mcp.json"),
        r#"{ "version": 1, "entries": {} }"#,
    )
    .unwrap();

    let report = mcp_by_id("claude")
        .unwrap()
        .validate_mcp(&scope, "missing-server")
        .unwrap();
    assert!(
        report.ok,
        "expected valid empty ledger, got {:?}",
        report.issues
    );
}

#[test]
fn validation_issue_order_is_deterministic() {
    let dir = tempfile::tempdir().unwrap();
    let scope = Scope::Local(dir.path().to_path_buf());
    std::fs::create_dir_all(dir.path().join(".claude/skills/order-skill")).unwrap();
    let agent = skill_by_id("claude").unwrap();

    let first = agent.validate_skill(&scope, "order-skill").unwrap().issues;
    let second = agent.validate_skill(&scope, "order-skill").unwrap().issues;

    assert_eq!(first, second);
    assert!(
        matches!(first.first(), Some(DriftIssue::ConfigOnly { .. })),
        "expected ConfigOnly first, got {first:?}"
    );
    assert!(
        matches!(first.get(1), Some(DriftIssue::SkillMissingSkillMd { .. })),
        "expected SkillMissingSkillMd second, got {first:?}"
    );
}