//! RSS 2.0 feed generation //! //! Simple XML builder for creator and project RSS feeds. //! No external dependency needed — just format strings. //! //! See also: `/docs/guide/rss` 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. fn xml_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } /// 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(|i| i.pub_date) .unwrap_or_else(Utc::now); let mut xml = String::with_capacity(4096); xml.push_str(r#""#); xml.push('\n'); xml.push_str(r#""#); xml.push_str("\n\n"); xml.push_str(&format!(" {}\n", xml_escape(title))); xml.push_str(&format!(" {}\n", xml_escape(link))); xml.push_str(&format!( " {}\n", xml_escape(description) )); xml.push_str(&format!( " {}\n", last_build.to_rfc2822() )); xml.push_str(" Makenot.work\n"); for item in items { xml.push_str(" \n"); xml.push_str(&format!(" {}\n", xml_escape(&item.title))); xml.push_str(&format!(" {}\n", xml_escape(&item.link))); xml.push_str(&format!( " {}\n", xml_escape(&item.description) )); xml.push_str(&format!( " {}\n", item.pub_date.to_rfc2822() )); xml.push_str(&format!( " {}\n", xml_escape(&item.guid) )); 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!("{}/p/{}", base_url, project_slug); let description = format!("{} by {}", project_description, 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!("{}/u/{}", base_url, 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!("{} - Blog", project_title); let link = format!("{}/p/{}/blog", base_url, project_slug); let description = format!( "Blog posts from {} by {}", project_description, 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!("{}/i/00000000-0000-0000-0000-000000000001", base_url), 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!("{}/i/00000000-0000-0000-0000-000000000002", base_url), 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 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")); } }