1use agentlint_core::{Diagnostic, Difficulty, Validator};
2use agentlint_frontmatter::{ParseError, parse};
3use std::path::Path;
4
5pub struct CursorValidator;
6
7impl Validator for CursorValidator {
8 fn patterns(&self) -> &[&str] {
9 &[
10 ".cursor/rules/**/*.mdc",
11 ".cursor/rules/**/*.md",
12 ".cursorrules",
13 ]
14 }
15
16 fn validate(&self, path: &Path, src: &str) -> Vec<Diagnostic> {
17 if !src.starts_with("---\n") && !src.starts_with("---\r\n") {
19 return vec![];
20 }
21 let fields = match parse(src) {
22 Ok(f) => f,
23 Err(ParseError::UnclosedFence) => {
24 return vec![
25 Diagnostic::error(
26 path,
27 1,
28 1,
29 "unclosed frontmatter fence: missing closing '---'",
30 )
31 .with_rule("cursor/frontmatter/unclosed-fence", Difficulty::Easy),
32 ];
33 }
34 Err(ParseError::NoFence) => return vec![],
35 };
36
37 let mut diags = Vec::new();
38
39 if !fields.iter().any(|f| f.key == "description") {
41 diags.push(
42 Diagnostic::warning(
43 path,
44 1,
45 1,
46 "missing 'description' field: Cursor cannot surface or auto-apply this rule \
47 without a description",
48 )
49 .with_rule("cursor/frontmatter/missing-description", Difficulty::Hard),
50 );
51 }
52
53 if let Some(globs_field) = fields.iter().find(|f| f.key == "globs") {
55 for segment in globs_field.value.split(',') {
56 let seg = segment.trim();
57 if seg.is_empty() {
58 diags.push(
59 Diagnostic::warning(
60 path,
61 globs_field.line,
62 1,
63 "invalid glob: empty segment in 'globs' field",
64 )
65 .with_rule("cursor/frontmatter/invalid-globs", Difficulty::Hard),
66 );
67 continue;
68 }
69 let open_brackets = seg.chars().filter(|&c| c == '[').count();
70 let close_brackets = seg.chars().filter(|&c| c == ']').count();
71 if open_brackets > close_brackets {
72 diags.push(
73 Diagnostic::warning(
74 path,
75 globs_field.line,
76 1,
77 format!("invalid glob '{seg}': unmatched '[' in 'globs' field"),
78 )
79 .with_rule("cursor/frontmatter/invalid-globs", Difficulty::Hard),
80 );
81 }
82 let chars: Vec<char> = seg.chars().collect();
84 let mut i = 0;
85 while i < chars.len() {
86 if chars[i] == '\\' {
87 let next = chars.get(i + 1).copied();
88 match next {
89 None | Some(' ') => {
90 diags.push(
91 Diagnostic::warning(
92 path,
93 globs_field.line,
94 1,
95 format!(
96 "invalid glob '{seg}': invalid escape sequence in \
97 'globs' field"
98 ),
99 )
100 .with_rule(
101 "cursor/frontmatter/invalid-globs",
102 Difficulty::Hard,
103 ),
104 );
105 break;
106 }
107 _ => {
108 i += 1; }
110 }
111 }
112 i += 1;
113 }
114 }
115 }
116
117 diags
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use std::path::Path;
125
126 fn v() -> CursorValidator {
127 CursorValidator
128 }
129
130 #[test]
131 fn no_frontmatter_is_clean() {
132 let diags = v().validate(Path::new("rule.md"), "# Hello\nsome content\n");
133 assert!(diags.is_empty());
134 }
135
136 #[test]
137 fn well_formed_frontmatter_is_clean() {
138 let src = "---\ntitle: test\ndescription: lint files\n---\n# Body\n";
139 let diags = v().validate(Path::new("rule.mdc"), src);
140 assert!(diags.is_empty());
141 }
142
143 #[test]
144 fn unclosed_fence_is_error() {
145 let src = "---\ntitle: test\n# no closing fence\n";
146 let diags = v().validate(Path::new("rule.mdc"), src);
147 assert_eq!(diags.len(), 1);
148 assert!(
149 diags[0].message.contains("unclosed"),
150 "unexpected message: {}",
151 diags[0].message
152 );
153 }
154
155 #[test]
158 fn missing_description_emits_warning() {
159 let src = "---\ntitle: My Rule\n---\n# Body\n";
160 let diags = v().validate(Path::new("rule.mdc"), src);
161 assert_eq!(diags.len(), 1);
162 assert!(
163 diags[0].rule.contains("missing-description"),
164 "rule: {}",
165 diags[0].rule
166 );
167 }
168
169 #[test]
170 fn description_present_no_missing_description_warning() {
171 let src = "---\ndescription: does stuff\nglobs: **/*.rs\n---\n";
172 let diags = v().validate(Path::new("rule.mdc"), src);
173 assert!(
174 !diags.iter().any(|d| d.rule.contains("missing-description")),
175 "unexpected missing-description diagnostic"
176 );
177 }
178
179 #[test]
182 fn valid_globs_are_clean() {
183 let src = "---\ndescription: ok\nglobs: **/*.rs,**/*.toml\n---\n";
184 let diags = v().validate(Path::new("rule.mdc"), src);
185 assert!(diags.is_empty(), "unexpected diags: {diags:?}");
186 }
187
188 #[test]
189 fn unmatched_bracket_in_globs_emits_warning() {
190 let src = "---\ndescription: ok\nglobs: **/*.rs,[invalid\n---\n";
191 let diags = v().validate(Path::new("rule.mdc"), src);
192 assert!(
193 diags.iter().any(|d| d.rule.contains("invalid-globs")),
194 "no invalid-globs diagnostic: {diags:?}"
195 );
196 }
197
198 #[test]
199 fn empty_segment_in_globs_emits_warning() {
200 let src = "---\ndescription: ok\nglobs: **/*.rs,,**/*.toml\n---\n";
201 let diags = v().validate(Path::new("rule.mdc"), src);
202 assert!(
203 diags.iter().any(|d| d.rule.contains("invalid-globs")),
204 "no invalid-globs diagnostic: {diags:?}"
205 );
206 }
207
208 #[test]
209 fn trailing_comma_in_globs_emits_warning() {
210 let src = "---\ndescription: ok\nglobs: **/*.rs,\n---\n";
211 let diags = v().validate(Path::new("rule.mdc"), src);
212 assert!(
213 diags.iter().any(|d| d.rule.contains("invalid-globs")),
214 "no invalid-globs diagnostic: {diags:?}"
215 );
216 }
217}