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 let kind = classify(&text);
60 match kind {
61 FeedKind::Rss2 => check_rss2(&text, &rel, &mut findings),
62 FeedKind::Atom1 => check_atom1(&text, &rel, &mut findings),
63 FeedKind::Sitemap => {
64 }
66 FeedKind::Unknown => {
67 }
70 }
71 }
72 findings
73 }
74}
75
76enum FeedKind {
77 Rss2,
78 Atom1,
79 Sitemap,
80 Unknown,
81}
82
83fn classify(text: &str) -> FeedKind {
84 let lower = text.to_lowercase();
85 if lower.contains("<rss") {
86 FeedKind::Rss2
87 } else if lower.contains("<feed") && lower.contains("atom") {
88 FeedKind::Atom1
89 } else if lower.contains("<urlset") || lower.contains("<sitemapindex") {
90 FeedKind::Sitemap
91 } else {
92 FeedKind::Unknown
93 }
94}
95
96fn check_rss2(text: &str, rel: &str, findings: &mut Vec<Finding>) {
97 let lower = text.to_lowercase();
98 if !lower.contains("<channel") {
99 findings.push(
100 Finding::new(NAME, Severity::Error, "RSS feed missing <channel>")
101 .with_code("RSS-CHANNEL")
102 .with_path(rel.to_string()),
103 );
104 return;
105 }
106 for required in ["<title", "<link", "<description"] {
107 if !lower.contains(required) {
108 findings.push(
109 Finding::new(
110 NAME,
111 Severity::Error,
112 format!("RSS <channel> missing {required}>"),
113 )
114 .with_code(format!(
115 "RSS-{}",
116 required.trim_start_matches('<').to_uppercase()
117 ))
118 .with_path(rel.to_string()),
119 );
120 }
121 }
122 if !lower.contains("<item") {
123 findings.push(
124 Finding::new(
125 NAME,
126 Severity::Warn,
127 "RSS feed contains zero <item> entries",
128 )
129 .with_code("RSS-EMPTY")
130 .with_path(rel.to_string()),
131 );
132 }
133}
134
135fn check_atom1(text: &str, rel: &str, findings: &mut Vec<Finding>) {
136 let lower = text.to_lowercase();
137 for required in ["<title", "<id", "<updated"] {
138 if !lower.contains(required) {
139 findings.push(
140 Finding::new(
141 NAME,
142 Severity::Error,
143 format!("Atom feed missing {required}>"),
144 )
145 .with_code(format!(
146 "ATOM-{}",
147 required.trim_start_matches('<').to_uppercase()
148 ))
149 .with_path(rel.to_string()),
150 );
151 }
152 }
153 if !lower.contains("<entry") {
154 findings.push(
155 Finding::new(
156 NAME,
157 Severity::Warn,
158 "Atom feed contains zero <entry> elements",
159 )
160 .with_code("ATOM-EMPTY")
161 .with_path(rel.to_string()),
162 );
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 fn site_with_files(files: &[(&str, &str)]) -> Site {
171 let tmp = tempfile::tempdir().unwrap();
172 let root = tmp.path().to_path_buf();
173 for (name, body) in files {
174 let p = root.join(name);
175 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
177 std::fs::write(&p, body).unwrap();
178 }
179 std::mem::forget(tmp);
180 Site {
181 root,
182 html_files: Vec::new(),
183 }
184 }
185
186 #[test]
187 fn valid_rss_passes() {
188 let body = r#"<?xml version="1.0"?>
189<rss version="2.0"><channel>
190 <title>x</title><link>https://x</link><description>d</description>
191 <item><title>i</title></item>
192</channel></rss>"#;
193 let s = site_with_files(&[("rss.xml", body)]);
194 let f = FeedsGate.run(&s, &AuditOptions::default());
195 assert!(f.is_empty(), "got {f:?}");
196 }
197
198 #[test]
199 fn rss_missing_channel_fields_flagged() {
200 let body = r#"<?xml version="1.0"?><rss version="2.0"><channel></channel></rss>"#;
201 let s = site_with_files(&[("rss.xml", body)]);
202 let f = FeedsGate.run(&s, &AuditOptions::default());
203 let codes: Vec<_> =
204 f.iter().filter_map(|x| x.code.as_deref()).collect();
205 assert!(codes.contains(&"RSS-TITLE"));
206 assert!(codes.contains(&"RSS-LINK"));
207 assert!(codes.contains(&"RSS-DESCRIPTION"));
208 }
209
210 #[test]
211 fn rss_missing_channel_short_circuits() {
212 let body = r#"<?xml version="1.0"?><rss version="2.0"></rss>"#;
213 let s = site_with_files(&[("rss.xml", body)]);
214 let f = FeedsGate.run(&s, &AuditOptions::default());
215 let codes: Vec<_> =
216 f.iter().filter_map(|x| x.code.as_deref()).collect();
217 assert_eq!(codes, vec!["RSS-CHANNEL"]);
218 }
219
220 #[test]
221 fn rss_empty_item_warns() {
222 let body = r#"<?xml version="1.0"?>
223<rss version="2.0"><channel>
224 <title>x</title><link>https://x</link><description>d</description>
225</channel></rss>"#;
226 let s = site_with_files(&[("rss.xml", body)]);
227 let f = FeedsGate.run(&s, &AuditOptions::default());
228 assert!(
229 f.iter()
230 .filter_map(|x| x.code.as_deref())
231 .any(|c| c == "RSS-EMPTY"),
232 "got {f:?}"
233 );
234 assert!(
235 f.iter().any(|x| matches!(x.severity, Severity::Warn)),
236 "got {f:?}"
237 );
238 }
239
240 #[test]
241 fn valid_atom_passes() {
242 let body = r#"<?xml version="1.0"?>
243<feed xmlns="http://www.w3.org/2005/Atom">
244 <title>x</title><id>urn:x</id><updated>2026-01-01</updated>
245 <entry><title>e</title></entry>
246</feed>"#;
247 let s = site_with_files(&[("atom.xml", body)]);
248 let f = FeedsGate.run(&s, &AuditOptions::default());
249 assert!(f.is_empty(), "got {f:?}");
250 }
251
252 #[test]
253 fn atom_missing_fields_flagged() {
254 let body = r#"<?xml version="1.0"?>
255<feed xmlns="http://www.w3.org/2005/Atom"></feed>"#;
256 let s = site_with_files(&[("atom.xml", body)]);
257 let f = FeedsGate.run(&s, &AuditOptions::default());
258 let codes: Vec<_> =
259 f.iter().filter_map(|x| x.code.as_deref()).collect();
260 assert!(codes.contains(&"ATOM-TITLE"));
261 assert!(codes.contains(&"ATOM-ID"));
262 assert!(codes.contains(&"ATOM-UPDATED"));
263 assert!(codes.contains(&"ATOM-EMPTY"));
264 }
265
266 #[test]
267 fn sitemap_xml_ignored() {
268 let body = r#"<?xml version="1.0"?>
269<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
270 <url><loc>https://x</loc></url>
271</urlset>"#;
272 let s = site_with_files(&[("sitemap.xml", body)]);
273 let f = FeedsGate.run(&s, &AuditOptions::default());
274 assert!(f.is_empty());
275 }
276
277 #[test]
278 fn unknown_xml_ignored() {
279 let body = r#"<?xml version="1.0"?><opml version="2.0"></opml>"#;
280 let s = site_with_files(&[("opml.xml", body)]);
281 let f = FeedsGate.run(&s, &AuditOptions::default());
282 assert!(f.is_empty());
283 }
284
285 #[test]
286 fn no_xml_files_returns_empty() {
287 let s = site_with_files(&[("index.html", "<html></html>")]);
288 let f = FeedsGate.run(&s, &AuditOptions::default());
289 assert!(f.is_empty());
290 }
291
292 #[test]
293 fn unreadable_xml_skipped() {
294 let tmp = tempfile::tempdir().unwrap();
296 let root = tmp.path().to_path_buf();
297 std::fs::create_dir_all(root.join("malformed.xml")).unwrap();
298 let s = Site {
299 root: root.clone(),
300 html_files: Vec::new(),
301 };
302 let _ = FeedsGate.run(&s, &AuditOptions::default());
304 std::mem::forget(tmp);
305 }
306
307 #[test]
308 fn non_utf8_xml_file_is_skipped() {
309 let s = site_with_files(&[("good.xml", "<urlset></urlset>")]);
312 std::fs::write(s.root.join("bad.xml"), [0xFFu8, 0xFE, 0x00, 0x9F])
313 .unwrap();
314 let f = FeedsGate.run(&s, &AuditOptions::default());
315 assert!(f.is_empty(), "non-UTF8 xml must be skipped: {f:?}");
316 }
317
318 #[test]
319 fn metadata_methods_exposed() {
320 let g = FeedsGate;
321 assert_eq!(g.name(), "feeds");
322 assert!(g.explain().contains("RSS"));
323 let _: FeedsGate = g;
325 let _clone = g;
326 let dbg = format!("{g:?}");
327 assert!(dbg.contains("FeedsGate"));
328 }
329}