Skip to main content

max / makenotwork

13.5 KB · 422 lines History Blame Raw
1 //! RSS 2.0 feed generation
2 //!
3 //! Simple XML builder for creator and project RSS feeds.
4 //! No external dependency needed, only format strings.
5 //!
6 //! See also: `/docs/guide/rss`
7
8 use std::fmt::Write as _;
9
10 use chrono::{DateTime, Utc};
11
12 /// A single item in an RSS feed.
13 pub struct FeedItem {
14 pub title: String,
15 pub link: String,
16 pub description: String,
17 pub pub_date: DateTime<Utc>,
18 pub guid: String,
19 }
20
21 /// Escape XML special characters, and drop bytes XML 1.0 forbids outright.
22 ///
23 /// XML 1.0 permits only `#x9` (tab), `#xA` (LF), and `#xD` (CR) from the C0
24 /// control range; every other control byte (`#x0`-`#x8`, `#xB`, `#xC`,
25 /// `#xE`-`#x1F`) is illegal *even when escaped*; a strict feed parser rejects
26 /// the whole document. Upstream validation already blocks most of these, so this
27 /// is defense-in-depth: silently drop them (there is no valid representation) so
28 /// a stray control byte in a title/description can't produce an unparseable feed.
29 fn xml_escape(s: &str) -> String {
30 let mut out = String::with_capacity(s.len());
31 for c in s.chars() {
32 match c {
33 '&' => out.push_str("&amp;"),
34 '<' => out.push_str("&lt;"),
35 '>' => out.push_str("&gt;"),
36 '"' => out.push_str("&quot;"),
37 '\'' => out.push_str("&apos;"),
38 '\t' | '\n' | '\r' => out.push(c),
39 // C0 control byte that XML 1.0 forbids; drop it.
40 c if (c as u32) < 0x20 => {}
41 c => out.push(c),
42 }
43 }
44 out
45 }
46
47 /// Core RSS 2.0 renderer. All public feed functions delegate here.
48 fn render_feed(title: &str, link: &str, description: &str, items: &[FeedItem]) -> String {
49 let last_build = items.first().map_or_else(Utc::now, |i| i.pub_date);
50
51 let mut xml = String::with_capacity(4096);
52 xml.push_str(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
53 xml.push('\n');
54 xml.push_str(r#"<rss version="2.0">"#);
55 xml.push_str("\n<channel>\n");
56 writeln!(xml, " <title>{}</title>", xml_escape(title)).unwrap();
57 writeln!(xml, " <link>{}</link>", xml_escape(link)).unwrap();
58 writeln!(
59 xml,
60 " <description>{}</description>",
61 xml_escape(description)
62 )
63 .unwrap();
64 writeln!(
65 xml,
66 " <lastBuildDate>{}</lastBuildDate>",
67 last_build.to_rfc2822()
68 )
69 .unwrap();
70 xml.push_str(" <generator>Makenotwork</generator>\n");
71
72 for item in items {
73 xml.push_str(" <item>\n");
74 writeln!(xml, " <title>{}</title>", xml_escape(&item.title)).unwrap();
75 writeln!(xml, " <link>{}</link>", xml_escape(&item.link)).unwrap();
76 writeln!(
77 xml,
78 " <description>{}</description>",
79 xml_escape(&item.description)
80 )
81 .unwrap();
82 writeln!(xml, " <pubDate>{}</pubDate>", item.pub_date.to_rfc2822()).unwrap();
83 writeln!(
84 xml,
85 " <guid isPermaLink=\"false\">{}</guid>",
86 xml_escape(&item.guid)
87 )
88 .unwrap();
89 xml.push_str(" </item>\n");
90 }
91
92 xml.push_str("</channel>\n</rss>\n");
93 xml
94 }
95
96 /// Render an RSS 2.0 feed with custom title, link, and description.
97 pub fn render_feed_custom(
98 title: &str,
99 link: &str,
100 description: &str,
101 items: &[FeedItem],
102 ) -> String {
103 render_feed(title, link, description, items)
104 }
105
106 /// Render an RSS 2.0 feed for a single project's items.
107 pub fn render_project_feed(
108 project_title: &str,
109 project_slug: &str,
110 project_description: &str,
111 creator_username: &str,
112 items: &[FeedItem],
113 base_url: &str,
114 ) -> String {
115 let link = format!("{base_url}/p/{project_slug}");
116 let description = format!("{project_description} by {creator_username}");
117 render_feed(project_title, &link, &description, items)
118 }
119
120 /// Render an RSS 2.0 feed for all of a creator's public items across projects.
121 pub fn render_creator_feed(
122 display_name: &str,
123 username: &str,
124 bio: &str,
125 items: &[FeedItem],
126 base_url: &str,
127 ) -> String {
128 let link = format!("{base_url}/u/{username}");
129 render_feed(display_name, &link, bio, items)
130 }
131
132 /// Render an RSS 2.0 feed for a project's blog posts.
133 pub fn render_blog_feed(
134 project_title: &str,
135 project_slug: &str,
136 project_description: &str,
137 creator_username: &str,
138 items: &[FeedItem],
139 base_url: &str,
140 ) -> String {
141 let title = format!("{project_title} - Blog");
142 let link = format!("{base_url}/p/{project_slug}/blog");
143 let description = format!("Blog posts from {project_description} by {creator_username}");
144 render_feed(&title, &link, &description, items)
145 }
146
147 #[cfg(test)]
148 mod tests {
149 use super::*;
150 use chrono::TimeZone;
151
152 fn sample_items(base_url: &str) -> Vec<FeedItem> {
153 vec![
154 FeedItem {
155 title: "Test Item".to_string(),
156 link: format!("{base_url}/i/00000000-0000-0000-0000-000000000001"),
157 description: "A test item".to_string(),
158 pub_date: Utc.with_ymd_and_hms(2026, 1, 15, 12, 0, 0).unwrap(),
159 guid: "00000000-0000-0000-0000-000000000001".to_string(),
160 },
161 FeedItem {
162 title: "Item with <special> & chars".to_string(),
163 link: format!("{base_url}/i/00000000-0000-0000-0000-000000000002"),
164 description: "Description with \"quotes\"".to_string(),
165 pub_date: Utc.with_ymd_and_hms(2026, 1, 10, 12, 0, 0).unwrap(),
166 guid: "00000000-0000-0000-0000-000000000002".to_string(),
167 },
168 ]
169 }
170
171 #[test]
172 fn project_feed_is_valid_xml_structure() {
173 let items = sample_items("https://makenot.work");
174 let xml = render_project_feed(
175 "My Project",
176 "my-project",
177 "A cool project",
178 "creator",
179 &items,
180 "https://makenot.work",
181 );
182
183 assert!(xml.starts_with(r#"<?xml version="1.0" encoding="UTF-8"?>"#));
184 assert!(xml.contains("<rss version=\"2.0\">"));
185 assert!(xml.contains("<channel>"));
186 assert!(xml.contains("<title>My Project</title>"));
187 assert!(xml.contains("<generator>Makenotwork</generator>"));
188 assert!(xml.contains("</rss>"));
189 }
190
191 #[test]
192 fn creator_feed_is_valid_xml_structure() {
193 let items = sample_items("https://makenot.work");
194 let xml = render_creator_feed(
195 "Creator Name",
196 "creator",
197 "I make things",
198 &items,
199 "https://makenot.work",
200 );
201
202 assert!(xml.contains("<title>Creator Name</title>"));
203 assert!(xml.contains("<link>https://makenot.work/u/creator</link>"));
204 }
205
206 #[test]
207 fn xml_special_chars_are_escaped() {
208 let items = sample_items("https://makenot.work");
209 let xml = render_project_feed(
210 "Test",
211 "test",
212 "desc",
213 "user",
214 &items,
215 "https://makenot.work",
216 );
217
218 assert!(xml.contains("&lt;special&gt;"));
219 assert!(xml.contains("&amp; chars"));
220 assert!(xml.contains("&quot;quotes&quot;"));
221 }
222
223 #[test]
224 fn empty_feed_is_valid() {
225 let xml = render_project_feed(
226 "Empty",
227 "empty",
228 "No items",
229 "user",
230 &[],
231 "https://makenot.work",
232 );
233
234 assert!(xml.contains("<title>Empty</title>"));
235 assert!(!xml.contains("<item>"));
236 }
237
238 #[test]
239 fn blog_feed_is_valid_xml_structure() {
240 let items = sample_items("https://makenot.work");
241 let xml = render_blog_feed(
242 "My Project",
243 "my-project",
244 "A cool project",
245 "creator",
246 &items,
247 "https://makenot.work",
248 );
249
250 assert!(xml.starts_with(r#"<?xml version="1.0" encoding="UTF-8"?>"#));
251 assert!(xml.contains("<title>My Project - Blog</title>"));
252 assert!(xml.contains("<link>https://makenot.work/p/my-project/blog</link>"));
253 assert!(xml.contains("creator"));
254 assert!(xml.contains("<item>"));
255 }
256
257 #[test]
258 fn xml_escape_all_special_chars() {
259 assert_eq!(xml_escape("&"), "&amp;");
260 assert_eq!(xml_escape("<"), "&lt;");
261 assert_eq!(xml_escape(">"), "&gt;");
262 assert_eq!(xml_escape("\""), "&quot;");
263 assert_eq!(xml_escape("'"), "&apos;");
264 }
265
266 #[test]
267 fn xml_escape_combined() {
268 assert_eq!(
269 xml_escape("Tom & Jerry <\"friends\">"),
270 "Tom &amp; Jerry &lt;&quot;friends&quot;&gt;"
271 );
272 }
273
274 #[test]
275 fn xml_escape_no_special_chars() {
276 assert_eq!(xml_escape("plain text 123"), "plain text 123");
277 }
278
279 #[test]
280 fn xml_escape_empty_string() {
281 assert_eq!(xml_escape(""), "");
282 }
283
284 #[test]
285 fn xml_escape_double_ampersand() {
286 // Ensure & is not double-escaped
287 assert_eq!(xml_escape("&amp;"), "&amp;amp;");
288 }
289
290 #[test]
291 fn xml_escape_drops_illegal_control_bytes_keeps_tab_lf_cr() {
292 // NUL, backspace, vertical tab, form feed, and other C0 controls are
293 // illegal in XML 1.0 even escaped; they must be dropped, not emitted.
294 assert_eq!(xml_escape("a\u{0}b\u{8}c\u{B}d\u{C}e\u{1F}f"), "abcdef");
295 // Tab / LF / CR are the three legal control chars, preserved verbatim.
296 assert_eq!(xml_escape("x\ty\nz\r"), "x\ty\nz\r");
297 // A control byte adjacent to an entity char still escapes correctly.
298 assert_eq!(xml_escape("A\u{0}&B"), "A&amp;B");
299 }
300
301 #[test]
302 fn pub_date_is_rfc2822() {
303 let date = Utc.with_ymd_and_hms(2026, 3, 15, 14, 30, 0).unwrap();
304 let item = FeedItem {
305 title: "Test".to_string(),
306 link: "https://example.com".to_string(),
307 description: "desc".to_string(),
308 pub_date: date,
309 guid: "guid-1".to_string(),
310 };
311 let xml = render_feed("T", "https://example.com", "D", &[item]);
312 // RFC 2822 format: "Sun, 15 Mar 2026 14:30:00 +0000"
313 assert!(xml.contains("<pubDate>Sun, 15 Mar 2026 14:30:00 +0000</pubDate>"));
314 }
315
316 #[test]
317 fn last_build_date_uses_first_item() {
318 let items = sample_items("https://example.com");
319 let xml = render_feed("T", "https://example.com", "D", &items);
320 // First item is 2026-01-15
321 assert!(xml.contains("<lastBuildDate>Thu, 15 Jan 2026 12:00:00 +0000</lastBuildDate>"));
322 }
323
324 #[test]
325 fn empty_feed_still_has_last_build_date() {
326 let xml = render_feed("T", "https://example.com", "D", &[]);
327 assert!(xml.contains("<lastBuildDate>"));
328 }
329
330 #[test]
331 fn feed_with_many_items() {
332 let items: Vec<FeedItem> = (0..50)
333 .map(|i| FeedItem {
334 title: format!("Item {i}"),
335 link: format!("https://example.com/{i}"),
336 description: format!("Description {i}"),
337 pub_date: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
338 guid: format!("guid-{i}"),
339 })
340 .collect();
341 let xml = render_feed("Bulk", "https://example.com", "50 items", &items);
342 // Count <item> occurrences
343 let count = xml.matches("<item>").count();
344 assert_eq!(count, 50);
345 }
346
347 #[test]
348 fn render_feed_custom_passes_through() {
349 let xml = render_feed_custom("Custom", "https://custom.com", "Custom desc", &[]);
350 assert!(xml.contains("<title>Custom</title>"));
351 assert!(xml.contains("<link>https://custom.com</link>"));
352 assert!(xml.contains("<description>Custom desc</description>"));
353 }
354
355 #[test]
356 fn project_feed_link_format() {
357 let xml = render_project_feed(
358 "Proj",
359 "my-slug",
360 "desc",
361 "alice",
362 &[],
363 "https://makenot.work",
364 );
365 assert!(xml.contains("<link>https://makenot.work/p/my-slug</link>"));
366 assert!(xml.contains("<description>desc by alice</description>"));
367 }
368
369 #[test]
370 fn creator_feed_link_format() {
371 let xml = render_creator_feed(
372 "Alice",
373 "alice",
374 "Makes things",
375 &[],
376 "https://makenot.work",
377 );
378 assert!(xml.contains("<link>https://makenot.work/u/alice</link>"));
379 }
380
381 #[test]
382 fn blog_feed_link_and_title_format() {
383 let xml = render_blog_feed(
384 "My Project",
385 "my-proj",
386 "A project",
387 "bob",
388 &[],
389 "https://makenot.work",
390 );
391 assert!(xml.contains("<title>My Project - Blog</title>"));
392 assert!(xml.contains("<link>https://makenot.work/p/my-proj/blog</link>"));
393 assert!(xml.contains("Blog posts from A project by bob"));
394 }
395
396 #[test]
397 fn guid_is_not_permalink() {
398 let items = vec![FeedItem {
399 title: "T".to_string(),
400 link: "https://example.com".to_string(),
401 description: "D".to_string(),
402 pub_date: Utc::now(),
403 guid: "unique-id".to_string(),
404 }];
405 let xml = render_feed("T", "https://example.com", "D", &items);
406 assert!(xml.contains("<guid isPermaLink=\"false\">unique-id</guid>"));
407 }
408
409 #[test]
410 fn special_chars_in_title_and_description_escaped_in_channel() {
411 let xml = render_feed(
412 "Tom & Jerry's <Show>",
413 "https://example.com?a=1&b=2",
414 "A \"great\" show",
415 &[],
416 );
417 assert!(xml.contains("<title>Tom &amp; Jerry&apos;s &lt;Show&gt;</title>"));
418 assert!(xml.contains("<link>https://example.com?a=1&amp;b=2</link>"));
419 assert!(xml.contains("<description>A &quot;great&quot; show</description>"));
420 }
421 }
422