Skip to main content

molt/
feed.rs

1//! RSS 2.0 feed generation.
2//!
3
4use std::fmt::Write as _;
5
6use crate::core::escape::escape_xml;
7use crate::core::url_policy::is_http_url;
8
9/// One entry in a feed.
10#[derive(Debug, Clone, PartialEq, Eq)]
11#[non_exhaustive]
12pub struct RssItem {
13    /// The item headline.
14    pub title: String,
15    /// The item's canonical URL.
16    pub link: String,
17    /// A summary or the full content.
18    pub description: String,
19    /// An RFC 822 date, e.g. `Tue, 11 Aug 2026 10:00:00 +0000`.
20    pub pub_date: String,
21    /// A globally unique identifier. Defaults to `link`.
22    pub guid: Option<String>,
23    /// The author's email address.
24    pub author: Option<String>,
25    /// Category labels.
26    pub categories: Option<Vec<String>>,
27}
28
29impl RssItem {
30    /// Creates an item. `guid` defaults to `link`; the default is applied here, not
31    /// at render time.
32    pub fn new(
33        title: impl Into<String>,
34        link: impl Into<String>,
35        description: impl Into<String>,
36        pub_date: impl Into<String>,
37    ) -> Self {
38        let link = link.into();
39        Self {
40            title: title.into(),
41            guid: Some(link.clone()),
42            link,
43            description: description.into(),
44            pub_date: pub_date.into(),
45            author: None,
46            categories: None,
47        }
48    }
49
50    /// Overrides the GUID.
51    #[must_use]
52    pub fn guid(mut self, guid: impl Into<String>) -> Self {
53        self.guid = Some(guid.into());
54        self
55    }
56
57    /// Sets the author.
58    #[must_use]
59    pub fn author(mut self, author: impl Into<String>) -> Self {
60        self.author = Some(author.into());
61        self
62    }
63
64    /// Sets the categories.
65    #[must_use]
66    pub fn categories<S: Into<String>>(mut self, categories: impl IntoIterator<Item = S>) -> Self {
67        self.categories = Some(categories.into_iter().map(Into::into).collect());
68        self
69    }
70}
71
72/// Renders an RSS 2.0 feed.
73///
74/// # Examples
75/// ```
76/// use molt::feed::{RssGenerator, RssItem};
77///
78/// let feed = RssGenerator::new("RideKeeper", "https://ridekeeper.example", "Release notes")
79///     .language("pt-BR");
80/// let xml = feed.generate(&[RssItem::new(
81///     "1.2 — tyres & chain",
82///     "https://ridekeeper.example/blog/1-2",
83///     "Tyre pressure log",
84///     "Tue, 11 Aug 2026 10:00:00 +0000",
85/// )]);
86/// assert!(xml.contains("<title>1.2 — tyres &amp; chain</title>"));
87/// ```
88#[derive(Debug, Clone, PartialEq, Eq)]
89#[non_exhaustive]
90pub struct RssGenerator {
91    /// The channel title.
92    pub title: String,
93    /// The channel's site URL.
94    pub link: String,
95    /// What the channel is about.
96    pub description: String,
97    /// An RFC 5646 language tag.
98    pub language: Option<String>,
99    /// A copyright notice.
100    pub copyright: Option<String>,
101    /// The editorial contact address.
102    pub managing_editor: Option<String>,
103    /// The technical contact address.
104    ///
105    /// Renders as `<webMaster>`. The camelCase spelling is the RSS specification's, not a
106    /// style slip. Do not "fix" it.
107    pub webmaster: Option<String>,
108}
109
110impl RssGenerator {
111    /// Creates a channel.
112    pub fn new(
113        title: impl Into<String>,
114        link: impl Into<String>,
115        description: impl Into<String>,
116    ) -> Self {
117        Self {
118            title: title.into(),
119            link: link.into(),
120            description: description.into(),
121            language: None,
122            copyright: None,
123            managing_editor: None,
124            webmaster: None,
125        }
126    }
127
128    /// Sets the channel language.
129    #[must_use]
130    pub fn language(mut self, language: impl Into<String>) -> Self {
131        self.language = Some(language.into());
132        self
133    }
134
135    /// Sets the copyright notice.
136    #[must_use]
137    pub fn copyright(mut self, copyright: impl Into<String>) -> Self {
138        self.copyright = Some(copyright.into());
139        self
140    }
141
142    /// Sets the editorial contact.
143    #[must_use]
144    pub fn managing_editor(mut self, editor: impl Into<String>) -> Self {
145        self.managing_editor = Some(editor.into());
146        self
147    }
148
149    /// Sets the technical contact, rendered as `<webMaster>`.
150    #[must_use]
151    pub fn webmaster(mut self, webmaster: impl Into<String>) -> Self {
152        self.webmaster = Some(webmaster.into());
153        self
154    }
155
156    /// Renders the feed.
157    #[must_use]
158    pub fn generate(&self, items: &[RssItem]) -> String {
159        let mut xml = String::with_capacity(512 + items.len() * 320);
160        xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
161        xml.push_str("<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\">\n");
162        xml.push_str("  <channel>\n");
163
164        let link = escape_xml(&self.link);
165        let _ = writeln!(xml, "    <title>{}</title>", escape_xml(&self.title));
166        // A feed reader shows the link as something to click, so only an absolute http(s)
167        // URL is written; the self link is built from it and goes with it.
168        let linkable = is_http_url(&self.link);
169        if linkable {
170            let _ = writeln!(xml, "    <link>{link}</link>");
171        }
172        let _ = writeln!(
173            xml,
174            "    <description>{}</description>",
175            escape_xml(&self.description)
176        );
177        if linkable {
178            let _ = writeln!(
179                xml,
180                "    <atom:link href=\"{link}/feed.xml\" rel=\"self\" type=\"application/rss+xml\" />"
181            );
182        }
183
184        write_optional(&mut xml, "language", self.language.as_deref());
185        write_optional(&mut xml, "copyright", self.copyright.as_deref());
186        write_optional(&mut xml, "managingEditor", self.managing_editor.as_deref());
187        write_optional(&mut xml, "webMaster", self.webmaster.as_deref());
188
189        for item in items {
190            write_item(&mut xml, item);
191        }
192
193        xml.push_str("  </channel>\n");
194        xml.push_str("</rss>");
195        xml
196    }
197}
198
199/// Writes `<tag>value</tag>` at channel depth, or nothing when the value is absent.
200fn write_optional(xml: &mut String, tag: &str, value: Option<&str>) {
201    if let Some(value) = value {
202        let _ = writeln!(xml, "    <{tag}>{}</{tag}>", escape_xml(value));
203    }
204}
205
206fn write_item(xml: &mut String, item: &RssItem) {
207    xml.push_str("    <item>\n");
208    let _ = writeln!(xml, "      <title>{}</title>", escape_xml(&item.title));
209    if is_http_url(&item.link) {
210        let _ = writeln!(xml, "      <link>{}</link>", escape_xml(&item.link));
211    }
212    let _ = writeln!(
213        xml,
214        "      <description>{}</description>",
215        escape_xml(&item.description)
216    );
217    let _ = writeln!(
218        xml,
219        "      <pubDate>{}</pubDate>",
220        escape_xml(&item.pub_date)
221    );
222
223    if let Some(guid) = &item.guid {
224        // `isPermaLink="true"` promises the guid is a URL a reader may open; for any other
225        // identifier it must say so.
226        let _ = writeln!(
227            xml,
228            "      <guid isPermaLink=\"{}\">{}</guid>",
229            is_http_url(guid),
230            escape_xml(guid)
231        );
232    }
233    if let Some(author) = &item.author {
234        let _ = writeln!(xml, "      <author>{}</author>", escape_xml(author));
235    }
236    if let Some(categories) = &item.categories {
237        for category in categories {
238            let _ = writeln!(xml, "      <category>{}</category>", escape_xml(category));
239        }
240    }
241
242    xml.push_str("    </item>\n");
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    fn channel() -> RssGenerator {
250        RssGenerator::new("RideKeeper", "https://ridekeeper.example", "Release notes")
251    }
252
253    #[test]
254    fn the_channel_carries_its_metadata_and_an_atom_self_link() {
255        let xml = channel().language("pt-BR").generate(&[]);
256        assert!(xml.contains("<title>RideKeeper</title>"));
257        assert!(xml.contains("<language>pt-BR</language>"));
258        assert!(xml.contains(
259            "<atom:link href=\"https://ridekeeper.example/feed.xml\" rel=\"self\" \
260             type=\"application/rss+xml\" />"
261        ));
262    }
263
264    #[test]
265    fn absent_channel_fields_are_omitted_rather_than_emitted_empty() {
266        let xml = channel().generate(&[]);
267        for tag in [
268            "<language>",
269            "<copyright>",
270            "<managingEditor>",
271            "<webMaster>",
272        ] {
273            assert!(!xml.contains(tag), "{tag} should be absent");
274        }
275    }
276
277    #[test]
278    fn the_guid_defaults_to_the_link() {
279        let item = RssItem::new(
280            "T",
281            "https://e.com/a",
282            "D",
283            "Tue, 11 Aug 2026 10:00:00 +0000",
284        );
285        assert_eq!(item.guid.as_deref(), Some("https://e.com/a"));
286
287        let overridden = item.clone().guid("urn:custom");
288        assert_eq!(overridden.guid.as_deref(), Some("urn:custom"));
289    }
290
291    #[test]
292    fn item_content_is_xml_escaped() {
293        let item = RssItem::new(
294            "1.2 — tyres & chain",
295            "https://e.com/a",
296            "Tyre pressure log <and> chain reminders",
297            "Tue, 11 Aug 2026 10:00:00 +0000",
298        );
299        let xml = channel().generate(&[item]);
300        assert!(xml.contains("<title>1.2 — tyres &amp; chain</title>"));
301        assert!(
302            xml.contains(
303                "<description>Tyre pressure log &lt;and&gt; chain reminders</description>"
304            )
305        );
306    }
307
308    #[test]
309    fn categories_render_one_element_each() {
310        let item = RssItem::new(
311            "T",
312            "https://e.com/a",
313            "D",
314            "Tue, 11 Aug 2026 10:00:00 +0000",
315        )
316        .categories(["release", "ios"]);
317        let xml = channel().generate(&[item]);
318        assert_eq!(xml.matches("<category>").count(), 2);
319    }
320
321    #[test]
322    fn a_feed_with_no_items_is_still_valid() {
323        let xml = channel().generate(&[]);
324        assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<rss "));
325        assert!(xml.ends_with("</rss>"));
326        assert!(!xml.contains("<item>"));
327    }
328
329    #[test]
330    fn every_optional_channel_field_is_rendered_when_supplied() {
331        let xml = channel()
332            .language("en")
333            .copyright("\u{a9} 2026")
334            .managing_editor("editor@e.com")
335            .webmaster("web@e.com")
336            .generate(&[]);
337
338        assert!(xml.contains("<copyright>\u{a9} 2026</copyright>"));
339        assert!(xml.contains("<managingEditor>editor@e.com</managingEditor>"));
340        assert!(xml.contains("<webMaster>web@e.com</webMaster>"));
341    }
342
343    #[test]
344    fn an_item_without_optional_fields_emits_neither() {
345        let xml = channel().generate(&[RssItem::new(
346            "T",
347            "https://e.com/p",
348            "D",
349            "Tue, 11 Aug 2026 10:00:00 +0000",
350        )]);
351
352        assert!(!xml.contains("<author>"));
353        assert!(!xml.contains("<category>"));
354    }
355
356    /// `isPermaLink="true"` promises readers they may open the guid, so only a URL guid gets it;
357    /// `urn:uuid:1234` gets `false`.
358    #[test]
359    fn an_explicit_guid_wins_over_the_link() {
360        let xml = channel().generate(&[RssItem::new(
361            "T",
362            "https://e.com/p",
363            "D",
364            "Tue, 11 Aug 2026 10:00:00 +0000",
365        )
366        .guid("urn:uuid:1234")]);
367
368        assert!(xml.contains(r#"<guid isPermaLink="false">urn:uuid:1234</guid>"#));
369        let url_guid = channel().generate(&[RssItem::new(
370            "T",
371            "https://e.com/p",
372            "D",
373            "Tue, 11 Aug 2026 10:00:00 +0000",
374        )
375        .guid("https://e.com/permalink")]);
376        assert!(url_guid.contains(r#"<guid isPermaLink="true">https://e.com/permalink</guid>"#));
377    }
378
379    #[test]
380    fn a_link_that_is_not_http_is_left_out_of_the_feed() {
381        let item = |link: &str| RssItem::new("T", link, "D", "Tue, 11 Aug 2026 10:00:00 +0000");
382        for bad in [
383            "javascript:alert(1)",
384            "/relative",
385            "ftp://e.com",
386            "data:text/html,x",
387        ] {
388            let xml = channel().generate(&[item(bad)]);
389            assert!(!xml.contains(&format!("<link>{bad}")), "{bad}: {xml}");
390            // The channel's own link is still there; only the item's is gone.
391            assert_eq!(xml.matches("<link>").count(), 1, "{bad}");
392        }
393        let bad_channel = RssGenerator::new("T", "javascript:x", "D").generate(&[]);
394        assert!(!bad_channel.contains("<link>"), "{bad_channel}");
395        assert!(!bad_channel.contains("atom:link"), "{bad_channel}");
396        assert_eq!(
397            channel()
398                .generate(&[item("https://e.com/p")])
399                .matches("<link>")
400                .count(),
401            2
402        );
403    }
404
405    #[test]
406    fn characters_xml_cannot_hold_do_not_reach_the_feed() {
407        let xml = channel().generate(&[RssItem::new(
408            "a\u{0}b\u{1F}c\u{FFFE}d",
409            "https://e.com/p",
410            "ok\ttab",
411            "Tue, 11 Aug 2026 10:00:00 +0000",
412        )]);
413        assert!(xml.contains("<title>abcd</title>"));
414        assert!(xml.contains("ok\ttab"));
415        assert!(!xml.chars().any(|c| matches!(c, '\u{0}'..='\u{8}' | '\u{B}' | '\u{C}' | '\u{E}'..='\u{1F}' | '\u{FFFE}' | '\u{FFFF}')));
416    }
417
418    #[test]
419    fn an_item_renders_every_field_it_is_given_xml_escaped() {
420        let xml = channel().generate(&[RssItem::new(
421            "Hello & welcome",
422            "https://example.com/hello",
423            "First <post>",
424            "Tue, 11 Aug 2026 10:00:00 +0000",
425        )
426        .author("me@example.com")
427        .categories(["rust", "html"])]);
428
429        assert!(xml.contains("<title>Hello &amp; welcome</title>"));
430        assert!(xml.contains("<description>First &lt;post&gt;</description>"));
431        assert!(xml.contains("<pubDate>Tue, 11 Aug 2026 10:00:00 +0000</pubDate>"));
432        assert!(xml.contains("<category>rust</category>"));
433        assert!(xml.contains("<category>html</category>"));
434    }
435}