xdoc-rs 0.1.1

Declarative XML engine for Rust
Documentation
use std::collections::BTreeMap;

use crate::core::{Document, ErrorKind, NodeId, NodeKind, QName, XmlError, XmlResult};

/// Controls which attributes are treated as XML IDs by signature references.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum IdAttributePolicy {
    /// Recognizes unqualified `Id`, unqualified `ID`, and `xml:id`.
    #[default]
    Standard,
    /// Recognizes only the provided attribute names.
    Custom(Vec<QName>),
}

/// Finds a unique element by XML ID.
pub fn find_element_by_id(
    document: &Document,
    id: &str,
    policy: &IdAttributePolicy,
) -> XmlResult<NodeId> {
    let index = collect_ids(document, policy)?;
    match index.get(id).copied() {
        Some(node) => Ok(node),
        None => Err(XmlError::new(
            ErrorKind::Signature,
            format!("no element found with XML ID `{id}`"),
        )),
    }
}

/// Validates that all IDs selected by the policy are unique.
pub fn ensure_unique_ids(document: &Document, policy: &IdAttributePolicy) -> XmlResult<()> {
    collect_ids(document, policy).map(|_| ())
}

fn collect_ids(
    document: &Document,
    policy: &IdAttributePolicy,
) -> XmlResult<BTreeMap<String, NodeId>> {
    let mut ids = BTreeMap::new();
    if let Some(root) = document.root() {
        collect_ids_from_node(document, root, policy, &mut ids)?;
    }
    Ok(ids)
}

fn collect_ids_from_node(
    document: &Document,
    node: NodeId,
    policy: &IdAttributePolicy,
    ids: &mut BTreeMap<String, NodeId>,
) -> XmlResult<()> {
    let NodeKind::Element(element) = document.node(node)?.kind() else {
        return Ok(());
    };

    for attribute in element.attributes() {
        if !policy.matches(attribute.name()) {
            continue;
        }
        if let Some(previous) = ids.insert(attribute.value().to_owned(), node) {
            return Err(XmlError::new(
                ErrorKind::Signature,
                format!(
                    "duplicate XML ID `{}` found at nodes {} and {}",
                    attribute.value(),
                    previous.index(),
                    node.index()
                ),
            ));
        }
    }

    for child in element.children() {
        collect_ids_from_node(document, *child, policy, ids)?;
    }

    Ok(())
}

impl IdAttributePolicy {
    fn matches(&self, name: &QName) -> bool {
        match self {
            Self::Standard => is_standard_id_name(name),
            Self::Custom(names) => names.iter().any(|candidate| candidate == name),
        }
    }
}

pub(crate) fn is_id_attribute_name(policy: &IdAttributePolicy, name: &QName) -> bool {
    policy.matches(name)
}

fn is_standard_id_name(name: &QName) -> bool {
    let is_unqualified = name.prefix().is_none() && name.namespace_uri().is_none();
    if is_unqualified && matches!(name.local(), "Id" | "ID") {
        return true;
    }

    name.prefix().map(|prefix| prefix.as_str()) == Some("xml")
        && name.namespace_uri().map(|uri| uri.as_str()) == Some(crate::core::XML_NAMESPACE_URI)
        && name.local() == "id"
}

#[cfg(test)]
mod tests {
    use crate::core::{Attribute, XML_NAMESPACE_URI};

    use super::*;

    #[test]
    fn signature_ids_find_standard_id_attributes() -> XmlResult<()> {
        let mut document = Document::new();
        let root = document.add_root_element(QName::new("Root")?)?;
        let child = document.add_element(root, QName::new("Child")?)?;
        let xml_id = document.add_element(root, QName::new("XmlId")?)?;
        document.add_attribute(root, Attribute::new(QName::new("Id")?, "root-id"))?;
        document.add_attribute(child, Attribute::new(QName::new("ID")?, "child-id"))?;
        document.add_attribute(
            xml_id,
            Attribute::new(QName::qualified("xml", "id", XML_NAMESPACE_URI)?, "xml-id"),
        )?;

        assert_eq!(
            find_element_by_id(&document, "root-id", &IdAttributePolicy::Standard)?,
            root
        );
        assert_eq!(
            find_element_by_id(&document, "child-id", &IdAttributePolicy::Standard)?,
            child
        );
        assert_eq!(
            find_element_by_id(&document, "xml-id", &IdAttributePolicy::Standard)?,
            xml_id
        );

        Ok(())
    }

    #[test]
    fn signature_ids_support_custom_policy() -> XmlResult<()> {
        let mut document = Document::new();
        let root = document.add_root_element(QName::new("Root")?)?;
        document.add_attribute(root, Attribute::new(QName::new("code")?, "A1"))?;

        assert_eq!(
            find_element_by_id(
                &document,
                "A1",
                &IdAttributePolicy::Custom(vec![QName::new("code")?])
            )?,
            root
        );
        assert!(find_element_by_id(&document, "A1", &IdAttributePolicy::Standard).is_err());

        Ok(())
    }

    #[test]
    fn signature_ids_reject_duplicates() -> XmlResult<()> {
        let mut document = Document::new();
        let root = document.add_root_element(QName::new("Root")?)?;
        let first = document.add_element(root, QName::new("First")?)?;
        let second = document.add_element(root, QName::new("Second")?)?;
        document.add_attribute(first, Attribute::new(QName::new("Id")?, "same"))?;
        document.add_attribute(second, Attribute::new(QName::new("ID")?, "same"))?;

        let error = ensure_unique_ids(&document, &IdAttributePolicy::Standard)
            .expect_err("duplicate IDs must fail");

        assert_eq!(error.kind(), &ErrorKind::Signature);
        assert!(error.message().contains("duplicate XML ID"));

        Ok(())
    }
}