1use 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
11pub 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
39pub 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
128pub fn exceeds_threshold(violations: &[Violation], threshold: Severity) -> bool {
130 violations.iter().any(|v| v.severity >= threshold)
131}
132
133pub fn aggregate_valid(results: &[FileLintResult]) -> bool {
135 results.iter().all(|r| r.report.valid)
136}