1use std::fmt::Write as _;
6
7use crate::core::escape::escape_xml;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct RssItem {
12 pub title: String,
14 pub link: String,
16 pub description: String,
18 pub pub_date: String,
20 pub guid: Option<String>,
22 pub author: Option<String>,
24 pub categories: Option<Vec<String>>,
26}
27
28impl RssItem {
29 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 #[must_use]
51 pub fn guid(mut self, guid: impl Into<String>) -> Self {
52 self.guid = Some(guid.into());
53 self
54 }
55
56 #[must_use]
58 pub fn author(mut self, author: impl Into<String>) -> Self {
59 self.author = Some(author.into());
60 self
61 }
62
63 #[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#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct RssGenerator {
89 pub title: String,
91 pub link: String,
93 pub description: String,
95 pub language: Option<String>,
97 pub copyright: Option<String>,
99 pub managing_editor: Option<String>,
101 pub webmaster: Option<String>,
107}
108
109impl RssGenerator {
110 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 #[must_use]
129 pub fn language(mut self, language: impl Into<String>) -> Self {
130 self.language = Some(language.into());
131 self
132 }
133
134 #[must_use]
136 pub fn copyright(mut self, copyright: impl Into<String>) -> Self {
137 self.copyright = Some(copyright.into());
138 self
139 }
140
141 #[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 #[must_use]
150 pub fn webmaster(mut self, webmaster: impl Into<String>) -> Self {
151 self.webmaster = Some(webmaster.into());
152 self
153 }
154
155 #[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
191fn 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 #[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 #[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 #[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 & chain</title>"));
291 assert!(
292 xml.contains(
293 "<description>Tyre pressure log <and> 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 #[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 #[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 #[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 #[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 #[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 & welcome</title>"));
377 assert!(xml.contains("<description>First <post></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}