agentlint-cli 0.1.0

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

use anyhow::{Context, Result};
use globset::{Glob, GlobSet, GlobSetBuilder};
use ignore::WalkBuilder;

use crate::Config;

const MAX_CONFIG_BYTES: u64 = 2 * 1024 * 1024;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileKind {
    Instructions,
    Skill,
    McpJson,
    McpYaml,
    ConfigJson,
    ConfigYaml,
    ConfigToml,
}

#[derive(Debug, Clone)]
pub struct SourceFile {
    pub relative_path: PathBuf,
    pub absolute_path: PathBuf,
    pub kind: FileKind,
    pub content: String,
}

/// Discover and read supported configuration files below `root`.
///
/// # Errors
///
/// Returns an error for invalid ignore patterns, traversal failures, unreadable
/// relevant files, or non-UTF-8 agent configuration.
pub fn scan(root: &Path, config: &Config, extra_ignores: &[String]) -> Result<Vec<SourceFile>> {
    let ignores = build_ignores(root, config, extra_ignores)?;
    let mut builder = WalkBuilder::new(root);
    builder
        .hidden(false)
        .git_ignore(true)
        .git_global(true)
        .git_exclude(true)
        .follow_links(false)
        .max_filesize(Some(MAX_CONFIG_BYTES));
    builder.filter_entry(|entry| {
        !entry.file_type().is_some_and(|kind| kind.is_dir())
            || !matches!(
                entry.file_name().to_str(),
                Some(
                    ".git"
                        | "node_modules"
                        | "vendor"
                        | "dist"
                        | "build"
                        | "target"
                        | ".next"
                        | ".cache"
                        | "venv"
                        | ".venv"
                )
            )
    });

    let mut files = Vec::new();
    for entry in builder.build() {
        let entry = entry.context("failed while traversing repository")?;
        let path = entry.path();
        if !entry.file_type().is_some_and(|kind| kind.is_file()) {
            continue;
        }
        let relative = path.strip_prefix(root).unwrap_or(path);
        if ignores.is_match(relative) {
            continue;
        }
        let Some(kind) = classify(relative) else {
            continue;
        };
        let metadata = entry.metadata().context("failed to read file metadata")?;
        if metadata.len() > MAX_CONFIG_BYTES {
            continue;
        }
        let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
        if bytes.contains(&0) {
            continue;
        }
        let content = String::from_utf8(bytes)
            .with_context(|| format!("agent configuration is not UTF-8: {}", path.display()))?;
        files.push(SourceFile {
            relative_path: relative.to_path_buf(),
            absolute_path: path.to_path_buf(),
            kind,
            content,
        });
    }
    files.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
    Ok(files)
}

fn build_ignores(root: &Path, config: &Config, extra: &[String]) -> Result<GlobSet> {
    let mut builder = GlobSetBuilder::new();
    let ignore_path = root.join(".agentlintignore");
    let ignore_file = if ignore_path.is_file() {
        fs::read_to_string(&ignore_path)
            .with_context(|| format!("failed to read {}", ignore_path.display()))?
    } else {
        String::new()
    };
    let file_patterns: Vec<&str> = ignore_file
        .lines()
        .map(str::trim)
        .filter(|line| !line.is_empty() && !line.starts_with('#'))
        .collect();
    for pattern in config
        .ignore
        .iter()
        .chain(extra)
        .map(String::as_str)
        .chain(file_patterns)
    {
        builder
            .add(Glob::new(pattern).with_context(|| format!("invalid ignore glob `{pattern}`"))?);
    }
    Ok(builder.build()?)
}

fn classify(path: &Path) -> Option<FileKind> {
    let name = path.file_name()?.to_str()?;
    let normalized = path.to_string_lossy().replace('\\', "/");
    let extension = path.extension().and_then(|value| value.to_str());
    if name == ".mcp.json"
        || name == "mcp.json"
        || (name.contains("mcp") && extension == Some("json"))
    {
        return Some(FileKind::McpJson);
    }
    if name.contains("mcp") && matches!(extension, Some("yaml" | "yml")) {
        return Some(FileKind::McpYaml);
    }
    if name == "SKILL.md" {
        return Some(FileKind::Skill);
    }
    if matches!(name, "AGENTS.md" | "CLAUDE.md" | "GEMINI.md")
        || normalized == ".github/copilot-instructions.md"
        || normalized.starts_with(".cursor/") && extension == Some("mdc")
        || normalized.starts_with(".github/instructions/")
            && matches!(extension, Some("md" | "mdc"))
        || is_agent_directory(path) && matches!(extension, Some("md" | "mdc" | "txt"))
    {
        return Some(FileKind::Instructions);
    }
    if is_agent_directory(path) {
        return match extension {
            Some("json") => Some(FileKind::ConfigJson),
            Some("yaml" | "yml") => Some(FileKind::ConfigYaml),
            Some("toml") => Some(FileKind::ConfigToml),
            _ => None,
        };
    }
    None
}

fn is_agent_directory(path: &Path) -> bool {
    path.components().any(|component| {
        matches!(component, Component::Normal(value) if value == ".agent" || value == ".ai" || value == ".codex")
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn supported_files_are_classified() {
        assert_eq!(
            classify(Path::new("AGENTS.md")),
            Some(FileKind::Instructions)
        );
        assert_eq!(
            classify(Path::new("skills/release/SKILL.md")),
            Some(FileKind::Skill)
        );
        assert_eq!(
            classify(Path::new(".cursor/rules/rust.mdc")),
            Some(FileKind::Instructions)
        );
        assert_eq!(classify(Path::new("README.md")), None);
    }
}