Skip to main content

winged_rust/
feed.rs

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