rjango 0.1.1

A full-stack Rust backend framework inspired by Django
Documentation
use std::fmt::Write as _;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Feed {
    pub title: String,
    pub link: String,
    pub description: String,
    pub items: Vec<FeedItem>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FeedItem {
    pub title: String,
    pub link: String,
    pub description: String,
    pub pub_date: Option<String>,
}

impl Feed {
    #[must_use]
    pub fn to_rss(&self) -> String {
        let mut xml = String::from(
            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<rss version=\"2.0\"><channel>",
        );
        append_text_element(&mut xml, "title", &self.title);
        append_text_element(&mut xml, "link", &self.link);
        append_text_element(&mut xml, "description", &self.description);

        for item in &self.items {
            xml.push_str("<item>");
            append_text_element(&mut xml, "title", &item.title);
            append_text_element(&mut xml, "link", &item.link);
            append_text_element(&mut xml, "description", &item.description);
            if let Some(pub_date) = &item.pub_date {
                append_text_element(&mut xml, "pubDate", pub_date);
            }
            xml.push_str("</item>");
        }

        xml.push_str("</channel></rss>");
        xml
    }

    #[must_use]
    pub fn to_atom(&self) -> String {
        let mut xml = String::from(
            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<feed xmlns=\"http://www.w3.org/2005/Atom\">",
        );
        append_text_element(&mut xml, "title", &self.title);
        let _ = write!(xml, "<link href=\"{}\" />", escape_xml(&self.link));
        append_text_element(&mut xml, "id", &self.link);
        append_text_element(&mut xml, "subtitle", &self.description);
        append_text_element(&mut xml, "updated", self.updated_timestamp());

        for item in &self.items {
            xml.push_str("<entry>");
            append_text_element(&mut xml, "title", &item.title);
            let _ = write!(xml, "<link href=\"{}\" />", escape_xml(&item.link));
            append_text_element(&mut xml, "id", &item.link);
            append_text_element(&mut xml, "summary", &item.description);
            append_text_element(
                &mut xml,
                "updated",
                item.pub_date.as_deref().unwrap_or(self.updated_timestamp()),
            );
            xml.push_str("</entry>");
        }

        xml.push_str("</feed>");
        xml
    }

    fn updated_timestamp(&self) -> &str {
        self.items
            .iter()
            .find_map(|item| item.pub_date.as_deref())
            .unwrap_or("1970-01-01T00:00:00Z")
    }
}

fn append_text_element(xml: &mut String, tag: &str, value: &str) {
    let _ = write!(xml, "<{tag}>{}</{tag}>", escape_xml(value));
}

fn escape_xml(input: &str) -> String {
    let mut escaped = String::with_capacity(input.len());
    for character in input.chars() {
        match character {
            '&' => escaped.push_str("&amp;"),
            '<' => escaped.push_str("&lt;"),
            '>' => escaped.push_str("&gt;"),
            '"' => escaped.push_str("&quot;"),
            '\'' => escaped.push_str("&apos;"),
            _ => escaped.push(character),
        }
    }
    escaped
}

#[cfg(test)]
mod tests {
    use super::{Feed, FeedItem};

    fn sample_feed() -> Feed {
        Feed {
            title: "Rjango Updates".to_string(),
            link: "https://example.com/feed?lang=en&format=rss".to_string(),
            description: "Latest <framework> news & releases".to_string(),
            items: vec![
                FeedItem {
                    title: "Version 0.1".to_string(),
                    link: "https://example.com/posts/1".to_string(),
                    description: "First release".to_string(),
                    pub_date: Some("Fri, 01 Mar 2024 10:00:00 GMT".to_string()),
                },
                FeedItem {
                    title: "Escaping & validation".to_string(),
                    link: "https://example.com/posts/2?draft=false&format=atom".to_string(),
                    description: "Safer <xml> output".to_string(),
                    pub_date: None,
                },
            ],
        }
    }

    #[test]
    fn rss_generation_includes_channel_metadata() {
        let rss = sample_feed().to_rss();

        assert!(rss.contains("<rss version=\"2.0\">"));
        assert!(rss.contains("<title>Rjango Updates</title>"));
        assert!(
            rss.contains("<description>Latest &lt;framework&gt; news &amp; releases</description>")
        );
    }

    #[test]
    fn rss_generation_includes_items_and_optional_pub_dates() {
        let rss = sample_feed().to_rss();

        assert_eq!(rss.matches("<item>").count(), 2);
        assert!(rss.contains("<pubDate>Fri, 01 Mar 2024 10:00:00 GMT</pubDate>"));
        assert!(rss.contains("<title>Escaping &amp; validation</title>"));
    }

    #[test]
    fn atom_generation_includes_required_elements() {
        let atom = sample_feed().to_atom();

        assert!(atom.contains("<feed xmlns=\"http://www.w3.org/2005/Atom\">"));
        assert!(atom.contains("<subtitle>Latest &lt;framework&gt; news &amp; releases</subtitle>"));
        assert!(atom.contains("<updated>Fri, 01 Mar 2024 10:00:00 GMT</updated>"));
    }

    #[test]
    fn atom_generation_escapes_attribute_values_and_entry_content() {
        let atom = sample_feed().to_atom();

        assert!(atom.contains("<link href=\"https://example.com/feed?lang=en&amp;format=rss\" />"));
        assert!(
            atom.contains(
                "<link href=\"https://example.com/posts/2?draft=false&amp;format=atom\" />"
            )
        );
        assert!(atom.contains("<summary>Safer &lt;xml&gt; output</summary>"));
    }

    #[test]
    fn atom_generation_falls_back_to_epoch_when_no_pub_dates_exist() {
        let feed = Feed {
            title: "Empty dates".to_string(),
            link: "https://example.com/empty".to_string(),
            description: "No dated entries".to_string(),
            items: vec![FeedItem {
                title: "Undated".to_string(),
                link: "https://example.com/empty/1".to_string(),
                description: "Still valid".to_string(),
                pub_date: None,
            }],
        };

        let atom = feed.to_atom();
        assert!(atom.contains("<updated>1970-01-01T00:00:00Z</updated>"));
    }
}