Skip to main content

ssg/audit/gates/
feeds.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! RSS / Atom feed structural validation gate.
5//!
6//! Walks the site root for `*.xml` files (typically `rss.xml`,
7//! `feed.xml`, `atom.xml`), discriminates RSS 2.0 vs Atom 1.0 by the
8//! root element name, and asserts the required-field set for each
9//! variant.
10//!
11//! RSS 2.0 required fields: `<channel>` containing `<title>`,
12//! `<link>`, `<description>`. At least one `<item>` is expected when
13//! the feed exists.
14//!
15//! Atom 1.0 required fields: `<feed>` containing `<title>`, `<id>`,
16//! `<updated>`. At least one `<entry>` is expected.
17
18use super::super::{AuditGate, AuditOptions, Finding, Severity, Site};
19use crate::walk::walk_files;
20
21const NAME: &str = "feeds";
22
23/// RSS/Atom feed structural validation gate.
24///
25/// # Examples
26///
27/// ```
28/// use ssg::audit::AuditGate;
29/// use ssg::audit::gates::feeds::FeedsGate;
30/// assert_eq!(FeedsGate.name(), "feeds");
31/// ```
32#[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                    // Sitemaps are validated by their own pipeline, not this gate.
65                }
66                FeedKind::Unknown => {
67                    // Quietly ignore unknown XML — many SSGs emit
68                    // OPML, GraphQL schema, etc.
69                }
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            // root.join(name) always has a parent directory.
176            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        // Create a directory with .xml extension so read_to_string errors.
295        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        // Should not panic — the gate `continue`s on read error.
303        let _ = FeedsGate.run(&s, &AuditOptions::default());
304        std::mem::forget(tmp);
305    }
306
307    #[test]
308    fn non_utf8_xml_file_is_skipped() {
309        // Invalid UTF-8 makes read_to_string fail on a real file that
310        // walk_files does return, driving the per-file `continue`.
311        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        // Debug + Clone + Copy traits
324        let _: FeedsGate = g;
325        let _clone = g;
326        let dbg = format!("{g:?}");
327        assert!(dbg.contains("FeedsGate"));
328    }
329}