//! RSS 2.0 feed generation //! //! Simple XML builder for creator and project RSS feeds. //! No external dependency needed, only format strings. //! //! See also: `/docs/guide/rss` use std::fmt::Write as _; use chrono::{DateTime, Utc}; /// A single item in an RSS feed. pub struct FeedItem { pub title: String, pub link: String, pub description: String, pub pub_date: DateTime, pub guid: String, } /// Escape XML special characters, and drop bytes XML 1.0 forbids outright. /// /// XML 1.0 permits only `#x9` (tab), `#xA` (LF), and `#xD` (CR) from the C0 /// control range; every other control byte (`#x0`-`#x8`, `#xB`, `#xC`, /// `#xE`-`#x1F`) is illegal *even when escaped*; a strict feed parser rejects /// the whole document. Upstream validation already blocks most of these, so this /// is defense-in-depth: silently drop them (there is no valid representation) so /// a stray control byte in a title/description can't produce an unparseable feed. fn xml_escape(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() { match c { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), '\'' => out.push_str("'"), '\t' | '\n' | '\r' => out.push(c), // C0 control byte that XML 1.0 forbids; drop it. c if (c as u32) < 0x20 => {} c => out.push(c), } } out } /// Core RSS 2.0 renderer. All public feed functions delegate here. fn render_feed(title: &str, link: &str, description: &str, items: &[FeedItem]) -> String { let last_build = items.first().map_or_else(Utc::now, |i| i.pub_date); let mut xml = String::with_capacity(4096); xml.push_str(r#""#); xml.push('\n'); xml.push_str(r#""#); xml.push_str("\n\n"); writeln!(xml, " {}", xml_escape(title)).unwrap(); writeln!(xml, " {}", xml_escape(link)).unwrap(); writeln!( xml, " {}", xml_escape(description) ) .unwrap(); writeln!( xml, " {}", last_build.to_rfc2822() ) .unwrap(); xml.push_str(" Makenot.work\n"); for item in items { xml.push_str(" \n"); writeln!(xml, " {}", xml_escape(&item.title)).unwrap(); writeln!(xml, " {}", xml_escape(&item.link)).unwrap(); writeln!( xml, " {}", xml_escape(&item.description) ) .unwrap(); writeln!(xml, " {}", item.pub_date.to_rfc2822()).unwrap(); writeln!( xml, " {}", xml_escape(&item.guid) ) .unwrap(); xml.push_str(" \n"); } xml.push_str("\n\n"); xml } /// Render an RSS 2.0 feed with custom title, link, and description. pub fn render_feed_custom( title: &str, link: &str, description: &str, items: &[FeedItem], ) -> String { render_feed(title, link, description, items) } /// Render an RSS 2.0 feed for a single project's items. pub fn render_project_feed( project_title: &str, project_slug: &str, project_description: &str, creator_username: &str, items: &[FeedItem], base_url: &str, ) -> String { let link = format!("{base_url}/p/{project_slug}"); let description = format!("{project_description} by {creator_username}"); render_feed(project_title, &link, &description, items) } /// Render an RSS 2.0 feed for all of a creator's public items across projects. pub fn render_creator_feed( display_name: &str, username: &str, bio: &str, items: &[FeedItem], base_url: &str, ) -> String { let link = format!("{base_url}/u/{username}"); render_feed(display_name, &link, bio, items) } /// Render an RSS 2.0 feed for a project's blog posts. pub fn render_blog_feed( project_title: &str, project_slug: &str, project_description: &str, creator_username: &str, items: &[FeedItem], base_url: &str, ) -> String { let title = format!("{project_title} - Blog"); let link = format!("{base_url}/p/{project_slug}/blog"); let description = format!("Blog posts from {project_description} by {creator_username}"); render_feed(&title, &link, &description, items) } #[cfg(test)] mod tests { use super::*; use chrono::TimeZone; fn sample_items(base_url: &str) -> Vec { vec![ FeedItem { title: "Test Item".to_string(), link: format!("{base_url}/i/00000000-0000-0000-0000-000000000001"), description: "A test item".to_string(), pub_date: Utc.with_ymd_and_hms(2026, 1, 15, 12, 0, 0).unwrap(), guid: "00000000-0000-0000-0000-000000000001".to_string(), }, FeedItem { title: "Item with & chars".to_string(), link: format!("{base_url}/i/00000000-0000-0000-0000-000000000002"), description: "Description with \"quotes\"".to_string(), pub_date: Utc.with_ymd_and_hms(2026, 1, 10, 12, 0, 0).unwrap(), guid: "00000000-0000-0000-0000-000000000002".to_string(), }, ] } #[test] fn project_feed_is_valid_xml_structure() { let items = sample_items("https://makenot.work"); let xml = render_project_feed( "My Project", "my-project", "A cool project", "creator", &items, "https://makenot.work", ); assert!(xml.starts_with(r#""#)); assert!(xml.contains("")); assert!(xml.contains("")); assert!(xml.contains("My Project")); assert!(xml.contains("Makenot.work")); assert!(xml.contains("")); } #[test] fn creator_feed_is_valid_xml_structure() { let items = sample_items("https://makenot.work"); let xml = render_creator_feed( "Creator Name", "creator", "I make things", &items, "https://makenot.work", ); assert!(xml.contains("Creator Name")); assert!(xml.contains("https://makenot.work/u/creator")); } #[test] fn xml_special_chars_are_escaped() { let items = sample_items("https://makenot.work"); let xml = render_project_feed( "Test", "test", "desc", "user", &items, "https://makenot.work", ); assert!(xml.contains("<special>")); assert!(xml.contains("& chars")); assert!(xml.contains(""quotes"")); } #[test] fn empty_feed_is_valid() { let xml = render_project_feed( "Empty", "empty", "No items", "user", &[], "https://makenot.work", ); assert!(xml.contains("Empty")); assert!(!xml.contains("")); } #[test] fn blog_feed_is_valid_xml_structure() { let items = sample_items("https://makenot.work"); let xml = render_blog_feed( "My Project", "my-project", "A cool project", "creator", &items, "https://makenot.work", ); assert!(xml.starts_with(r#""#)); assert!(xml.contains("My Project - Blog")); assert!(xml.contains("https://makenot.work/p/my-project/blog")); assert!(xml.contains("creator")); assert!(xml.contains("")); } #[test] fn xml_escape_all_special_chars() { assert_eq!(xml_escape("&"), "&"); assert_eq!(xml_escape("<"), "<"); assert_eq!(xml_escape(">"), ">"); assert_eq!(xml_escape("\""), """); assert_eq!(xml_escape("'"), "'"); } #[test] fn xml_escape_combined() { assert_eq!( xml_escape("Tom & Jerry <\"friends\">"), "Tom & Jerry <"friends">" ); } #[test] fn xml_escape_no_special_chars() { assert_eq!(xml_escape("plain text 123"), "plain text 123"); } #[test] fn xml_escape_empty_string() { assert_eq!(xml_escape(""), ""); } #[test] fn xml_escape_double_ampersand() { // Ensure & is not double-escaped assert_eq!(xml_escape("&"), "&amp;"); } #[test] fn xml_escape_drops_illegal_control_bytes_keeps_tab_lf_cr() { // NUL, backspace, vertical tab, form feed, and other C0 controls are // illegal in XML 1.0 even escaped; they must be dropped, not emitted. assert_eq!(xml_escape("a\u{0}b\u{8}c\u{B}d\u{C}e\u{1F}f"), "abcdef"); // Tab / LF / CR are the three legal control chars, preserved verbatim. assert_eq!(xml_escape("x\ty\nz\r"), "x\ty\nz\r"); // A control byte adjacent to an entity char still escapes correctly. assert_eq!(xml_escape("A\u{0}&B"), "A&B"); } #[test] fn pub_date_is_rfc2822() { let date = Utc.with_ymd_and_hms(2026, 3, 15, 14, 30, 0).unwrap(); let item = FeedItem { title: "Test".to_string(), link: "https://example.com".to_string(), description: "desc".to_string(), pub_date: date, guid: "guid-1".to_string(), }; let xml = render_feed("T", "https://example.com", "D", &[item]); // RFC 2822 format: "Sun, 15 Mar 2026 14:30:00 +0000" assert!(xml.contains("Sun, 15 Mar 2026 14:30:00 +0000")); } #[test] fn last_build_date_uses_first_item() { let items = sample_items("https://example.com"); let xml = render_feed("T", "https://example.com", "D", &items); // First item is 2026-01-15 assert!(xml.contains("Thu, 15 Jan 2026 12:00:00 +0000")); } #[test] fn empty_feed_still_has_last_build_date() { let xml = render_feed("T", "https://example.com", "D", &[]); assert!(xml.contains("")); } #[test] fn feed_with_many_items() { let items: Vec = (0..50) .map(|i| FeedItem { title: format!("Item {i}"), link: format!("https://example.com/{i}"), description: format!("Description {i}"), pub_date: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(), guid: format!("guid-{i}"), }) .collect(); let xml = render_feed("Bulk", "https://example.com", "50 items", &items); // Count occurrences let count = xml.matches("").count(); assert_eq!(count, 50); } #[test] fn render_feed_custom_passes_through() { let xml = render_feed_custom("Custom", "https://custom.com", "Custom desc", &[]); assert!(xml.contains("Custom")); assert!(xml.contains("https://custom.com")); assert!(xml.contains("Custom desc")); } #[test] fn project_feed_link_format() { let xml = render_project_feed( "Proj", "my-slug", "desc", "alice", &[], "https://makenot.work", ); assert!(xml.contains("https://makenot.work/p/my-slug")); assert!(xml.contains("desc by alice")); } #[test] fn creator_feed_link_format() { let xml = render_creator_feed( "Alice", "alice", "Makes things", &[], "https://makenot.work", ); assert!(xml.contains("https://makenot.work/u/alice")); } #[test] fn blog_feed_link_and_title_format() { let xml = render_blog_feed( "My Project", "my-proj", "A project", "bob", &[], "https://makenot.work", ); assert!(xml.contains("My Project - Blog")); assert!(xml.contains("https://makenot.work/p/my-proj/blog")); assert!(xml.contains("Blog posts from A project by bob")); } #[test] fn guid_is_not_permalink() { let items = vec![FeedItem { title: "T".to_string(), link: "https://example.com".to_string(), description: "D".to_string(), pub_date: Utc::now(), guid: "unique-id".to_string(), }]; let xml = render_feed("T", "https://example.com", "D", &items); assert!(xml.contains("unique-id")); } #[test] fn special_chars_in_title_and_description_escaped_in_channel() { let xml = render_feed( "Tom & Jerry's ", "https://example.com?a=1&b=2", "A \"great\" show", &[], ); assert!(xml.contains("Tom & Jerry's <Show>")); assert!(xml.contains("https://example.com?a=1&b=2")); assert!(xml.contains("A "great" show")); } }