1use std::fmt::Write as _;
5
6use crate::core::escape::escape_xml;
7use crate::core::url_policy::is_http_url;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11#[non_exhaustive]
12pub struct RssItem {
13 pub title: String,
15 pub link: String,
17 pub description: String,
19 pub pub_date: String,
21 pub guid: Option<String>,
23 pub author: Option<String>,
25 pub categories: Option<Vec<String>>,
27}
28
29impl RssItem {
30 pub fn new(
33 title: impl Into<String>,
34 link: impl Into<String>,
35 description: impl Into<String>,
36 pub_date: impl Into<String>,
37 ) -> Self {
38 let link = link.into();
39 Self {
40 title: title.into(),
41 guid: Some(link.clone()),
42 link,
43 description: description.into(),
44 pub_date: pub_date.into(),
45 author: None,
46 categories: None,
47 }
48 }
49
50 #[must_use]
52 pub fn guid(mut self, guid: impl Into<String>) -> Self {
53 self.guid = Some(guid.into());
54 self
55 }
56
57 #[must_use]
59 pub fn author(mut self, author: impl Into<String>) -> Self {
60 self.author = Some(author.into());
61 self
62 }
63
64 #[must_use]
66 pub fn categories<S: Into<String>>(mut self, categories: impl IntoIterator<Item = S>) -> Self {
67 self.categories = Some(categories.into_iter().map(Into::into).collect());
68 self
69 }
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
89#[non_exhaustive]
90pub struct RssGenerator {
91 pub title: String,
93 pub link: String,
95 pub description: String,
97 pub language: Option<String>,
99 pub copyright: Option<String>,
101 pub managing_editor: Option<String>,
103 pub webmaster: Option<String>,
108}
109
110impl RssGenerator {
111 pub fn new(
113 title: impl Into<String>,
114 link: impl Into<String>,
115 description: impl Into<String>,
116 ) -> Self {
117 Self {
118 title: title.into(),
119 link: link.into(),
120 description: description.into(),
121 language: None,
122 copyright: None,
123 managing_editor: None,
124 webmaster: None,
125 }
126 }
127
128 #[must_use]
130 pub fn language(mut self, language: impl Into<String>) -> Self {
131 self.language = Some(language.into());
132 self
133 }
134
135 #[must_use]
137 pub fn copyright(mut self, copyright: impl Into<String>) -> Self {
138 self.copyright = Some(copyright.into());
139 self
140 }
141
142 #[must_use]
144 pub fn managing_editor(mut self, editor: impl Into<String>) -> Self {
145 self.managing_editor = Some(editor.into());
146 self
147 }
148
149 #[must_use]
151 pub fn webmaster(mut self, webmaster: impl Into<String>) -> Self {
152 self.webmaster = Some(webmaster.into());
153 self
154 }
155
156 #[must_use]
158 pub fn generate(&self, items: &[RssItem]) -> String {
159 let mut xml = String::with_capacity(512 + items.len() * 320);
160 xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
161 xml.push_str("<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\">\n");
162 xml.push_str(" <channel>\n");
163
164 let link = escape_xml(&self.link);
165 let _ = writeln!(xml, " <title>{}</title>", escape_xml(&self.title));
166 let linkable = is_http_url(&self.link);
169 if linkable {
170 let _ = writeln!(xml, " <link>{link}</link>");
171 }
172 let _ = writeln!(
173 xml,
174 " <description>{}</description>",
175 escape_xml(&self.description)
176 );
177 if linkable {
178 let _ = writeln!(
179 xml,
180 " <atom:link href=\"{link}/feed.xml\" rel=\"self\" type=\"application/rss+xml\" />"
181 );
182 }
183
184 write_optional(&mut xml, "language", self.language.as_deref());
185 write_optional(&mut xml, "copyright", self.copyright.as_deref());
186 write_optional(&mut xml, "managingEditor", self.managing_editor.as_deref());
187 write_optional(&mut xml, "webMaster", self.webmaster.as_deref());
188
189 for item in items {
190 write_item(&mut xml, item);
191 }
192
193 xml.push_str(" </channel>\n");
194 xml.push_str("</rss>");
195 xml
196 }
197}
198
199fn write_optional(xml: &mut String, tag: &str, value: Option<&str>) {
201 if let Some(value) = value {
202 let _ = writeln!(xml, " <{tag}>{}</{tag}>", escape_xml(value));
203 }
204}
205
206fn write_item(xml: &mut String, item: &RssItem) {
207 xml.push_str(" <item>\n");
208 let _ = writeln!(xml, " <title>{}</title>", escape_xml(&item.title));
209 if is_http_url(&item.link) {
210 let _ = writeln!(xml, " <link>{}</link>", escape_xml(&item.link));
211 }
212 let _ = writeln!(
213 xml,
214 " <description>{}</description>",
215 escape_xml(&item.description)
216 );
217 let _ = writeln!(
218 xml,
219 " <pubDate>{}</pubDate>",
220 escape_xml(&item.pub_date)
221 );
222
223 if let Some(guid) = &item.guid {
224 let _ = writeln!(
227 xml,
228 " <guid isPermaLink=\"{}\">{}</guid>",
229 is_http_url(guid),
230 escape_xml(guid)
231 );
232 }
233 if let Some(author) = &item.author {
234 let _ = writeln!(xml, " <author>{}</author>", escape_xml(author));
235 }
236 if let Some(categories) = &item.categories {
237 for category in categories {
238 let _ = writeln!(xml, " <category>{}</category>", escape_xml(category));
239 }
240 }
241
242 xml.push_str(" </item>\n");
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 fn channel() -> RssGenerator {
250 RssGenerator::new("RideKeeper", "https://ridekeeper.example", "Release notes")
251 }
252
253 #[test]
254 fn the_channel_carries_its_metadata_and_an_atom_self_link() {
255 let xml = channel().language("pt-BR").generate(&[]);
256 assert!(xml.contains("<title>RideKeeper</title>"));
257 assert!(xml.contains("<language>pt-BR</language>"));
258 assert!(xml.contains(
259 "<atom:link href=\"https://ridekeeper.example/feed.xml\" rel=\"self\" \
260 type=\"application/rss+xml\" />"
261 ));
262 }
263
264 #[test]
265 fn absent_channel_fields_are_omitted_rather_than_emitted_empty() {
266 let xml = channel().generate(&[]);
267 for tag in [
268 "<language>",
269 "<copyright>",
270 "<managingEditor>",
271 "<webMaster>",
272 ] {
273 assert!(!xml.contains(tag), "{tag} should be absent");
274 }
275 }
276
277 #[test]
278 fn the_guid_defaults_to_the_link() {
279 let item = RssItem::new(
280 "T",
281 "https://e.com/a",
282 "D",
283 "Tue, 11 Aug 2026 10:00:00 +0000",
284 );
285 assert_eq!(item.guid.as_deref(), Some("https://e.com/a"));
286
287 let overridden = item.clone().guid("urn:custom");
288 assert_eq!(overridden.guid.as_deref(), Some("urn:custom"));
289 }
290
291 #[test]
292 fn item_content_is_xml_escaped() {
293 let item = RssItem::new(
294 "1.2 — tyres & chain",
295 "https://e.com/a",
296 "Tyre pressure log <and> chain reminders",
297 "Tue, 11 Aug 2026 10:00:00 +0000",
298 );
299 let xml = channel().generate(&[item]);
300 assert!(xml.contains("<title>1.2 — tyres & chain</title>"));
301 assert!(
302 xml.contains(
303 "<description>Tyre pressure log <and> chain reminders</description>"
304 )
305 );
306 }
307
308 #[test]
309 fn categories_render_one_element_each() {
310 let item = RssItem::new(
311 "T",
312 "https://e.com/a",
313 "D",
314 "Tue, 11 Aug 2026 10:00:00 +0000",
315 )
316 .categories(["release", "ios"]);
317 let xml = channel().generate(&[item]);
318 assert_eq!(xml.matches("<category>").count(), 2);
319 }
320
321 #[test]
322 fn a_feed_with_no_items_is_still_valid() {
323 let xml = channel().generate(&[]);
324 assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<rss "));
325 assert!(xml.ends_with("</rss>"));
326 assert!(!xml.contains("<item>"));
327 }
328
329 #[test]
330 fn every_optional_channel_field_is_rendered_when_supplied() {
331 let xml = channel()
332 .language("en")
333 .copyright("\u{a9} 2026")
334 .managing_editor("editor@e.com")
335 .webmaster("web@e.com")
336 .generate(&[]);
337
338 assert!(xml.contains("<copyright>\u{a9} 2026</copyright>"));
339 assert!(xml.contains("<managingEditor>editor@e.com</managingEditor>"));
340 assert!(xml.contains("<webMaster>web@e.com</webMaster>"));
341 }
342
343 #[test]
344 fn an_item_without_optional_fields_emits_neither() {
345 let xml = channel().generate(&[RssItem::new(
346 "T",
347 "https://e.com/p",
348 "D",
349 "Tue, 11 Aug 2026 10:00:00 +0000",
350 )]);
351
352 assert!(!xml.contains("<author>"));
353 assert!(!xml.contains("<category>"));
354 }
355
356 #[test]
359 fn an_explicit_guid_wins_over_the_link() {
360 let xml = channel().generate(&[RssItem::new(
361 "T",
362 "https://e.com/p",
363 "D",
364 "Tue, 11 Aug 2026 10:00:00 +0000",
365 )
366 .guid("urn:uuid:1234")]);
367
368 assert!(xml.contains(r#"<guid isPermaLink="false">urn:uuid:1234</guid>"#));
369 let url_guid = channel().generate(&[RssItem::new(
370 "T",
371 "https://e.com/p",
372 "D",
373 "Tue, 11 Aug 2026 10:00:00 +0000",
374 )
375 .guid("https://e.com/permalink")]);
376 assert!(url_guid.contains(r#"<guid isPermaLink="true">https://e.com/permalink</guid>"#));
377 }
378
379 #[test]
380 fn a_link_that_is_not_http_is_left_out_of_the_feed() {
381 let item = |link: &str| RssItem::new("T", link, "D", "Tue, 11 Aug 2026 10:00:00 +0000");
382 for bad in [
383 "javascript:alert(1)",
384 "/relative",
385 "ftp://e.com",
386 "data:text/html,x",
387 ] {
388 let xml = channel().generate(&[item(bad)]);
389 assert!(!xml.contains(&format!("<link>{bad}")), "{bad}: {xml}");
390 assert_eq!(xml.matches("<link>").count(), 1, "{bad}");
392 }
393 let bad_channel = RssGenerator::new("T", "javascript:x", "D").generate(&[]);
394 assert!(!bad_channel.contains("<link>"), "{bad_channel}");
395 assert!(!bad_channel.contains("atom:link"), "{bad_channel}");
396 assert_eq!(
397 channel()
398 .generate(&[item("https://e.com/p")])
399 .matches("<link>")
400 .count(),
401 2
402 );
403 }
404
405 #[test]
406 fn characters_xml_cannot_hold_do_not_reach_the_feed() {
407 let xml = channel().generate(&[RssItem::new(
408 "a\u{0}b\u{1F}c\u{FFFE}d",
409 "https://e.com/p",
410 "ok\ttab",
411 "Tue, 11 Aug 2026 10:00:00 +0000",
412 )]);
413 assert!(xml.contains("<title>abcd</title>"));
414 assert!(xml.contains("ok\ttab"));
415 assert!(!xml.chars().any(|c| matches!(c, '\u{0}'..='\u{8}' | '\u{B}' | '\u{C}' | '\u{E}'..='\u{1F}' | '\u{FFFE}' | '\u{FFFF}')));
416 }
417
418 #[test]
419 fn an_item_renders_every_field_it_is_given_xml_escaped() {
420 let xml = channel().generate(&[RssItem::new(
421 "Hello & welcome",
422 "https://example.com/hello",
423 "First <post>",
424 "Tue, 11 Aug 2026 10:00:00 +0000",
425 )
426 .author("me@example.com")
427 .categories(["rust", "html"])]);
428
429 assert!(xml.contains("<title>Hello & welcome</title>"));
430 assert!(xml.contains("<description>First <post></description>"));
431 assert!(xml.contains("<pubDate>Tue, 11 Aug 2026 10:00:00 +0000</pubDate>"));
432 assert!(xml.contains("<category>rust</category>"));
433 assert!(xml.contains("<category>html</category>"));
434 }
435}