1use atom_syndication as atom;
9use chrono::{DateTime, Utc};
10
11pub struct AtomEntry {
13 pub id: String,
14 pub title: String,
15 pub updated: DateTime<Utc>,
16 pub content_html: String,
18 pub alternate_href: String,
20}
21
22pub struct AtomFeed {
24 pub id: String,
25 pub title: String,
26 pub updated: DateTime<Utc>,
27 pub self_href: String,
29 pub author: String,
31 pub entries: Vec<AtomEntry>,
32}
33
34impl AtomFeed {
35 pub fn serialize(&self) -> String {
37 let mut author = atom::Person::default();
38 author.set_name(self.author.clone());
39
40 let entries = self
41 .entries
42 .iter()
43 .map(AtomEntry::to_atom)
44 .collect::<Vec<_>>();
45
46 let mut feed = atom::Feed::default();
47 feed.set_id(self.id.clone());
48 feed.set_title(self.title.clone());
49 feed.set_updated(self.updated.fixed_offset());
50 feed.set_authors(vec![author]);
51 feed.set_links(vec![link("self", &self.self_href)]);
52 feed.set_entries(entries);
53 feed.to_string()
54 }
55}
56
57impl AtomEntry {
58 fn to_atom(&self) -> atom::Entry {
59 let mut content = atom::Content::default();
60 content.set_value(self.content_html.clone());
61 content.set_content_type("html".to_string());
62
63 let mut entry = atom::Entry::default();
64 entry.set_id(self.id.clone());
65 entry.set_title(self.title.clone());
66 entry.set_updated(self.updated.fixed_offset());
67 entry.set_links(vec![link("alternate", &self.alternate_href)]);
68 entry.set_content(content);
69 entry
70 }
71}
72
73fn link(rel: &str, href: &str) -> atom::Link {
75 let mut l = atom::Link::default();
76 l.set_rel(rel);
77 l.set_href(href.to_string());
78 l
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84
85 fn ts() -> DateTime<Utc> {
86 DateTime::parse_from_rfc3339("2025-01-15T00:00:00Z")
87 .unwrap()
88 .with_timezone(&Utc)
89 }
90
91 fn entry(id: &str, title: &str) -> AtomEntry {
92 AtomEntry {
93 id: id.to_string(),
94 title: title.to_string(),
95 updated: ts(),
96 content_html: "<p>Body</p>".to_string(),
97 alternate_href: format!("https://example.com/{id}.html"),
98 }
99 }
100
101 #[test]
102 fn test_serialize_has_namespace_and_feed_elements() {
103 let feed = AtomFeed {
104 id: "https://example.com/feed.xml".to_string(),
105 title: "My Blog".to_string(),
106 updated: ts(),
107 self_href: "https://example.com/feed.xml".to_string(),
108 author: "Ada Lovelace".to_string(),
109 entries: vec![entry("post", "First Post")],
110 };
111 let xml = feed.serialize();
112
113 assert!(xml.contains(r#"<feed xmlns="http://www.w3.org/2005/Atom">"#));
114 assert!(xml.contains("<id>https://example.com/feed.xml</id>"));
115 assert!(xml.contains("<title>My Blog</title>"));
116 assert!(xml.contains("<name>Ada Lovelace</name>"));
117 assert!(xml.contains(r#"rel="self""#));
118 assert!(xml.contains(r#"href="https://example.com/feed.xml""#));
119 assert!(xml.contains("<entry>"));
121 assert!(xml.contains("<title>First Post</title>"));
122 assert!(xml.contains(r#"rel="alternate""#));
123 assert!(xml.contains(r#"href="https://example.com/post.html""#));
124 assert!(xml.contains(r#"type="html""#));
126 assert!(xml.contains("<p>Body</p>"));
127 }
128
129 #[test]
130 fn test_serialize_multiple_entries() {
131 let feed = AtomFeed {
132 id: "id".to_string(),
133 title: "t".to_string(),
134 updated: ts(),
135 self_href: "self".to_string(),
136 author: "Rheo".to_string(),
137 entries: vec![entry("a", "A"), entry("b", "B")],
138 };
139 let xml = feed.serialize();
140 assert_eq!(xml.matches("<entry>").count(), 2);
141 }
142
143 #[test]
144 fn test_serialize_escapes_title() {
145 let feed = AtomFeed {
146 id: "id".to_string(),
147 title: r#"Tom & Jerry <3"#.to_string(),
148 updated: ts(),
149 self_href: "self".to_string(),
150 author: "Rheo".to_string(),
151 entries: vec![],
152 };
153 let xml = feed.serialize();
154 assert!(xml.contains("Tom & Jerry <3"));
155 }
156}