1use super::super::{AuditGate, AuditOptions, Finding, Severity, Site};
16
17const NAME: &str = "ai_discovery";
18
19#[derive(Debug, Clone, Copy)]
30pub struct AiDiscoveryGate;
31
32impl AuditGate for AiDiscoveryGate {
33 fn name(&self) -> &'static str {
34 NAME
35 }
36
37 fn explain(&self) -> &'static str {
38 "Asserts that AI-discovery protocol files exist + parse: \
39 llms.txt (shipped in v0.0.43), agents.txt and \
40 .well-known/ai-plugin.json (both from E8 — issue #552). \
41 Files produced by E8 emit info notes when absent; the \
42 already-shipped llms.txt emits a warning."
43 }
44
45 fn run(&self, site: &Site, _opts: &AuditOptions) -> Vec<Finding> {
46 let mut findings = Vec::new();
47
48 let llms = site.root.join("llms.txt");
50 if !llms.exists() {
51 findings.push(
52 Finding::new(
53 NAME,
54 Severity::Warn,
55 "llms.txt is missing (LlmManifestPlugin should emit it)",
56 )
57 .with_code("AI-LLMS-MISSING")
58 .with_path("llms.txt".to_string()),
59 );
60 } else if let Ok(text) = std::fs::read_to_string(&llms) {
61 if text.trim().is_empty() {
62 findings.push(
63 Finding::new(NAME, Severity::Warn, "llms.txt is empty")
64 .with_code("AI-LLMS-EMPTY")
65 .with_path("llms.txt".to_string()),
66 );
67 }
68 }
69
70 let agents = site.root.join("agents.txt");
72 if !agents.exists() {
73 findings.push(
74 Finding::new(
75 NAME,
76 Severity::Info,
77 "agents.txt absent (depends on E8 — issue #552)",
78 )
79 .with_code("AI-AGENTS-MISSING")
80 .with_path("agents.txt".to_string()),
81 );
82 }
83
84 let plugin_json = site.root.join(".well-known/ai-plugin.json");
86 if !plugin_json.exists() {
87 findings.push(
88 Finding::new(
89 NAME,
90 Severity::Info,
91 ".well-known/ai-plugin.json absent (depends on E8 — issue #552)",
92 )
93 .with_code("AI-PLUGIN-JSON-MISSING")
94 .with_path(".well-known/ai-plugin.json".to_string()),
95 );
96 } else if let Ok(text) = std::fs::read_to_string(&plugin_json) {
97 if serde_json::from_str::<serde_json::Value>(&text).is_err() {
98 findings.push(
99 Finding::new(
100 NAME,
101 Severity::Error,
102 ".well-known/ai-plugin.json is not valid JSON",
103 )
104 .with_code("AI-PLUGIN-JSON-INVALID")
105 .with_path(".well-known/ai-plugin.json".to_string()),
106 );
107 }
108 }
109
110 findings
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117
118 fn site_with_files(files: &[(&str, &str)]) -> Site {
119 let tmp = tempfile::tempdir().unwrap();
120 let root = tmp.path().to_path_buf();
121 for (name, body) in files {
122 let p = root.join(name);
123 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
125 std::fs::write(&p, body).unwrap();
126 }
127 std::mem::forget(tmp);
128 Site {
129 root,
130 html_files: Vec::new(),
131 }
132 }
133
134 #[test]
135 fn all_present_and_valid_passes() {
136 let s = site_with_files(&[
137 ("llms.txt", "# llms\n"),
138 ("agents.txt", "# agents\n"),
139 (".well-known/ai-plugin.json", r#"{"schema_version":"v1"}"#),
140 ]);
141 let f = AiDiscoveryGate.run(&s, &AuditOptions::default());
142 assert!(f.is_empty(), "got {f:?}");
143 }
144
145 #[test]
146 fn missing_e8_files_emit_info_not_error() {
147 let s = site_with_files(&[("llms.txt", "# llms\n")]);
148 let f = AiDiscoveryGate.run(&s, &AuditOptions::default());
149 assert!(f.iter().all(|x| matches!(x.severity, Severity::Info)));
150 let codes: Vec<_> =
151 f.iter().filter_map(|x| x.code.as_deref()).collect();
152 assert!(codes.contains(&"AI-AGENTS-MISSING"));
153 assert!(codes.contains(&"AI-PLUGIN-JSON-MISSING"));
154 }
155
156 #[test]
157 fn invalid_ai_plugin_json_flagged_error() {
158 let s = site_with_files(&[
159 ("llms.txt", "# llms\n"),
160 (".well-known/ai-plugin.json", "{ this is not json }"),
161 ]);
162 let f = AiDiscoveryGate.run(&s, &AuditOptions::default());
163 assert!(f
164 .iter()
165 .any(|x| x.code.as_deref() == Some("AI-PLUGIN-JSON-INVALID")
166 && x.severity == Severity::Error));
167 }
168
169 #[test]
170 fn missing_llms_txt_warns() {
171 let s = site_with_files(&[
172 ("agents.txt", "# agents\n"),
173 (".well-known/ai-plugin.json", r#"{"schema_version":"v1"}"#),
174 ]);
175 let f = AiDiscoveryGate.run(&s, &AuditOptions::default());
176 let llms = f
177 .iter()
178 .find(|x| x.code.as_deref() == Some("AI-LLMS-MISSING"))
179 .expect("missing llms.txt finding");
180 assert_eq!(llms.severity, Severity::Warn);
181 }
182
183 #[test]
184 fn empty_llms_txt_warns() {
185 let s = site_with_files(&[
186 ("llms.txt", " \n "),
187 ("agents.txt", "# agents\n"),
188 (".well-known/ai-plugin.json", r#"{"schema_version":"v1"}"#),
189 ]);
190 let f = AiDiscoveryGate.run(&s, &AuditOptions::default());
191 let empty = f
192 .iter()
193 .find(|x| x.code.as_deref() == Some("AI-LLMS-EMPTY"))
194 .expect("empty llms.txt finding");
195 assert_eq!(empty.severity, Severity::Warn);
196 }
197
198 #[test]
199 fn all_three_missing_emits_warn_plus_two_infos() {
200 let s = site_with_files(&[("placeholder.txt", "noop")]);
201 let f = AiDiscoveryGate.run(&s, &AuditOptions::default());
202 let codes: Vec<_> =
203 f.iter().filter_map(|x| x.code.as_deref()).collect();
204 assert!(codes.contains(&"AI-LLMS-MISSING"));
205 assert!(codes.contains(&"AI-AGENTS-MISSING"));
206 assert!(codes.contains(&"AI-PLUGIN-JSON-MISSING"));
207 let warn_count = f
208 .iter()
209 .filter(|x| matches!(x.severity, Severity::Warn))
210 .count();
211 let info_count = f
212 .iter()
213 .filter(|x| matches!(x.severity, Severity::Info))
214 .count();
215 assert_eq!(warn_count, 1);
216 assert_eq!(info_count, 2);
217 }
218
219 #[test]
220 fn valid_ai_plugin_json_passes_silent() {
221 let s = site_with_files(&[
222 ("llms.txt", "# llms\n"),
223 ("agents.txt", "# agents\n"),
224 (".well-known/ai-plugin.json", r#"{"foo": [1, 2, 3]}"#),
225 ]);
226 let f = AiDiscoveryGate.run(&s, &AuditOptions::default());
227 assert!(f.is_empty(), "got {f:?}");
228 }
229
230 #[test]
231 fn unreadable_llms_txt_is_tolerated() {
232 let s = site_with_files(&[
235 ("agents.txt", "# agents\n"),
236 (".well-known/ai-plugin.json", r#"{"schema_version":"v1"}"#),
237 ]);
238 std::fs::create_dir_all(s.root.join("llms.txt")).unwrap();
239 let f = AiDiscoveryGate.run(&s, &AuditOptions::default());
240 assert!(f.is_empty(), "unreadable llms.txt must not panic: {f:?}");
241 }
242
243 #[test]
244 fn unreadable_ai_plugin_json_is_tolerated() {
245 let s = site_with_files(&[
246 ("llms.txt", "# llms\n"),
247 ("agents.txt", "# agents\n"),
248 ]);
249 std::fs::create_dir_all(s.root.join(".well-known/ai-plugin.json"))
250 .unwrap();
251 let f = AiDiscoveryGate.run(&s, &AuditOptions::default());
252 assert!(f.is_empty(), "unreadable plugin json must skip: {f:?}");
253 }
254
255 #[test]
256 fn metadata_methods_exposed() {
257 let g = AiDiscoveryGate;
258 assert_eq!(g.name(), "ai_discovery");
259 assert!(g.explain().contains("llms.txt"));
260 let _copy: AiDiscoveryGate = g;
261 let _clone = g;
262 assert!(format!("{g:?}").contains("AiDiscoveryGate"));
263 }
264}