1use std::path::{Path, PathBuf};
2
3#[cfg(feature = "test-utils")]
4pub mod testing;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum Severity {
12 Error,
13 Warning,
14}
15
16impl std::fmt::Display for Severity {
17 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18 match self {
19 Severity::Error => write!(f, "error"),
20 Severity::Warning => write!(f, "warning"),
21 }
22 }
23}
24
25#[derive(Debug, Clone)]
26pub struct Diagnostic {
27 pub path: PathBuf,
28 pub line: usize,
29 pub col: usize,
30 pub severity: Severity,
31 pub message: String,
32}
33
34impl Diagnostic {
35 pub fn error(
36 path: impl Into<PathBuf>,
37 line: usize,
38 col: usize,
39 message: impl Into<String>,
40 ) -> Self {
41 Self {
42 path: path.into(),
43 line,
44 col,
45 severity: Severity::Error,
46 message: message.into(),
47 }
48 }
49
50 pub fn warning(
51 path: impl Into<PathBuf>,
52 line: usize,
53 col: usize,
54 message: impl Into<String>,
55 ) -> Self {
56 Self {
57 path: path.into(),
58 line,
59 col,
60 severity: Severity::Warning,
61 message: message.into(),
62 }
63 }
64
65 pub fn gnu_format(&self) -> String {
66 format!(
67 "{}:{}:{}: {}: {}",
68 self.path.display(),
69 self.line,
70 self.col,
71 self.severity,
72 self.message,
73 )
74 }
75}
76
77pub trait Validator: Send + Sync {
82 fn patterns(&self) -> &[&str];
84
85 fn validate(&self, path: &Path, src: &str) -> Vec<Diagnostic>;
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum OutputFormat {
95 Gnu,
96 Json,
97}
98
99pub struct RunResult {
104 pub diagnostics: Vec<Diagnostic>,
105 pub files_checked: usize,
106}
107
108pub fn run_on(
114 files: impl IntoIterator<Item = (PathBuf, String)>,
115 validators: &[Box<dyn Validator>],
116) -> RunResult {
117 let mut diagnostics = Vec::new();
118 let mut files_checked = 0;
119
120 for (path, src) in files {
121 let matched = find_validators(&path, validators);
122 if matched.is_empty() {
123 continue;
124 }
125 files_checked += 1;
126 for validator in matched {
127 diagnostics.extend(validator.validate(&path, &src));
128 }
129 }
130
131 RunResult {
132 diagnostics,
133 files_checked,
134 }
135}
136
137pub fn run(roots: &[PathBuf], validators: &[Box<dyn Validator>]) -> RunResult {
141 let mut read_errors: Vec<Diagnostic> = Vec::new();
142
143 let files: Vec<(PathBuf, String)> = collect_paths(roots)
144 .into_iter()
145 .filter_map(|path| match std::fs::read_to_string(&path) {
146 Ok(src) => Some((path, src)),
147 Err(e) => {
148 read_errors.push(Diagnostic::error(
149 &path,
150 1,
151 1,
152 format!("could not read file: {e}"),
153 ));
154 None
155 }
156 })
157 .collect();
158
159 let mut result = run_on(files, validators);
160 read_errors.extend(result.diagnostics);
162 result.diagnostics = read_errors;
163 result
164}
165
166fn collect_paths(roots: &[PathBuf]) -> Vec<PathBuf> {
167 let mut out = Vec::new();
168 for root in roots {
169 if root.is_file() {
170 out.push(root.clone());
171 } else if root.is_dir() {
172 for entry in walkdir::WalkDir::new(root)
173 .follow_links(false)
174 .into_iter()
175 .filter_map(|e| e.ok())
176 .filter(|e| e.file_type().is_file())
177 {
178 out.push(entry.into_path());
179 }
180 }
181 }
182 out
183}
184
185fn find_validators<'a>(
186 path: &Path,
187 validators: &'a [Box<dyn Validator>],
188) -> Vec<&'a dyn Validator> {
189 let path_str = path.to_string_lossy();
190 validators
191 .iter()
192 .filter(|v| v.patterns().iter().any(|p| glob_match(p, &path_str)))
193 .map(|v| v.as_ref())
194 .collect()
195}
196
197fn glob_match(pattern: &str, path: &str) -> bool {
199 glob_match_inner(pattern.as_bytes(), path.as_bytes())
200}
201
202fn glob_match_inner(pat: &[u8], s: &[u8]) -> bool {
203 match (pat.first(), s.first()) {
204 (None, None) => true,
205 (None, Some(_)) => false,
206 (Some(b'*'), _) => {
207 if pat.get(1) == Some(&b'*') {
209 let rest_pat = pat.get(2..).unwrap_or(b"");
210 let rest_pat = rest_pat.strip_prefix(b"/").unwrap_or(rest_pat);
212 for i in 0..=s.len() {
214 if glob_match_inner(rest_pat, &s[i..]) {
215 return true;
216 }
217 }
218 false
219 } else {
220 let rest_pat = &pat[1..];
221 for i in 0..=s.len() {
223 if s[..i].contains(&b'/') {
224 break;
225 }
226 if glob_match_inner(rest_pat, &s[i..]) {
227 return true;
228 }
229 }
230 false
231 }
232 }
233 (Some(&pc), Some(&sc)) => {
234 if pc == sc {
235 glob_match_inner(&pat[1..], &s[1..])
236 } else {
237 false
238 }
239 }
240 (Some(_), None) => false,
241 }
242}
243
244pub fn format_gnu(diagnostics: &[Diagnostic]) -> String {
249 diagnostics
250 .iter()
251 .map(|d| d.gnu_format())
252 .collect::<Vec<_>>()
253 .join("\n")
254}
255
256pub fn format_json(diagnostics: &[Diagnostic]) -> String {
257 let entries: Vec<serde_json::Value> = diagnostics
258 .iter()
259 .map(|d| {
260 serde_json::json!({
261 "path": d.path.display().to_string(),
262 "line": d.line,
263 "col": d.col,
264 "severity": d.severity.to_string(),
265 "message": d.message,
266 })
267 })
268 .collect();
269 serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string())
270}
271
272#[cfg(test)]
277mod tests {
278 use super::*;
279
280 #[test]
281 fn glob_literal() {
282 assert!(glob_match("AGENTS.md", "AGENTS.md"));
283 assert!(!glob_match("AGENTS.md", "agents.md"));
284 }
285
286 #[test]
287 fn glob_star() {
288 assert!(glob_match("*.md", "README.md"));
289 assert!(!glob_match("*.md", "src/README.md"));
290 }
291
292 #[test]
293 fn glob_double_star() {
294 assert!(glob_match(
295 ".claude/agents/**/*.md",
296 ".claude/agents/foo/bar.md"
297 ));
298 assert!(glob_match(
299 ".claude/agents/**/*.md",
300 ".claude/agents/bar.md"
301 ));
302 }
303}