Skip to main content

agentlint_codex/
lib.rs

1use agentlint_core::{Diagnostic, Difficulty, Validator};
2use std::path::Path;
3
4pub struct CodexValidator;
5
6const MIN_NON_EMPTY_LINES: usize = 5;
7const MIN_NON_WS_CHARS: usize = 100;
8
9impl Validator for CodexValidator {
10    fn patterns(&self) -> &[&str] {
11        &["AGENTS.md"]
12    }
13
14    fn validate(&self, path: &Path, src: &str) -> Vec<Diagnostic> {
15        if src.trim().is_empty() {
16            return vec![
17                Diagnostic::error(path, 1, 1, "AGENTS.md is empty")
18                    .with_rule("codex/content/empty", Difficulty::Easy),
19            ];
20        }
21
22        let mut diags = Vec::new();
23
24        // codex/content/no-heading: no line starting with `#`
25        let has_heading = src.lines().any(|l| l.starts_with('#'));
26        if !has_heading {
27            diags.push(
28                Diagnostic::warning(path, 1, 1, "AGENTS.md has no markdown headings")
29                    .with_rule("codex/content/no-heading", Difficulty::Painful),
30            );
31        }
32
33        // codex/content/too-sparse: fewer than 5 non-empty lines OR fewer than 100 non-ws chars
34        let non_empty_lines = src.lines().filter(|l| !l.trim().is_empty()).count();
35        let non_ws_chars = src.chars().filter(|c| !c.is_whitespace()).count();
36        if non_empty_lines < MIN_NON_EMPTY_LINES || non_ws_chars < MIN_NON_WS_CHARS {
37            diags.push(
38                Diagnostic::warning(
39                    path,
40                    1,
41                    1,
42                    "AGENTS.md is too sparse to provide meaningful guidance",
43                )
44                .with_rule("codex/content/too-sparse", Difficulty::Painful),
45            );
46        }
47
48        diags
49    }
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55    use std::path::Path;
56
57    #[test]
58    fn non_empty_with_heading_is_clean() {
59        let v = CodexValidator;
60        let src = "# Agents\n\nThis is a well-structured agents file.\n\
61                   It has multiple lines of content.\n\
62                   This line adds more context.\n\
63                   And another line for good measure.\n\
64                   Final line to ensure sufficient content here.";
65        let diags = v.validate(Path::new("AGENTS.md"), src);
66        assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
67    }
68
69    #[test]
70    fn empty_file_is_error() {
71        let v = CodexValidator;
72        let diags = v.validate(Path::new("AGENTS.md"), "");
73        assert!(!diags.is_empty());
74        assert!(diags[0].message.contains("empty"));
75    }
76
77    #[test]
78    fn whitespace_only_is_error() {
79        let v = CodexValidator;
80        let diags = v.validate(Path::new("AGENTS.md"), "   \n\t\n  ");
81        assert!(!diags.is_empty());
82        assert!(diags[0].message.contains("empty"));
83    }
84
85    // --- no-heading rule ---
86
87    #[test]
88    fn no_heading_fires_when_missing() {
89        let v = CodexValidator;
90        // Enough content to not trigger too-sparse, but no headings
91        let src = "This is a description without any headings.\n\
92                   It has plenty of lines to read through.\n\
93                   There is no section structure here at all.\n\
94                   The content is just a wall of text flowing.\n\
95                   This is the fifth line of text content now.";
96        let diags = v.validate(Path::new("AGENTS.md"), src);
97        let rules: Vec<_> = diags.iter().map(|d| d.rule).collect();
98        assert!(
99            rules.contains(&"codex/content/no-heading"),
100            "expected no-heading diagnostic, got: {rules:?}"
101        );
102    }
103
104    #[test]
105    fn no_heading_clean_when_heading_present() {
106        let v = CodexValidator;
107        let src = "# Overview\n\nThis file has a heading and sufficient content.\n\
108                   More lines of content here to pass the sparse check.\n\
109                   And more content to ensure we have enough characters.\n\
110                   Final line with enough text to be over one hundred chars.";
111        let diags = v.validate(Path::new("AGENTS.md"), src);
112        let heading_diags: Vec<_> = diags
113            .iter()
114            .filter(|d| d.rule == "codex/content/no-heading")
115            .collect();
116        assert!(heading_diags.is_empty());
117    }
118
119    // --- too-sparse rule ---
120
121    #[test]
122    fn too_sparse_fires_when_few_lines() {
123        let v = CodexValidator;
124        // Only 3 non-empty lines, well under 5
125        let src = "# Agents\nLine two.\nLine three.";
126        let diags = v.validate(Path::new("AGENTS.md"), src);
127        let rules: Vec<_> = diags.iter().map(|d| d.rule).collect();
128        assert!(
129            rules.contains(&"codex/content/too-sparse"),
130            "expected too-sparse diagnostic, got: {rules:?}"
131        );
132    }
133
134    #[test]
135    fn too_sparse_fires_when_few_chars() {
136        let v = CodexValidator;
137        // 5 non-empty lines but very short — under 100 non-ws chars
138        let src = "# A\nb\nc\nd\ne";
139        let diags = v.validate(Path::new("AGENTS.md"), src);
140        let rules: Vec<_> = diags.iter().map(|d| d.rule).collect();
141        assert!(
142            rules.contains(&"codex/content/too-sparse"),
143            "expected too-sparse diagnostic, got: {rules:?}"
144        );
145    }
146
147    #[test]
148    fn too_sparse_clean_when_sufficient_content() {
149        let v = CodexValidator;
150        let src = "# Agents\n\nThis file has enough content to pass.\n\
151                   It has at least five non-empty lines throughout.\n\
152                   This is the fourth line of meaningful content here.\n\
153                   Fifth line ensures we meet the line count threshold.\n\
154                   And this pushes the character count well past one hundred.";
155        let diags = v.validate(Path::new("AGENTS.md"), src);
156        let sparse_diags: Vec<_> = diags
157            .iter()
158            .filter(|d| d.rule == "codex/content/too-sparse")
159            .collect();
160        assert!(sparse_diags.is_empty());
161    }
162}