use atom_syndication as atom;
use chrono::{DateTime, Utc};
pub struct AtomEntry {
pub id: String,
pub title: String,
pub updated: DateTime<Utc>,
pub content_html: String,
pub alternate_href: String,
}
pub struct AtomFeed {
pub id: String,
pub title: String,
pub updated: DateTime<Utc>,
pub self_href: String,
pub author: String,
pub entries: Vec<AtomEntry>,
}
impl AtomFeed {
pub fn serialize(&self) -> String {
let mut author = atom::Person::default();
author.set_name(self.author.clone());
let entries = self
.entries
.iter()
.map(AtomEntry::to_atom)
.collect::<Vec<_>>();
let mut feed = atom::Feed::default();
feed.set_id(self.id.clone());
feed.set_title(self.title.clone());
feed.set_updated(self.updated.fixed_offset());
feed.set_authors(vec![author]);
feed.set_links(vec![link("self", &self.self_href)]);
feed.set_entries(entries);
feed.to_string()
}
}
impl AtomEntry {
fn to_atom(&self) -> atom::Entry {
let mut content = atom::Content::default();
content.set_value(self.content_html.clone());
content.set_content_type("html".to_string());
let mut entry = atom::Entry::default();
entry.set_id(self.id.clone());
entry.set_title(self.title.clone());
entry.set_updated(self.updated.fixed_offset());
entry.set_links(vec![link("alternate", &self.alternate_href)]);
entry.set_content(content);
entry
}
}
fn link(rel: &str, href: &str) -> atom::Link {
let mut l = atom::Link::default();
l.set_rel(rel);
l.set_href(href.to_string());
l
}
#[cfg(test)]
mod tests {
use super::*;
fn ts() -> DateTime<Utc> {
DateTime::parse_from_rfc3339("2025-01-15T00:00:00Z")
.unwrap()
.with_timezone(&Utc)
}
fn entry(id: &str, title: &str) -> AtomEntry {
AtomEntry {
id: id.to_string(),
title: title.to_string(),
updated: ts(),
content_html: "<p>Body</p>".to_string(),
alternate_href: format!("https://example.com/{id}.html"),
}
}
#[test]
fn test_serialize_has_namespace_and_feed_elements() {
let feed = AtomFeed {
id: "https://example.com/feed.xml".to_string(),
title: "My Blog".to_string(),
updated: ts(),
self_href: "https://example.com/feed.xml".to_string(),
author: "Ada Lovelace".to_string(),
entries: vec![entry("post", "First Post")],
};
let xml = feed.serialize();
assert!(xml.contains(r#"<feed xmlns="http://www.w3.org/2005/Atom">"#));
assert!(xml.contains("<id>https://example.com/feed.xml</id>"));
assert!(xml.contains("<title>My Blog</title>"));
assert!(xml.contains("<name>Ada Lovelace</name>"));
assert!(xml.contains(r#"rel="self""#));
assert!(xml.contains(r#"href="https://example.com/feed.xml""#));
assert!(xml.contains("<entry>"));
assert!(xml.contains("<title>First Post</title>"));
assert!(xml.contains(r#"rel="alternate""#));
assert!(xml.contains(r#"href="https://example.com/post.html""#));
assert!(xml.contains(r#"type="html""#));
assert!(xml.contains("<p>Body</p>"));
}
#[test]
fn test_serialize_multiple_entries() {
let feed = AtomFeed {
id: "id".to_string(),
title: "t".to_string(),
updated: ts(),
self_href: "self".to_string(),
author: "Rheo".to_string(),
entries: vec![entry("a", "A"), entry("b", "B")],
};
let xml = feed.serialize();
assert_eq!(xml.matches("<entry>").count(), 2);
}
#[test]
fn test_serialize_escapes_title() {
let feed = AtomFeed {
id: "id".to_string(),
title: r#"Tom & Jerry <3"#.to_string(),
updated: ts(),
self_href: "self".to_string(),
author: "Rheo".to_string(),
entries: vec![],
};
let xml = feed.serialize();
assert!(xml.contains("Tom & Jerry <3"));
}
}