Skip to main content

agentlint_cursor/
lib.rs

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        // Frontmatter is optional — only validate when the opening fence is present.
18        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        // #39 — missing description
40        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        // #40 — invalid globs
54        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                // Check for invalid escape sequences: backslash not followed by a valid char
83                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; // skip escaped char
109                            }
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    // #39 — missing-description
156
157    #[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    // #40 — invalid-globs
180
181    #[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}