1use super::super::{AuditGate, AuditOptions, Finding, Severity, Site};
19use crate::walk::walk_files;
20
21const NAME: &str = "feeds";
22
23#[derive(Debug, Clone, Copy)]
33pub struct FeedsGate;
34
35impl AuditGate for FeedsGate {
36 fn name(&self) -> &'static str {
37 NAME
38 }
39
40 fn explain(&self) -> &'static str {
41 "Walks the site root for *.xml files, classifies each as RSS \
42 2.0 or Atom 1.0 by root element, and asserts the required \
43 fields per variant. RSS 2.0 demands <channel> with <title>, \
44 <link>, <description>; Atom 1.0 demands <feed> with <title>, \
45 <id>, <updated>. Empty feeds raise a warning."
46 }
47
48 fn run(&self, site: &Site, _opts: &AuditOptions) -> Vec<Finding> {
49 let mut findings = Vec::new();
50 let xml_files = walk_files(&site.root, "xml").unwrap_or_default();
51 if xml_files.is_empty() {
52 return findings;
53 }
54 for path in &xml_files {
55 let Ok(text) = std::fs::read_to_string(path) else {
56 continue;
57 };
58 let rel = site.rel(path);
59 if let Ok(doc) = oxml::parse(&text) {
60 match classify_doc(&doc) {
61 FeedKind::Rss2 => {
62 check_rss2_doc(&doc, &rel, &mut findings);
63 }
64 FeedKind::Atom1 => {
65 check_atom1_doc(&doc, &rel, &mut findings);
66 }
67 FeedKind::Sitemap | FeedKind::Unknown => {}
68 }
69 continue;
70 }
71 let kind = classify(&text);
72 match kind {
73 FeedKind::Rss2 => check_rss2(&text, &rel, &mut findings),
74 FeedKind::Atom1 => check_atom1(&text, &rel, &mut findings),
75 FeedKind::Sitemap => {
76 }
78 FeedKind::Unknown => {
79 }
82 }
83 }
84 findings
85 }
86}
87
88enum FeedKind {
89 Rss2,
90 Atom1,
91 Sitemap,
92 Unknown,
93}
94
95fn classify_doc(doc: &oxml::Document) -> FeedKind {
96 let Some(root) = doc.root_element() else {
97 return FeedKind::Unknown;
98 };
99 let Some(name) = doc.element_name(root) else {
100 return FeedKind::Unknown;
101 };
102 let local = name.local.to_ascii_lowercase();
103 match local.as_str() {
104 "rss" => FeedKind::Rss2,
105 "feed"
106 if name
107 .namespace
108 .as_deref()
109 .is_some_and(|ns| ns.to_ascii_lowercase().contains("atom")) =>
110 {
111 FeedKind::Atom1
112 }
113 "urlset" | "sitemapindex" => FeedKind::Sitemap,
114 _ => FeedKind::Unknown,
115 }
116}
117
118fn check_rss2_doc(
119 doc: &oxml::Document,
120 rel: &str,
121 findings: &mut Vec<Finding>,
122) {
123 let Some(root) = doc.root_element() else {
124 return;
125 };
126 let channel = doc.children(root).iter().copied().find(|&child| {
127 doc.element_name(child)
128 .is_some_and(|n| n.local.eq_ignore_ascii_case("channel"))
129 });
130 let Some(channel) = channel else {
131 findings.push(
132 Finding::new(NAME, Severity::Error, "RSS feed missing <channel>")
133 .with_code("RSS-CHANNEL")
134 .with_path(rel.to_string()),
135 );
136 return;
137 };
138 for required in ["title", "link", "description"] {
139 let present = doc.children(channel).iter().any(|&child| {
140 doc.element_name(child)
141 .is_some_and(|n| n.local.eq_ignore_ascii_case(required))
142 });
143 if !present {
144 findings.push(
145 Finding::new(
146 NAME,
147 Severity::Error,
148 format!("RSS <channel> missing <{required}>"),
149 )
150 .with_code(format!("RSS-{}", required.to_uppercase()))
151 .with_path(rel.to_string()),
152 );
153 }
154 }
155 let has_item = doc.children(channel).iter().any(|&child| {
156 doc.element_name(child)
157 .is_some_and(|n| n.local.eq_ignore_ascii_case("item"))
158 });
159 if !has_item {
160 findings.push(
161 Finding::new(
162 NAME,
163 Severity::Warn,
164 "RSS feed contains zero <item> entries",
165 )
166 .with_code("RSS-EMPTY")
167 .with_path(rel.to_string()),
168 );
169 }
170}
171
172fn check_atom1_doc(
173 doc: &oxml::Document,
174 rel: &str,
175 findings: &mut Vec<Finding>,
176) {
177 let Some(root) = doc.root_element() else {
178 return;
179 };
180 for required in ["title", "id", "updated"] {
181 let present = doc.children(root).iter().any(|&child| {
182 doc.element_name(child)
183 .is_some_and(|n| n.local.eq_ignore_ascii_case(required))
184 });
185 if !present {
186 findings.push(
187 Finding::new(
188 NAME,
189 Severity::Error,
190 format!("Atom feed missing <{required}>"),
191 )
192 .with_code(format!("ATOM-{}", required.to_uppercase()))
193 .with_path(rel.to_string()),
194 );
195 }
196 }
197 let has_entry = doc.children(root).iter().any(|&child| {
198 doc.element_name(child)
199 .is_some_and(|n| n.local.eq_ignore_ascii_case("entry"))
200 });
201 if !has_entry {
202 findings.push(
203 Finding::new(
204 NAME,
205 Severity::Warn,
206 "Atom feed contains zero <entry> elements",
207 )
208 .with_code("ATOM-EMPTY")
209 .with_path(rel.to_string()),
210 );
211 }
212}
213
214fn classify(text: &str) -> FeedKind {
215 let lower = text.to_lowercase();
216 if lower.contains("<rss") {
217 FeedKind::Rss2
218 } else if lower.contains("<feed") && lower.contains("atom") {
219 FeedKind::Atom1
220 } else if lower.contains("<urlset") || lower.contains("<sitemapindex") {
221 FeedKind::Sitemap
222 } else {
223 FeedKind::Unknown
224 }
225}
226
227fn check_rss2(text: &str, rel: &str, findings: &mut Vec<Finding>) {
228 let lower = text.to_lowercase();
229 if !lower.contains("<channel") {
230 findings.push(
231 Finding::new(NAME, Severity::Error, "RSS feed missing <channel>")
232 .with_code("RSS-CHANNEL")
233 .with_path(rel.to_string()),
234 );
235 return;
236 }
237 for required in ["<title", "<link", "<description"] {
238 if !lower.contains(required) {
239 findings.push(
240 Finding::new(
241 NAME,
242 Severity::Error,
243 format!("RSS <channel> missing {required}>"),
244 )
245 .with_code(format!(
246 "RSS-{}",
247 required.trim_start_matches('<').to_uppercase()
248 ))
249 .with_path(rel.to_string()),
250 );
251 }
252 }
253 if !lower.contains("<item") {
254 findings.push(
255 Finding::new(
256 NAME,
257 Severity::Warn,
258 "RSS feed contains zero <item> entries",
259 )
260 .with_code("RSS-EMPTY")
261 .with_path(rel.to_string()),
262 );
263 }
264}
265
266fn check_atom1(text: &str, rel: &str, findings: &mut Vec<Finding>) {
267 let lower = text.to_lowercase();
268 for required in ["<title", "<id", "<updated"] {
269 if !lower.contains(required) {
270 findings.push(
271 Finding::new(
272 NAME,
273 Severity::Error,
274 format!("Atom feed missing {required}>"),
275 )
276 .with_code(format!(
277 "ATOM-{}",
278 required.trim_start_matches('<').to_uppercase()
279 ))
280 .with_path(rel.to_string()),
281 );
282 }
283 }
284 if !lower.contains("<entry") {
285 findings.push(
286 Finding::new(
287 NAME,
288 Severity::Warn,
289 "Atom feed contains zero <entry> elements",
290 )
291 .with_code("ATOM-EMPTY")
292 .with_path(rel.to_string()),
293 );
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 fn site_with_files(files: &[(&str, &str)]) -> Site {
302 let tmp = tempfile::tempdir().unwrap();
303 let root = tmp.path().to_path_buf();
304 for (name, body) in files {
305 let p = root.join(name);
306 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
308 std::fs::write(&p, body).unwrap();
309 }
310 std::mem::forget(tmp);
311 Site {
312 root,
313 html_files: Vec::new(),
314 }
315 }
316
317 #[test]
318 fn valid_rss_passes() {
319 let body = r#"<?xml version="1.0"?>
320<rss version="2.0"><channel>
321 <title>x</title><link>https://x</link><description>d</description>
322 <item><title>i</title></item>
323</channel></rss>"#;
324 let s = site_with_files(&[("rss.xml", body)]);
325 let f = FeedsGate.run(&s, &AuditOptions::default());
326 assert!(f.is_empty(), "got {f:?}");
327 }
328
329 #[test]
330 fn rss_missing_channel_fields_flagged() {
331 let body = r#"<?xml version="1.0"?><rss version="2.0"><channel></channel></rss>"#;
332 let s = site_with_files(&[("rss.xml", body)]);
333 let f = FeedsGate.run(&s, &AuditOptions::default());
334 let codes: Vec<_> =
335 f.iter().filter_map(|x| x.code.as_deref()).collect();
336 assert!(codes.contains(&"RSS-TITLE"));
337 assert!(codes.contains(&"RSS-LINK"));
338 assert!(codes.contains(&"RSS-DESCRIPTION"));
339 }
340
341 #[test]
342 fn rss_missing_channel_short_circuits() {
343 let body = r#"<?xml version="1.0"?><rss version="2.0"></rss>"#;
344 let s = site_with_files(&[("rss.xml", body)]);
345 let f = FeedsGate.run(&s, &AuditOptions::default());
346 let codes: Vec<_> =
347 f.iter().filter_map(|x| x.code.as_deref()).collect();
348 assert_eq!(codes, vec!["RSS-CHANNEL"]);
349 }
350
351 #[test]
352 fn rss_empty_item_warns() {
353 let body = r#"<?xml version="1.0"?>
354<rss version="2.0"><channel>
355 <title>x</title><link>https://x</link><description>d</description>
356</channel></rss>"#;
357 let s = site_with_files(&[("rss.xml", body)]);
358 let f = FeedsGate.run(&s, &AuditOptions::default());
359 assert!(
360 f.iter()
361 .filter_map(|x| x.code.as_deref())
362 .any(|c| c == "RSS-EMPTY"),
363 "got {f:?}"
364 );
365 assert!(
366 f.iter().any(|x| matches!(x.severity, Severity::Warn)),
367 "got {f:?}"
368 );
369 }
370
371 #[test]
372 fn valid_atom_passes() {
373 let body = r#"<?xml version="1.0"?>
374<feed xmlns="http://www.w3.org/2005/Atom">
375 <title>x</title><id>urn:x</id><updated>2026-01-01</updated>
376 <entry><title>e</title></entry>
377</feed>"#;
378 let s = site_with_files(&[("atom.xml", body)]);
379 let f = FeedsGate.run(&s, &AuditOptions::default());
380 assert!(f.is_empty(), "got {f:?}");
381 }
382
383 #[test]
384 fn atom_missing_fields_flagged() {
385 let body = r#"<?xml version="1.0"?>
386<feed xmlns="http://www.w3.org/2005/Atom"></feed>"#;
387 let s = site_with_files(&[("atom.xml", body)]);
388 let f = FeedsGate.run(&s, &AuditOptions::default());
389 let codes: Vec<_> =
390 f.iter().filter_map(|x| x.code.as_deref()).collect();
391 assert!(codes.contains(&"ATOM-TITLE"));
392 assert!(codes.contains(&"ATOM-ID"));
393 assert!(codes.contains(&"ATOM-UPDATED"));
394 assert!(codes.contains(&"ATOM-EMPTY"));
395 }
396
397 #[test]
398 fn sitemap_xml_ignored() {
399 let body = r#"<?xml version="1.0"?>
400<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
401 <url><loc>https://x</loc></url>
402</urlset>"#;
403 let s = site_with_files(&[("sitemap.xml", body)]);
404 let f = FeedsGate.run(&s, &AuditOptions::default());
405 assert!(f.is_empty(), "{:?}", f);
406 }
407
408 #[test]
409 fn unknown_xml_ignored() {
410 let body = r#"<?xml version="1.0"?><opml version="2.0"></opml>"#;
411 let s = site_with_files(&[("opml.xml", body)]);
412 let f = FeedsGate.run(&s, &AuditOptions::default());
413 assert!(f.is_empty(), "{:?}", f);
414 }
415
416 #[test]
417 fn no_xml_files_returns_empty() {
418 let s = site_with_files(&[("index.html", "<html></html>")]);
419 let f = FeedsGate.run(&s, &AuditOptions::default());
420 assert!(f.is_empty(), "{:?}", f);
421 }
422
423 #[test]
424 fn unreadable_xml_skipped() {
425 let tmp = tempfile::tempdir().unwrap();
427 let root = tmp.path().to_path_buf();
428 std::fs::create_dir_all(root.join("malformed.xml")).unwrap();
429 let s = Site {
430 root: root.clone(),
431 html_files: Vec::new(),
432 };
433 let _ = FeedsGate.run(&s, &AuditOptions::default());
435 std::mem::forget(tmp);
436 }
437
438 #[test]
439 fn non_utf8_xml_file_is_skipped() {
440 let s = site_with_files(&[("good.xml", "<urlset></urlset>")]);
443 std::fs::write(s.root.join("bad.xml"), [0xFFu8, 0xFE, 0x00, 0x9F])
444 .unwrap();
445 let f = FeedsGate.run(&s, &AuditOptions::default());
446 assert!(f.is_empty(), "non-UTF8 xml must be skipped: {f:?}");
447 }
448
449 #[test]
450 fn metadata_methods_exposed() {
451 let g = FeedsGate;
452 assert_eq!(g.name(), "feeds");
453 assert!(g.explain().contains("RSS"));
454 let _: FeedsGate = g;
456 let _clone = g;
457 let dbg = format!("{g:?}");
458 assert!(dbg.contains("FeedsGate"));
459 }
460}