xdoc-rs 0.1.1

Declarative XML engine for Rust
Documentation
//! Leptos-style XML component model.
//!
//! Components are plain Rust functions. They receive typed props and optional
//! `Children`, then return any value implementing `IntoXml` or
//! `IntoXmlFragment`. Reactivity is intentionally outside the MVP.
//! Typed props are regular Rust structs owned by the component function.

use crate::builder::{DocumentBuilder, ElementBuilder};
use crate::core::{Document, XmlResult};

pub use crate::builder::{
    fragment, FragmentBuilder as Fragment, IntoXmlFragment, XmlNode as FragmentNode,
};

/// Explicit children input for component functions.
pub type Children = Fragment;

/// Converts a value into a single XML element builder.
pub trait IntoXml {
    fn into_xml(self) -> XmlResult<ElementBuilder>;
}

impl IntoXml for ElementBuilder {
    fn into_xml(self) -> XmlResult<ElementBuilder> {
        Ok(self)
    }
}

impl IntoXml for XmlResult<ElementBuilder> {
    fn into_xml(self) -> XmlResult<ElementBuilder> {
        self
    }
}

/// Builds a document from a component that returns one root element.
pub fn document(component: impl IntoXml) -> XmlResult<Document> {
    DocumentBuilder::new().root(component.into_xml()?)?.build()
}

/// Normalizes any fragment-like value into an explicit `Children` value.
pub fn children(value: impl IntoXmlFragment) -> XmlResult<Children> {
    value.into_xml_fragment()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::builder::{element, text};
    use crate::core::ErrorKind;
    use crate::writer::{to_string_compact, to_string_pretty, WriterConfig};

    fn simple_component() -> XmlResult<ElementBuilder> {
        element("Simple")?.text("value")
    }

    fn wrapper(children: Children) -> XmlResult<ElementBuilder> {
        element("Wrapper")?.child(children)
    }

    fn layout(title: impl IntoXml, children: Children) -> XmlResult<ElementBuilder> {
        element("Layout")?.child(title.into_xml()?)?.child(children)
    }

    fn pair_component() -> XmlResult<Fragment> {
        fragment()
            .child(element("First")?.text("one")?)?
            .child(element("Second")?.text("two")?)
    }

    fn item_list_from_vec(items: Vec<ItemProps>) -> XmlResult<ElementBuilder> {
        element("Items")?.child(
            items
                .into_iter()
                .map(item_component)
                .collect::<Vec<XmlResult<ElementBuilder>>>(),
        )
    }

    #[derive(Debug, Clone)]
    struct IdProps {
        value: String,
    }

    fn id_component(props: IdProps) -> XmlResult<ElementBuilder> {
        element("ID")?.text(props.value)
    }

    #[derive(Debug, Clone)]
    struct ItemProps {
        code: String,
        name: String,
        quantity: u32,
    }

    fn item_component(props: ItemProps) -> XmlResult<ElementBuilder> {
        element("Item")?
            .attr("code", props.code)?
            .attr("quantity", props.quantity)?
            .text(props.name)
    }

    #[derive(Debug, Clone)]
    struct QualifiedElementProps {
        prefix: String,
        local_name: String,
        namespace_uri: String,
        text: String,
    }

    fn qualified_component(props: QualifiedElementProps) -> XmlResult<ElementBuilder> {
        ElementBuilder::qualified(props.prefix, props.local_name, props.namespace_uri)?
            .text(props.text)
    }

    #[test]
    fn component_contracts_into_xml_accepts_element_builder() -> XmlResult<()> {
        let root = element("Root")?.text("ok")?.into_xml()?;
        let document = document(root)?;

        assert_eq!(to_string_compact(&document)?, "<Root>ok</Root>");
        Ok(())
    }

    #[test]
    fn into_xml_accepts_component_result() -> XmlResult<()> {
        let document = document(simple_component())?;

        assert_eq!(to_string_compact(&document)?, "<Simple>value</Simple>");
        Ok(())
    }

    #[test]
    fn component_contracts_into_xml_fragment_is_available() -> XmlResult<()> {
        let fragment = text("hello").into_xml_fragment()?;
        let document = document(element("Root")?.child(fragment)?)?;

        assert_eq!(to_string_compact(&document)?, "<Root>hello</Root>");
        Ok(())
    }

    #[test]
    fn component_contracts_children_are_explicit_fragments() -> XmlResult<()> {
        let children = children(fragment().child(element("Child")?.text("value")?)?)?;
        let document = document(wrapper(children)?)?;

        assert_eq!(
            to_string_compact(&document)?,
            "<Wrapper><Child>value</Child></Wrapper>"
        );
        Ok(())
    }

    #[test]
    fn component_contracts_materialize_with_builder_backend() -> XmlResult<()> {
        let root = element("Root")?.child(simple_component())?;
        let document = DocumentBuilder::new().root(root)?.build()?;

        assert_eq!(
            to_string_compact(&document)?,
            "<Root><Simple>value</Simple></Root>"
        );
        Ok(())
    }

    #[test]
    fn component_props_create_xml_with_typed_props() -> XmlResult<()> {
        let props = ItemProps {
            code: "A1".to_owned(),
            name: "Widget".to_owned(),
            quantity: 3,
        };
        let document = document(item_component(props)?)?;

        assert_eq!(
            to_string_compact(&document)?,
            "<Item code=\"A1\" quantity=\"3\">Widget</Item>"
        );
        Ok(())
    }

    #[test]
    fn component_props_can_build_text_from_props() -> XmlResult<()> {
        let props = IdProps {
            value: "INV-1".to_owned(),
        };
        let document = document(id_component(props)?)?;

        assert_eq!(to_string_compact(&document)?, "<ID>INV-1</ID>");
        Ok(())
    }

    #[test]
    fn component_props_can_build_qualified_names() -> XmlResult<()> {
        let props = QualifiedElementProps {
            prefix: "doc".to_owned(),
            local_name: "Title".to_owned(),
            namespace_uri: "urn:doc".to_owned(),
            text: "Report".to_owned(),
        };
        let document = document(qualified_component(props)?)?;

        assert_eq!(
            to_string_compact(&document)?,
            "<doc:Title>Report</doc:Title>"
        );
        Ok(())
    }

    #[test]
    fn component_props_invalid_names_return_xml_error() {
        let props = QualifiedElementProps {
            prefix: "doc".to_owned(),
            local_name: "1Invalid".to_owned(),
            namespace_uri: "urn:doc".to_owned(),
            text: "Report".to_owned(),
        };
        let error = qualified_component(props).expect_err("invalid local name must fail");

        assert_eq!(error.kind(), &ErrorKind::InvalidName);
    }

    #[test]
    fn children_component_wraps_explicit_children() -> XmlResult<()> {
        let content = children(
            fragment()
                .child(element("A")?.text("one")?)?
                .child(element("B")?.text("two")?)?,
        )?;
        let document = document(wrapper(content)?)?;

        assert_eq!(
            to_string_compact(&document)?,
            "<Wrapper><A>one</A><B>two</B></Wrapper>"
        );
        Ok(())
    }

    #[test]
    fn children_fragment_component_returns_multiple_nodes() -> XmlResult<()> {
        let document = document(element("Root")?.child(pair_component()?)?)?;

        assert_eq!(
            to_string_compact(&document)?,
            "<Root><First>one</First><Second>two</Second></Root>"
        );
        Ok(())
    }

    #[test]
    fn children_components_compose_from_plain_rust() -> XmlResult<()> {
        let title = id_component(IdProps {
            value: "T-1".to_owned(),
        })?;
        let content = children(pair_component()?)?;
        let document = document(layout(title, content)?)?;

        assert_eq!(
            to_string_compact(&document)?,
            "<Layout><ID>T-1</ID><First>one</First><Second>two</Second></Layout>"
        );
        Ok(())
    }

    #[test]
    fn component_collections_option_can_include_content() -> XmlResult<()> {
        let include = true;
        let optional_child = include.then(simple_component);
        let document = document(element("Root")?.child(optional_child.transpose()?)?)?;

        assert_eq!(
            to_string_compact(&document)?,
            "<Root><Simple>value</Simple></Root>"
        );
        Ok(())
    }

    #[test]
    fn component_collections_option_can_omit_content() -> XmlResult<()> {
        let include = false;
        let optional_child = include.then(simple_component);
        let document = document(element("Root")?.child(optional_child.transpose()?)?)?;

        assert_eq!(to_string_compact(&document)?, "<Root/>");
        Ok(())
    }

    #[test]
    fn component_collections_vec_represents_list_nodes() -> XmlResult<()> {
        let items = vec![
            ItemProps {
                code: "a".to_owned(),
                name: "Alpha".to_owned(),
                quantity: 1,
            },
            ItemProps {
                code: "b".to_owned(),
                name: "Beta".to_owned(),
                quantity: 2,
            },
        ];
        let document = document(item_list_from_vec(items)?)?;

        assert_eq!(
            to_string_compact(&document)?,
            "<Items><Item code=\"a\" quantity=\"1\">Alpha</Item><Item code=\"b\" quantity=\"2\">Beta</Item></Items>"
        );
        Ok(())
    }

    #[test]
    fn component_collections_iterators_preserve_order() -> XmlResult<()> {
        let document = document(
            element("Items")?.children(
                ["a", "b", "c"]
                    .into_iter()
                    .map(|code| element("Item")?.attr("code", code)?.text(code)),
            )?,
        )?;

        assert_eq!(
            to_string_compact(&document)?,
            "<Items><Item code=\"a\">a</Item><Item code=\"b\">b</Item><Item code=\"c\">c</Item></Items>"
        );
        Ok(())
    }

    #[test]
    fn component_writer_serializes_simple_component_compact() -> XmlResult<()> {
        let document = document(simple_component())?;

        assert_eq!(to_string_compact(&document)?, "<Simple>value</Simple>");
        Ok(())
    }

    #[test]
    fn component_writer_serializes_props_component_compact() -> XmlResult<()> {
        let document = document(item_component(ItemProps {
            code: "x".to_owned(),
            name: "Xray".to_owned(),
            quantity: 7,
        })?)?;

        assert_eq!(
            to_string_compact(&document)?,
            "<Item code=\"x\" quantity=\"7\">Xray</Item>"
        );
        Ok(())
    }

    #[test]
    fn component_writer_serializes_children_component_compact() -> XmlResult<()> {
        let content = children(pair_component()?)?;
        let document = document(wrapper(content)?)?;

        assert_eq!(
            to_string_compact(&document)?,
            "<Wrapper><First>one</First><Second>two</Second></Wrapper>"
        );
        Ok(())
    }

    #[test]
    fn component_writer_serializes_list_in_stable_order() -> XmlResult<()> {
        let document = document(
            element("Items")?.children(
                ["c", "a", "b"]
                    .into_iter()
                    .map(|code| element("Item")?.attr("code", code)?.text(code)),
            )?,
        )?;

        let first = to_string_compact(&document)?;
        let second = to_string_compact(&document)?;

        assert_eq!(first, second);
        assert_eq!(
            first,
            "<Items><Item code=\"c\">c</Item><Item code=\"a\">a</Item><Item code=\"b\">b</Item></Items>"
        );
        Ok(())
    }

    #[test]
    fn component_writer_pretty_output_is_stable() -> XmlResult<()> {
        let content = children(pair_component()?)?;
        let document = document(wrapper(content)?)?;

        let xml = to_string_pretty(&document, WriterConfig::pretty())?;

        assert_eq!(
            xml,
            "<Wrapper>\n  <First>one</First>\n  <Second>two</Second>\n</Wrapper>"
        );
        Ok(())
    }
}