use std::fmt::Write as _;
use crate::core::escape::escape_xml;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RssItem {
pub title: String,
pub link: String,
pub description: String,
pub pub_date: String,
pub guid: Option<String>,
pub author: Option<String>,
pub categories: Option<Vec<String>>,
}
impl RssItem {
pub fn new(
title: impl Into<String>,
link: impl Into<String>,
description: impl Into<String>,
pub_date: impl Into<String>,
) -> Self {
let link = link.into();
Self {
title: title.into(),
guid: Some(link.clone()),
link,
description: description.into(),
pub_date: pub_date.into(),
author: None,
categories: None,
}
}
#[must_use]
pub fn guid(mut self, guid: impl Into<String>) -> Self {
self.guid = Some(guid.into());
self
}
#[must_use]
pub fn author(mut self, author: impl Into<String>) -> Self {
self.author = Some(author.into());
self
}
#[must_use]
pub fn categories<S: Into<String>>(mut self, categories: impl IntoIterator<Item = S>) -> Self {
self.categories = Some(categories.into_iter().map(Into::into).collect());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RssGenerator {
pub title: String,
pub link: String,
pub description: String,
pub language: Option<String>,
pub copyright: Option<String>,
pub managing_editor: Option<String>,
pub webmaster: Option<String>,
}
impl RssGenerator {
pub fn new(
title: impl Into<String>,
link: impl Into<String>,
description: impl Into<String>,
) -> Self {
Self {
title: title.into(),
link: link.into(),
description: description.into(),
language: None,
copyright: None,
managing_editor: None,
webmaster: None,
}
}
#[must_use]
pub fn language(mut self, language: impl Into<String>) -> Self {
self.language = Some(language.into());
self
}
#[must_use]
pub fn copyright(mut self, copyright: impl Into<String>) -> Self {
self.copyright = Some(copyright.into());
self
}
#[must_use]
pub fn managing_editor(mut self, editor: impl Into<String>) -> Self {
self.managing_editor = Some(editor.into());
self
}
#[must_use]
pub fn webmaster(mut self, webmaster: impl Into<String>) -> Self {
self.webmaster = Some(webmaster.into());
self
}
#[must_use]
pub fn generate(&self, items: &[RssItem]) -> String {
let mut xml = String::with_capacity(512 + items.len() * 320);
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.push_str("<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\">\n");
xml.push_str(" <channel>\n");
let link = escape_xml(&self.link);
let _ = writeln!(xml, " <title>{}</title>", escape_xml(&self.title));
let _ = writeln!(xml, " <link>{link}</link>");
let _ = writeln!(
xml,
" <description>{}</description>",
escape_xml(&self.description)
);
let _ = writeln!(
xml,
" <atom:link href=\"{link}/feed.xml\" rel=\"self\" type=\"application/rss+xml\" />"
);
write_optional(&mut xml, "language", self.language.as_deref());
write_optional(&mut xml, "copyright", self.copyright.as_deref());
write_optional(&mut xml, "managingEditor", self.managing_editor.as_deref());
write_optional(&mut xml, "webMaster", self.webmaster.as_deref());
for item in items {
write_item(&mut xml, item);
}
xml.push_str(" </channel>\n");
xml.push_str("</rss>");
xml
}
}
fn write_optional(xml: &mut String, tag: &str, value: Option<&str>) {
if let Some(value) = value {
let _ = writeln!(xml, " <{tag}>{}</{tag}>", escape_xml(value));
}
}
fn write_item(xml: &mut String, item: &RssItem) {
xml.push_str(" <item>\n");
let _ = writeln!(xml, " <title>{}</title>", escape_xml(&item.title));
let _ = writeln!(xml, " <link>{}</link>", escape_xml(&item.link));
let _ = writeln!(
xml,
" <description>{}</description>",
escape_xml(&item.description)
);
let _ = writeln!(
xml,
" <pubDate>{}</pubDate>",
escape_xml(&item.pub_date)
);
if let Some(guid) = &item.guid {
let _ = writeln!(
xml,
" <guid isPermaLink=\"true\">{}</guid>",
escape_xml(guid)
);
}
if let Some(author) = &item.author {
let _ = writeln!(xml, " <author>{}</author>", escape_xml(author));
}
if let Some(categories) = &item.categories {
for category in categories {
let _ = writeln!(xml, " <category>{}</category>", escape_xml(category));
}
}
xml.push_str(" </item>\n");
}
#[cfg(test)]
mod tests {
use super::*;
fn channel() -> RssGenerator {
RssGenerator::new("RideKeeper", "https://ridekeeper.example", "Release notes")
}
#[test]
fn the_channel_carries_its_metadata_and_an_atom_self_link() {
let xml = channel().language("pt-BR").generate(&[]);
assert!(xml.contains("<title>RideKeeper</title>"));
assert!(xml.contains("<language>pt-BR</language>"));
assert!(xml.contains(
"<atom:link href=\"https://ridekeeper.example/feed.xml\" rel=\"self\" \
type=\"application/rss+xml\" />"
));
}
#[test]
fn absent_channel_fields_are_omitted_rather_than_emitted_empty() {
let xml = channel().generate(&[]);
for tag in [
"<language>",
"<copyright>",
"<managingEditor>",
"<webMaster>",
] {
assert!(!xml.contains(tag), "{tag} should be absent");
}
}
#[test]
fn the_guid_defaults_to_the_link() {
let item = RssItem::new(
"T",
"https://e.com/a",
"D",
"Tue, 11 Aug 2026 10:00:00 +0000",
);
assert_eq!(item.guid.as_deref(), Some("https://e.com/a"));
let overridden = item.clone().guid("urn:custom");
assert_eq!(overridden.guid.as_deref(), Some("urn:custom"));
}
#[test]
fn item_content_is_xml_escaped() {
let item = RssItem::new(
"1.2 — tyres & chain",
"https://e.com/a",
"Tyre pressure log <and> chain reminders",
"Tue, 11 Aug 2026 10:00:00 +0000",
);
let xml = channel().generate(&[item]);
assert!(xml.contains("<title>1.2 — tyres & chain</title>"));
assert!(
xml.contains(
"<description>Tyre pressure log <and> chain reminders</description>"
)
);
}
#[test]
fn categories_render_one_element_each() {
let item = RssItem::new(
"T",
"https://e.com/a",
"D",
"Tue, 11 Aug 2026 10:00:00 +0000",
)
.categories(["release", "ios"]);
let xml = channel().generate(&[item]);
assert_eq!(xml.matches("<category>").count(), 2);
}
#[test]
fn a_feed_with_no_items_is_still_valid() {
let xml = channel().generate(&[]);
assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<rss "));
assert!(xml.ends_with("</rss>"));
assert!(!xml.contains("<item>"));
}
#[test]
fn every_optional_channel_field_is_rendered_when_supplied() {
let xml = channel()
.language("en")
.copyright("\u{a9} 2026")
.managing_editor("editor@e.com")
.webmaster("web@e.com")
.generate(&[]);
assert!(xml.contains("<copyright>\u{a9} 2026</copyright>"));
assert!(xml.contains("<managingEditor>editor@e.com</managingEditor>"));
assert!(xml.contains("<webMaster>web@e.com</webMaster>"));
}
#[test]
fn an_item_without_optional_fields_emits_neither() {
let xml = channel().generate(&[RssItem::new(
"T",
"https://e.com/p",
"D",
"Tue, 11 Aug 2026 10:00:00 +0000",
)]);
assert!(!xml.contains("<author>"));
assert!(!xml.contains("<category>"));
}
#[test]
fn an_explicit_guid_wins_over_the_link() {
let xml = channel().generate(&[RssItem::new(
"T",
"https://e.com/p",
"D",
"Tue, 11 Aug 2026 10:00:00 +0000",
)
.guid("urn:uuid:1234")]);
assert!(xml.contains(r#"<guid isPermaLink="true">urn:uuid:1234</guid>"#));
}
#[test]
fn an_item_renders_every_field_it_is_given_xml_escaped() {
let xml = channel().generate(&[RssItem::new(
"Hello & welcome",
"https://example.com/hello",
"First <post>",
"Tue, 11 Aug 2026 10:00:00 +0000",
)
.author("me@example.com")
.categories(["swift", "html"])]);
assert!(xml.contains("<title>Hello & welcome</title>"));
assert!(xml.contains("<description>First <post></description>"));
assert!(xml.contains("<pubDate>Tue, 11 Aug 2026 10:00:00 +0000</pubDate>"));
assert!(xml.contains("<category>swift</category>"));
assert!(xml.contains("<category>html</category>"));
}
}