rs-wikipages2struct 0.1.0

Converts Wikipedia pages into Rust structs.
Documentation
use std::io;

use io::Read;

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "enable-graphql", derive(async_graphql::SimpleObject))]
pub struct Page {
    #[serde(rename = "title")]
    pub title: Option<String>,

    #[serde(rename = "ns")]
    pub namespace: Option<String>,

    #[serde(rename = "id")]
    pub id: Option<String>,

    #[serde(rename = "redirect")]
    pub redirect: Option<Redirect>,

    #[serde(rename = "restrictions")]
    pub restrictions: Option<String>,

    #[serde(rename = "revision")]
    pub revision: Option<Revision>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "enable-graphql", derive(async_graphql::SimpleObject))]
pub struct Redirect {
    #[serde(rename = "@title")]
    pub title: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "enable-graphql", derive(async_graphql::SimpleObject))]
pub struct Revision {
    #[serde(rename = "id")]
    pub id: Option<String>,

    #[serde(rename = "parentid")]
    pub parent_id: Option<String>,

    #[serde(rename = "timestamp")]
    pub timestamp: Option<String>,

    #[serde(rename = "comment")]
    pub comment: Option<String>,

    #[serde(rename = "origin")]
    pub origin: Option<String>,

    #[serde(rename = "model")]
    pub model: Option<String>,

    #[serde(rename = "format")]
    pub format: Option<String>,

    #[serde(rename = "text")]
    pub text: Option<String>,

    #[serde(rename = "sha1")]
    pub sha1: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename = "container")]
pub struct Container {
    #[serde(rename = "page")]
    pub pages: Vec<Page>,
}

pub const CONTAINER_PREFIX: &str = "<container>";
pub const CONTAINER_SUFFIX: &str = "</container>";

impl Container {
    pub fn from_pages_str(s: &str) -> Result<Self, io::Error> {
        let ps = Read::chain(CONTAINER_PREFIX.as_bytes(), s.as_bytes());
        let pss = Read::chain(ps, CONTAINER_SUFFIX.as_bytes());
        quick_xml::de::from_reader(pss).map_err(io::Error::other)
    }

    pub fn into_pages(self) -> Vec<Page> {
        self.pages
    }
}

pub fn xmlpages2pages(xpages: &str) -> Result<Vec<Page>, io::Error> {
    Container::from_pages_str(xpages).map(|c| c.into_pages())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io;

    /// A tiny helper that builds a `Container` from a string that already contains
    /// one or more `<page>…</page>` fragments.
    fn make_container(input: &str) -> io::Result<Container> {
        Container::from_pages_str(input)
    }

    #[test]
    fn test_container_parsing_single_page() {
        // The XML string you hand‑in **does not** include the outer <container> tags –
        // the helper will add them for you.
        let xml = r#"
            <page>
                <title>Test Page</title>
                <id>42</id>
                <restrictions>none</restrictions>
                <revision>
                    <id>1000</id>
                    <timestamp>2025-08-10T12:34:56Z</timestamp>
                    <minor/>
                    <comment>Initial revision</comment>
                    <text>Page content goes here.</text>
                </revision>
            </page>
        "#;

        let container = make_container(xml).expect("Failed to parse container");
        assert_eq!(container.pages.len(), 1);

        let page = &container.pages[0];
        assert_eq!(page.title.as_deref(), Some("Test Page"));
        assert_eq!(page.id.as_deref(), Some("42"));
        assert_eq!(page.restrictions.as_deref(), Some("none"));

        let rev = page.revision.as_ref().expect("revision missing");
        assert_eq!(rev.id.as_deref(), Some("1000"));
        assert_eq!(rev.timestamp.as_deref(), Some("2025-08-10T12:34:56Z"));
        assert_eq!(rev.comment.as_deref(), Some("Initial revision"));
        assert_eq!(rev.text.as_deref(), Some("Page content goes here."));
    }

    #[test]
    fn test_container_parsing_multiple_pages() {
        // Two page fragments – the helper will wrap them with <container>.
        let xml = r#"
            <page>
                <title>First</title>
                <id>1</id>
                <revision><id>10</id><timestamp>2025-01-01T00:00:00Z</timestamp></revision>
            </page>
            <page>
                <title>Second</title>
                <id>2</id>
                <revision><id>20</id><timestamp>2025-02-01T00:00:00Z</timestamp></revision>
            </page>
        "#;

        let container = make_container(xml).expect("Failed to parse container");
        assert_eq!(container.pages.len(), 2);

        // Check the first page
        let p1 = &container.pages[0];
        assert_eq!(p1.title.as_deref(), Some("First"));
        assert_eq!(p1.id.as_deref(), Some("1"));
        let r1 = p1.revision.as_ref().expect("revision missing");
        assert_eq!(r1.id.as_deref(), Some("10"));
        assert_eq!(r1.timestamp.as_deref(), Some("2025-01-01T00:00:00Z"));

        // Check the second page
        let p2 = &container.pages[1];
        assert_eq!(p2.title.as_deref(), Some("Second"));
        assert_eq!(p2.id.as_deref(), Some("2"));
        let r2 = p2.revision.as_ref().expect("revision missing");
        assert_eq!(r2.id.as_deref(), Some("20"));
        assert_eq!(r2.timestamp.as_deref(), Some("2025-02-01T00:00:00Z"));
    }

    #[test]
    fn test_container_parsing_error() {
        // Malformed XML – missing the closing '>' on the second element.
        let bad_xml = r#"<page><title>Oops</title></page><invalid"#;
        assert!(make_container(bad_xml).is_err());
    }
}