agentlint-cli 0.1.0

A fast, local-first linter for AI coding-agent configuration
Documentation
use std::fs;
use std::path::{Path, PathBuf};

use agentlint::{LintOptions, Severity};
use assert_cmd::Command;
use tempfile::tempdir;

fn fixture(name: &str) -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests/fixtures")
        .join(name)
}

fn rules(name: &str) -> Vec<String> {
    agentlint::lint(&fixture(name), &LintOptions::default())
        .expect("fixture should lint")
        .diagnostics
        .into_iter()
        .map(|diagnostic| diagnostic.rule)
        .collect()
}

#[test]
fn clean_project_has_no_diagnostics() {
    let report = agentlint::lint(&fixture("clean-project"), &LintOptions::default()).expect("lint");
    assert!(report.diagnostics.is_empty(), "{:?}", report.diagnostics);
    assert_eq!(report.files_scanned, 1);
}

#[test]
fn validates_mcp_structure_executable_and_environment() {
    let found = rules("broken-mcp");
    assert!(found.contains(&"AGL101".into()));
    assert!(found.contains(&"AGL102".into()));
    assert!(found.contains(&"AGL104".into()));
    assert!(found.contains(&"AGL105".into()));
}

#[test]
fn detects_duplicate_guidance() {
    assert!(rules("duplicate-rules").contains(&"AGL302".into()));
}

#[test]
fn detects_stale_paths_and_scripts() {
    let found = rules("stale-paths");
    assert!(found.contains(&"AGL202".into()));
    assert!(found.contains(&"AGL203".into()));
}

#[test]
fn secret_diagnostic_is_redacted() {
    let report = agentlint::lint(&fixture("leaked-secret"), &LintOptions::default()).expect("lint");
    let diagnostic = report
        .diagnostics
        .iter()
        .find(|diagnostic| diagnostic.rule == "AGL401")
        .expect("secret diagnostic");
    assert!(
        !diagnostic
            .message
            .contains("sk-proj-fixture123456789secret")
    );
    assert!(diagnostic.message.contains("..."));
}

#[test]
fn detects_package_manager_conflict() {
    assert!(rules("package-manager-conflict").contains(&"AGL601".into()));
}

#[test]
fn malformed_json_reports_source_location() {
    let directory = tempdir().expect("tempdir");
    fs::write(
        directory.path().join(".mcp.json"),
        "{\n  \"mcpServers\": {\n",
    )
    .expect("fixture");
    let report = agentlint::lint(directory.path(), &LintOptions::default()).expect("lint");
    let diagnostic = report
        .diagnostics
        .iter()
        .find(|item| item.rule == "AGL001")
        .expect("syntax diagnostic");
    assert!(diagnostic.line.is_some());
    assert_eq!(diagnostic.severity, Severity::Error);
}

#[test]
fn duplicate_json_key_is_reported() {
    let directory = tempdir().expect("tempdir");
    fs::write(
        directory.path().join(".mcp.json"),
        "{\n  \"mcpServers\": {\n    \"one\": {\n      \"command\": \"cargo\",\n      \"command\": \"git\"\n    }\n  }\n}\n",
    )
    .expect("fixture");
    assert!(
        agentlint::lint(directory.path(), &LintOptions::default())
            .expect("lint")
            .diagnostics
            .iter()
            .any(|item| item.rule == "AGL003")
    );
}

#[test]
fn contradiction_patterns_are_conservative_and_detect_examples() {
    let directory = tempdir().expect("tempdir");
    fs::write(
        directory.path().join("AGENTS.md"),
        "Always use npm.\nNever use npm; use pnpm instead.\nDo not modify files under src/generated.\nYou may freely edit src/generated.\n",
    )
    .expect("fixture");
    let report = agentlint::lint(directory.path(), &LintOptions::default()).expect("lint");
    assert!(
        report
            .diagnostics
            .iter()
            .filter(|item| item.rule == "AGL602")
            .count()
            >= 2
    );
}

#[test]
fn gitignore_and_agentlintignore_are_respected() {
    let directory = tempdir().expect("tempdir");
    fs::create_dir(directory.path().join("ignored")).expect("directory");
    fs::write(directory.path().join(".agentlintignore"), "ignored/**\n").expect("ignore");
    fs::write(
        directory.path().join("ignored/AGENTS.md"),
        "Run scripts/missing.sh\n",
    )
    .expect("file");
    let report = agentlint::lint(directory.path(), &LintOptions::default()).expect("lint");
    assert_eq!(report.files_scanned, 0);
}

#[test]
fn cli_json_output_is_valid_and_clean() {
    let output = Command::cargo_bin("agentlint")
        .expect("binary")
        .args([
            fixture("stale-paths").to_str().expect("utf8"),
            "--format",
            "json",
            "--no-color",
        ])
        .output()
        .expect("run");
    assert_eq!(output.status.code(), Some(1));
    let value: serde_json::Value =
        serde_json::from_slice(&output.stdout).expect("valid JSON stdout");
    assert_eq!(value["version"], "0.1.0");
    assert!(output.stderr.is_empty());
}

#[test]
fn cli_github_output_uses_annotations() {
    Command::cargo_bin("agentlint")
        .expect("binary")
        .args([
            fixture("stale-paths").to_str().expect("utf8"),
            "--format",
            "github",
        ])
        .assert()
        .failure()
        .stdout(predicates::str::contains("::error file=AGENTS.md"));
}

#[test]
fn init_creates_valid_starter_config() {
    let directory = tempdir().expect("tempdir");
    Command::cargo_bin("agentlint")
        .expect("binary")
        .args(["init", directory.path().to_str().expect("utf8")])
        .assert()
        .success();
    let config = fs::read_to_string(directory.path().join(".agentlint.yaml")).expect("config");
    assert!(config.contains("AGL401: error"));
    agentlint::lint(directory.path(), &LintOptions::default()).expect("starter config parses");
}

#[test]
fn safe_fix_corrects_proven_package_manager() {
    let directory = tempdir().expect("tempdir");
    fs::write(
        directory.path().join("pnpm-lock.yaml"),
        "lockfileVersion: '9.0'\n",
    )
    .expect("lock");
    fs::write(
        directory.path().join("package.json"),
        r#"{"scripts":{"test":"node --test"}}"#,
    )
    .expect("package");
    fs::write(
        directory.path().join("AGENTS.md"),
        "Run `npm run test`.  \n",
    )
    .expect("instructions");
    Command::cargo_bin("agentlint")
        .expect("binary")
        .args([
            directory.path().to_str().expect("utf8"),
            "--fix",
            "--no-color",
        ])
        .assert()
        .success();
    assert_eq!(
        fs::read_to_string(directory.path().join("AGENTS.md")).expect("fixed"),
        "Run `pnpm run test`.\n"
    );
}

#[test]
fn skill_frontmatter_and_globs_are_validated() {
    let directory = tempdir().expect("tempdir");
    fs::create_dir_all(directory.path().join("skills/demo")).expect("directory");
    fs::write(
        directory.path().join("skills/demo/SKILL.md"),
        "---\nname: demo\ndescription: Demo skill\nglobs: '[src/**'\n---\n# Demo\n",
    )
    .expect("skill");
    let found = agentlint::lint(directory.path(), &LintOptions::default())
        .expect("lint")
        .diagnostics;
    assert!(found.iter().any(|item| item.rule == "AGL102"));
    assert!(found.iter().any(|item| item.rule == "AGL201"));
}

#[test]
fn missing_skill_frontmatter_is_an_error() {
    let directory = tempdir().expect("tempdir");
    fs::create_dir_all(directory.path().join("skills/demo")).expect("directory");
    fs::write(directory.path().join("skills/demo/SKILL.md"), "# Demo\n").expect("skill");
    let report = agentlint::lint(directory.path(), &LintOptions::default()).expect("lint");
    assert!(
        report
            .diagnostics
            .iter()
            .any(|item| item.rule == "AGL002" && item.severity == Severity::Error)
    );
}

#[test]
fn destructive_commands_and_stale_tooling_are_detected() {
    let directory = tempdir().expect("tempdir");
    fs::write(
        directory.path().join("AGENTS.md"),
        "Run:\n```bash\ngit clean -fdx\npytest\n```\n",
    )
    .expect("instructions");
    let found = agentlint::lint(directory.path(), &LintOptions::default())
        .expect("lint")
        .diagnostics;
    assert!(found.iter().any(|item| item.rule == "AGL501"));
    assert!(found.iter().any(|item| item.rule == "AGL603"));
}

#[test]
fn generic_agent_configuration_syntax_is_checked() {
    let directory = tempdir().expect("tempdir");
    fs::create_dir(directory.path().join(".codex")).expect("directory");
    fs::write(directory.path().join(".codex/config.toml"), "model = [\n").expect("config");
    assert!(
        agentlint::lint(directory.path(), &LintOptions::default())
            .expect("lint")
            .diagnostics
            .iter()
            .any(|item| item.rule == "AGL001")
    );
}

#[test]
fn configured_rule_can_be_disabled() {
    let directory = tempdir().expect("tempdir");
    fs::write(
        directory.path().join("AGENTS.md"),
        "Read `src/missing.rs`.\n",
    )
    .expect("instructions");
    fs::write(
        directory.path().join(".agentlint.yaml"),
        "rules:\n  AGL202: off\n",
    )
    .expect("config");
    let report = agentlint::lint(directory.path(), &LintOptions::default()).expect("lint");
    assert!(!report.diagnostics.iter().any(|item| item.rule == "AGL202"));
}

#[test]
fn large_context_is_measured_and_reported() {
    let directory = tempdir().expect("tempdir");
    let content = format!("Always follow this policy.\n\n{}\n", "word ".repeat(6_100));
    fs::write(directory.path().join("AGENTS.md"), content).expect("instructions");
    let report = agentlint::lint(directory.path(), &LintOptions::default()).expect("lint");
    assert!(report.estimated_total_tokens >= 6_000);
    assert!(report.diagnostics.iter().any(|item| item.rule == "AGL301"));
}