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,
}
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);
}
}