use std::fmt::Write as _;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Feed {
pub title: String,
pub link: String,
pub description: String,
pub items: Vec<FeedItem>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FeedItem {
pub title: String,
pub link: String,
pub description: String,
pub pub_date: Option<String>,
}
impl Feed {
#[must_use]
pub fn to_rss(&self) -> String {
let mut xml = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<rss version=\"2.0\"><channel>",
);
append_text_element(&mut xml, "title", &self.title);
append_text_element(&mut xml, "link", &self.link);
append_text_element(&mut xml, "description", &self.description);
for item in &self.items {
xml.push_str("<item>");
append_text_element(&mut xml, "title", &item.title);
append_text_element(&mut xml, "link", &item.link);
append_text_element(&mut xml, "description", &item.description);
if let Some(pub_date) = &item.pub_date {
append_text_element(&mut xml, "pubDate", pub_date);
}
xml.push_str("</item>");
}
xml.push_str("</channel></rss>");
xml
}
#[must_use]
pub fn to_atom(&self) -> String {
let mut xml = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<feed xmlns=\"http://www.w3.org/2005/Atom\">",
);
append_text_element(&mut xml, "title", &self.title);
let _ = write!(xml, "<link href=\"{}\" />", escape_xml(&self.link));
append_text_element(&mut xml, "id", &self.link);
append_text_element(&mut xml, "subtitle", &self.description);
append_text_element(&mut xml, "updated", self.updated_timestamp());
for item in &self.items {
xml.push_str("<entry>");
append_text_element(&mut xml, "title", &item.title);
let _ = write!(xml, "<link href=\"{}\" />", escape_xml(&item.link));
append_text_element(&mut xml, "id", &item.link);
append_text_element(&mut xml, "summary", &item.description);
append_text_element(
&mut xml,
"updated",
item.pub_date.as_deref().unwrap_or(self.updated_timestamp()),
);
xml.push_str("</entry>");
}
xml.push_str("</feed>");
xml
}
fn updated_timestamp(&self) -> &str {
self.items
.iter()
.find_map(|item| item.pub_date.as_deref())
.unwrap_or("1970-01-01T00:00:00Z")
}
}
fn append_text_element(xml: &mut String, tag: &str, value: &str) {
let _ = write!(xml, "<{tag}>{}</{tag}>", escape_xml(value));
}
fn escape_xml(input: &str) -> String {
let mut escaped = String::with_capacity(input.len());
for character in input.chars() {
match character {
'&' => escaped.push_str("&"),
'<' => escaped.push_str("<"),
'>' => escaped.push_str(">"),
'"' => escaped.push_str("""),
'\'' => escaped.push_str("'"),
_ => escaped.push(character),
}
}
escaped
}
#[cfg(test)]
mod tests {
use super::{Feed, FeedItem};
fn sample_feed() -> Feed {
Feed {
title: "Rjango Updates".to_string(),
link: "https://example.com/feed?lang=en&format=rss".to_string(),
description: "Latest <framework> news & releases".to_string(),
items: vec![
FeedItem {
title: "Version 0.1".to_string(),
link: "https://example.com/posts/1".to_string(),
description: "First release".to_string(),
pub_date: Some("Fri, 01 Mar 2024 10:00:00 GMT".to_string()),
},
FeedItem {
title: "Escaping & validation".to_string(),
link: "https://example.com/posts/2?draft=false&format=atom".to_string(),
description: "Safer <xml> output".to_string(),
pub_date: None,
},
],
}
}
#[test]
fn rss_generation_includes_channel_metadata() {
let rss = sample_feed().to_rss();
assert!(rss.contains("<rss version=\"2.0\">"));
assert!(rss.contains("<title>Rjango Updates</title>"));
assert!(
rss.contains("<description>Latest <framework> news & releases</description>")
);
}
#[test]
fn rss_generation_includes_items_and_optional_pub_dates() {
let rss = sample_feed().to_rss();
assert_eq!(rss.matches("<item>").count(), 2);
assert!(rss.contains("<pubDate>Fri, 01 Mar 2024 10:00:00 GMT</pubDate>"));
assert!(rss.contains("<title>Escaping & validation</title>"));
}
#[test]
fn atom_generation_includes_required_elements() {
let atom = sample_feed().to_atom();
assert!(atom.contains("<feed xmlns=\"http://www.w3.org/2005/Atom\">"));
assert!(atom.contains("<subtitle>Latest <framework> news & releases</subtitle>"));
assert!(atom.contains("<updated>Fri, 01 Mar 2024 10:00:00 GMT</updated>"));
}
#[test]
fn atom_generation_escapes_attribute_values_and_entry_content() {
let atom = sample_feed().to_atom();
assert!(atom.contains("<link href=\"https://example.com/feed?lang=en&format=rss\" />"));
assert!(
atom.contains(
"<link href=\"https://example.com/posts/2?draft=false&format=atom\" />"
)
);
assert!(atom.contains("<summary>Safer <xml> output</summary>"));
}
#[test]
fn atom_generation_falls_back_to_epoch_when_no_pub_dates_exist() {
let feed = Feed {
title: "Empty dates".to_string(),
link: "https://example.com/empty".to_string(),
description: "No dated entries".to_string(),
items: vec![FeedItem {
title: "Undated".to_string(),
link: "https://example.com/empty/1".to_string(),
description: "Still valid".to_string(),
pub_date: None,
}],
};
let atom = feed.to_atom();
assert!(atom.contains("<updated>1970-01-01T00:00:00Z</updated>"));
}
}