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
31pub 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}