Skip to main content

agent_rules_tool/
lint.rs

1//! Lint rule file content against the embedded JSON Schema and RFC semantics.
2
3use crate::error::Error;
4use crate::parse::{is_empty_frontmatter, parse_rule};
5use crate::schema::validate_frontmatter;
6use crate::spec::{RFC_FILE_CONVENTIONS, RFC_TRIGGER_MODES};
7use crate::{FileLintResult, LintOptions, LintReport, Severity, Violation};
8use serde_json::Value;
9use std::path::Path;
10
11/// Lint one rule file's markdown content (frontmatter + body).
12///
13/// Validates frontmatter against the embedded schema and applies semantic checks
14/// such as `name` vs filename stem and `trigger: auto` requiring `paths` or
15/// `keywords`.
16///
17/// # Examples
18///
19/// ```
20/// use agent_rules_tool::{lint_string, LintOptions};
21///
22/// let content = "---\nname: my-rule\ntrigger: always\n---\n\n# My rule\n";
23/// let report = lint_string(content, &LintOptions::default())?;
24/// assert!(report.valid);
25/// # Ok::<(), agent_rules_tool::Error>(())
26/// ```
27pub fn lint_string(content: &str, options: &LintOptions) -> Result<LintReport, Error> {
28    let parsed = parse_rule(content)?;
29    let mut violations = validate_frontmatter(&parsed.frontmatter)?;
30    violations.extend(semantic_checks(
31        &parsed.frontmatter,
32        options.filename_hint.as_deref(),
33    ));
34
35    let valid = violations.is_empty();
36    Ok(LintReport { violations, valid })
37}
38
39/// Recursively lint all `*.md` files under `root`.
40///
41/// Returns an empty vector when `root` does not exist. Paths in each
42/// [`FileLintResult`] are relative to `root`.
43pub fn lint_directory(root: &Path, options: &LintOptions) -> Result<Vec<FileLintResult>, Error> {
44    if !root.exists() {
45        return Ok(Vec::new());
46    }
47
48    let mut results = Vec::new();
49    collect_md_files(root, root, &mut results, options)?;
50    results.sort_by(|a, b| a.path.cmp(&b.path));
51    Ok(results)
52}
53
54fn collect_md_files(
55    root: &Path,
56    current: &Path,
57    results: &mut Vec<FileLintResult>,
58    options: &LintOptions,
59) -> Result<(), Error> {
60    if !current.is_dir() {
61        return Ok(());
62    }
63
64    for entry in std::fs::read_dir(current)? {
65        let entry = entry?;
66        let path = entry.path();
67        if path.is_dir() {
68            collect_md_files(root, &path, results, options)?;
69        } else if path.extension().and_then(|e| e.to_str()) == Some("md") {
70            let content = std::fs::read_to_string(&path)?;
71            let rel = path.strip_prefix(root).unwrap_or(&path).to_path_buf();
72            let filename_hint = path
73                .file_stem()
74                .and_then(|s| s.to_str())
75                .map(str::to_string);
76            let mut file_options = options.clone();
77            file_options.filename_hint = filename_hint;
78            let report = lint_string(&content, &file_options)?;
79            results.push(FileLintResult { path: rel, report });
80        }
81    }
82    Ok(())
83}
84
85fn semantic_checks(frontmatter: &Value, filename_hint: Option<&str>) -> Vec<Violation> {
86    if is_empty_frontmatter(frontmatter) {
87        return Vec::new();
88    }
89
90    let mut violations = Vec::new();
91
92    if let Some(stem) = filename_hint
93        && let Some(name) = frontmatter.get("name").and_then(|v| v.as_str())
94        && name != stem
95    {
96        violations.push(Violation {
97            severity: Severity::Warn,
98            field: "name".to_string(),
99            message: format!("name '{name}' does not match filename stem '{stem}'"),
100            spec_ref: RFC_FILE_CONVENTIONS,
101        });
102    }
103
104    if let Some(trigger) = frontmatter.get("trigger").and_then(|v| v.as_str())
105        && trigger == "auto"
106    {
107        let has_paths = frontmatter
108            .get("paths")
109            .and_then(|v| v.as_array())
110            .is_some_and(|a| !a.is_empty());
111        let has_keywords = frontmatter
112            .get("keywords")
113            .and_then(|v| v.as_array())
114            .is_some_and(|a| !a.is_empty());
115        if !has_paths && !has_keywords {
116            violations.push(Violation {
117                severity: Severity::Error,
118                field: "trigger".to_string(),
119                message: "when trigger is 'auto', paths or keywords is required".to_string(),
120                spec_ref: RFC_TRIGGER_MODES,
121            });
122        }
123    }
124
125    violations
126}
127
128/// Returns `true` when any violation meets or exceeds `threshold`.
129pub fn exceeds_threshold(violations: &[Violation], threshold: Severity) -> bool {
130    violations.iter().any(|v| v.severity >= threshold)
131}
132
133/// Returns `true` when every file result in `results` is valid.
134pub fn aggregate_valid(results: &[FileLintResult]) -> bool {
135    results.iter().all(|r| r.report.valid)
136}