Skip to main content

agentlint_core/
lib.rs

1use std::path::{Path, PathBuf};
2
3#[cfg(feature = "test-utils")]
4pub mod testing;
5
6// ---------------------------------------------------------------------------
7// Diagnostic
8// ---------------------------------------------------------------------------
9
10#[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
77// ---------------------------------------------------------------------------
78// Validator trait
79// ---------------------------------------------------------------------------
80
81pub trait Validator: Send + Sync {
82    /// File glob patterns this validator claims (e.g. `.claude/agents/**/*.md`).
83    fn patterns(&self) -> &[&str];
84
85    /// Validate `src` (the file contents) for `path`. Returns all diagnostics.
86    fn validate(&self, path: &Path, src: &str) -> Vec<Diagnostic>;
87}
88
89// ---------------------------------------------------------------------------
90// Output format
91// ---------------------------------------------------------------------------
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum OutputFormat {
95    Gnu,
96    Json,
97}
98
99// ---------------------------------------------------------------------------
100// Runner
101// ---------------------------------------------------------------------------
102
103pub struct RunResult {
104    pub diagnostics: Vec<Diagnostic>,
105    pub files_checked: usize,
106}
107
108/// Pure domain runner: dispatch `files` (already-loaded path+content pairs) to
109/// matching validators and collect diagnostics.
110///
111/// This is the hexagonal core — it has no filesystem dependency. Infrastructure
112/// callers (see [`run`]) are responsible for discovery and I/O.
113pub 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
137/// Infrastructure convenience: walk `roots`, read each file, then delegate to
138/// [`run_on`]. Read errors are surfaced as [`Diagnostic::error`] entries rather
139/// than panicking.
140pub 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    // Prepend read errors so they appear before validation diagnostics.
161    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
197/// Minimal glob matching: supports `**`, `*`, and literal segments.
198fn 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            // Check for `**`
208            if pat.get(1) == Some(&b'*') {
209                let rest_pat = pat.get(2..).unwrap_or(b"");
210                // Skip leading `/` after `**`
211                let rest_pat = rest_pat.strip_prefix(b"/").unwrap_or(rest_pat);
212                // Try matching rest_pat against every suffix of s
213                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                // `*` matches anything except `/`
222                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
244// ---------------------------------------------------------------------------
245// Output helpers
246// ---------------------------------------------------------------------------
247
248pub 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// ---------------------------------------------------------------------------
273// Tests
274// ---------------------------------------------------------------------------
275
276#[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}