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 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 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 #[test]
88 fn no_heading_fires_when_missing() {
89 let v = CodexValidator;
90 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 #[test]
122 fn too_sparse_fires_when_few_lines() {
123 let v = CodexValidator;
124 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 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}