Skip to main content

agentlint/
scanner.rs

1use std::fs;
2use std::path::{Component, Path, PathBuf};
3
4use anyhow::{Context, Result};
5use globset::{Glob, GlobSet, GlobSetBuilder};
6use ignore::WalkBuilder;
7
8use crate::Config;
9
10const MAX_CONFIG_BYTES: u64 = 2 * 1024 * 1024;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum FileKind {
14    Instructions,
15    Skill,
16    McpJson,
17    McpYaml,
18    ConfigJson,
19    ConfigYaml,
20    ConfigToml,
21}
22
23#[derive(Debug, Clone)]
24pub struct SourceFile {
25    pub relative_path: PathBuf,
26    pub absolute_path: PathBuf,
27    pub kind: FileKind,
28    pub content: String,
29}
30
31/// Discover and read supported configuration files below `root`.
32///
33/// # Errors
34///
35/// Returns an error for invalid ignore patterns, traversal failures, unreadable
36/// relevant files, or non-UTF-8 agent configuration.
37pub fn scan(root: &Path, config: &Config, extra_ignores: &[String]) -> Result<Vec<SourceFile>> {
38    let ignores = build_ignores(root, config, extra_ignores)?;
39    let mut builder = WalkBuilder::new(root);
40    builder
41        .hidden(false)
42        .git_ignore(true)
43        .git_global(true)
44        .git_exclude(true)
45        .follow_links(false)
46        .max_filesize(Some(MAX_CONFIG_BYTES));
47    builder.filter_entry(|entry| {
48        !entry.file_type().is_some_and(|kind| kind.is_dir())
49            || !matches!(
50                entry.file_name().to_str(),
51                Some(
52                    ".git"
53                        | "node_modules"
54                        | "vendor"
55                        | "dist"
56                        | "build"
57                        | "target"
58                        | ".next"
59                        | ".cache"
60                        | "venv"
61                        | ".venv"
62                )
63            )
64    });
65
66    let mut files = Vec::new();
67    for entry in builder.build() {
68        let entry = entry.context("failed while traversing repository")?;
69        let path = entry.path();
70        if !entry.file_type().is_some_and(|kind| kind.is_file()) {
71            continue;
72        }
73        let relative = path.strip_prefix(root).unwrap_or(path);
74        if ignores.is_match(relative) {
75            continue;
76        }
77        let Some(kind) = classify(relative) else {
78            continue;
79        };
80        let metadata = entry.metadata().context("failed to read file metadata")?;
81        if metadata.len() > MAX_CONFIG_BYTES {
82            continue;
83        }
84        let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
85        if bytes.contains(&0) {
86            continue;
87        }
88        let content = String::from_utf8(bytes)
89            .with_context(|| format!("agent configuration is not UTF-8: {}", path.display()))?;
90        files.push(SourceFile {
91            relative_path: relative.to_path_buf(),
92            absolute_path: path.to_path_buf(),
93            kind,
94            content,
95        });
96    }
97    files.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
98    Ok(files)
99}
100
101fn build_ignores(root: &Path, config: &Config, extra: &[String]) -> Result<GlobSet> {
102    let mut builder = GlobSetBuilder::new();
103    let ignore_path = root.join(".agentlintignore");
104    let ignore_file = if ignore_path.is_file() {
105        fs::read_to_string(&ignore_path)
106            .with_context(|| format!("failed to read {}", ignore_path.display()))?
107    } else {
108        String::new()
109    };
110    let file_patterns: Vec<&str> = ignore_file
111        .lines()
112        .map(str::trim)
113        .filter(|line| !line.is_empty() && !line.starts_with('#'))
114        .collect();
115    for pattern in config
116        .ignore
117        .iter()
118        .chain(extra)
119        .map(String::as_str)
120        .chain(file_patterns)
121    {
122        builder
123            .add(Glob::new(pattern).with_context(|| format!("invalid ignore glob `{pattern}`"))?);
124    }
125    Ok(builder.build()?)
126}
127
128fn classify(path: &Path) -> Option<FileKind> {
129    let name = path.file_name()?.to_str()?;
130    let normalized = path.to_string_lossy().replace('\\', "/");
131    let extension = path.extension().and_then(|value| value.to_str());
132    if name == ".mcp.json"
133        || name == "mcp.json"
134        || (name.contains("mcp") && extension == Some("json"))
135    {
136        return Some(FileKind::McpJson);
137    }
138    if name.contains("mcp") && matches!(extension, Some("yaml" | "yml")) {
139        return Some(FileKind::McpYaml);
140    }
141    if name == "SKILL.md" {
142        return Some(FileKind::Skill);
143    }
144    if matches!(name, "AGENTS.md" | "CLAUDE.md" | "GEMINI.md")
145        || normalized == ".github/copilot-instructions.md"
146        || normalized.starts_with(".cursor/") && extension == Some("mdc")
147        || normalized.starts_with(".github/instructions/")
148            && matches!(extension, Some("md" | "mdc"))
149        || is_agent_directory(path) && matches!(extension, Some("md" | "mdc" | "txt"))
150    {
151        return Some(FileKind::Instructions);
152    }
153    if is_agent_directory(path) {
154        return match extension {
155            Some("json") => Some(FileKind::ConfigJson),
156            Some("yaml" | "yml") => Some(FileKind::ConfigYaml),
157            Some("toml") => Some(FileKind::ConfigToml),
158            _ => None,
159        };
160    }
161    None
162}
163
164fn is_agent_directory(path: &Path) -> bool {
165    path.components().any(|component| {
166        matches!(component, Component::Normal(value) if value == ".agent" || value == ".ai" || value == ".codex")
167    })
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn supported_files_are_classified() {
176        assert_eq!(
177            classify(Path::new("AGENTS.md")),
178            Some(FileKind::Instructions)
179        );
180        assert_eq!(
181            classify(Path::new("skills/release/SKILL.md")),
182            Some(FileKind::Skill)
183        );
184        assert_eq!(
185            classify(Path::new(".cursor/rules/rust.mdc")),
186            Some(FileKind::Instructions)
187        );
188        assert_eq!(classify(Path::new("README.md")), None);
189    }
190}