Skip to main content

max / makenotwork

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