openbim-gaeb 0.1.2

Pure-Rust GAEB DA XML reading, inspection, and lossless editing.
Documentation
use openbim_gaeb::{Document, Error};

const XML_BODY: &[u8] = br#"<?xml version="1.0" encoding="UTF-8"?>
<!-- vendor extension must survive -->
<GAEB xmlns="http://www.gaeb.de/GAEB_DA_XML/DA83/3.3" xmlns:vendor="urn:vendor">
  <GAEBInfo><Version>3.3</Version><VersDate>2023-01</VersDate></GAEBInfo>
  <Award><DP>83</DP><BoQ><BoQBody>
    <BoQCtgy ID="cat-1" RNoPart="01"><LblTx>Earthworks</LblTx><Itemlist>
      <Item ID="item-1" RNoPart="0010"><Qty>1.000</Qty><QU>m3</QU><UP>12.50</UP><IT>12.50</IT><Description><CompleteText><Text><p><span>Excavate &amp; haul</span></p></Text></CompleteText></Description><vendor:opaque answer="42"/></Item>
    </Itemlist></BoQCtgy>
  </BoQBody></BoQ></Award>
</GAEB>
"#;

fn xml() -> Vec<u8> {
    let mut bytes = vec![0xEF, 0xBB, 0xBF];
    bytes.extend_from_slice(XML_BODY);
    bytes
}

#[test]
fn extracts_common_boq_item_view() {
    let bytes = xml();
    let document = Document::parse(&bytes).unwrap();
    let item = document.item("item-1").unwrap();
    assert_eq!(item.outline_number.as_deref(), Some("0010"));
    assert_eq!(item.quantity.as_deref(), Some("1.000"));
    assert_eq!(item.unit.as_deref(), Some("m3"));
    assert_eq!(item.unit_price.as_deref(), Some("12.50"));
    assert_eq!(item.total_price.as_deref(), Some("12.50"));
    assert_eq!(item.description.as_deref(), Some("Excavate & haul"));
    assert_eq!(item.category_path.len(), 1);
    assert_eq!(item.category_path[0].id.as_deref(), Some("cat-1"));
    assert_eq!(item.category_path[0].label.as_deref(), Some("Earthworks"));
}

#[test]
fn unchanged_write_is_byte_identical_including_bom_and_unknown_xml() {
    let bytes = xml();
    let document = Document::parse(&bytes).unwrap();
    assert_eq!(document.as_bytes(), bytes);
    let mut output = Vec::new();
    document.write_to(&mut output).unwrap();
    assert_eq!(output, bytes);
    assert_eq!(document.into_bytes(), bytes);
}

#[test]
fn rejects_non_xml_and_non_gaeb_documents() {
    assert!(matches!(Document::parse(b"PK\x03\x04"), Err(Error::NotXml)));
    assert!(matches!(Document::parse(b"<root/>"), Err(Error::NotGaeb)));
}

#[test]
fn ignores_vendor_elements_that_reuse_gaeb_local_names() {
    let xml = br#"<g:GAEB xmlns:g="http://www.gaeb.de/GAEB_DA_XML/DA86/3.3" xmlns:v="urn:vendor"><g:GAEBInfo><g:Version>3.3</g:Version><v:Version>9.9</v:Version></g:GAEBInfo><g:Award><g:DP>86</g:DP><v:DP>99</v:DP><v:Item ID="spoof"><v:Qty>999</v:Qty></v:Item><g:Item ID="real"><v:Qty>888</v:Qty><g:Qty>1.5</g:Qty><v:Description>spoof</v:Description></g:Item></g:Award></g:GAEB>"#;
    let document = Document::parse(xml).unwrap();

    assert_eq!(document.items().len(), 1);
    assert!(document.item("spoof").is_none());
    let item = document.item("real").unwrap();
    assert_eq!(item.quantity.as_deref(), Some("1.5"));
    assert_eq!(item.description, None);
    assert_eq!(document.metadata().version_text.as_deref(), Some("3.3"));
    assert_eq!(document.metadata().phase_code.as_deref(), Some("86"));
}

#[test]
fn namespaced_vendor_id_does_not_become_a_gaeb_item_id() {
    let xml = br#"<GAEB xmlns="http://www.gaeb.de/GAEB_DA_XML/DA86/3.3" xmlns:v="urn:vendor"><GAEBInfo><Version>3.3</Version></GAEBInfo><Award><DP>86</DP><Item v:ID="spoof"><Qty>1</Qty></Item></Award></GAEB>"#;
    let document = Document::parse(xml).unwrap();

    assert_eq!(document.items().len(), 1);
    assert_eq!(document.items()[0].id, "");
    assert!(document.item("spoof").is_none());
}

#[test]
fn rejects_spoofed_namespaces_and_extra_root_elements() {
    let spoof = br#"<GAEB xmlns="http://www.gaeb.de/GAEB_DA_XML/not-gaeb"><GAEBInfo><Version>3.3</Version></GAEBInfo></GAEB>"#;
    assert!(matches!(Document::parse(spoof), Err(Error::NotGaeb)));

    let multiple = br#"<GAEB xmlns="http://www.gaeb.de/GAEB_DA_XML/DA83/3.3"><GAEBInfo><Version>3.3</Version></GAEBInfo></GAEB><vendor/>"#;
    assert!(matches!(Document::parse(multiple), Err(Error::Xml(_))));

    let trailing_text = br#"<GAEB xmlns="http://www.gaeb.de/GAEB_DA_XML/DA83/3.3"><GAEBInfo><Version>3.3</Version></GAEBInfo></GAEB>not-xml"#;
    assert!(matches!(Document::parse(trailing_text), Err(Error::Xml(_))));
}

#[test]
fn rejects_undeclared_namespace_prefixes() {
    let xml = br#"<GAEB xmlns="http://www.gaeb.de/GAEB_DA_XML/DA83/3.3"><GAEBInfo><Version>3.3</Version></GAEBInfo><x:Item ID="bad"/></GAEB>"#;
    assert!(matches!(Document::parse(xml), Err(Error::Xml(_))));
}

#[test]
fn rejects_duplicate_attributes_instead_of_selecting_one() {
    let xml = br#"<GAEB xmlns="http://www.gaeb.de/GAEB_DA_XML/DA86/3.3"><Award><Item ID="a" ID="b"><Qty>1</Qty></Item></Award></GAEB>"#;
    assert!(matches!(Document::parse(xml), Err(Error::Xml(_))));
}

#[test]
fn rejects_misplaced_or_repeated_xml_declarations() {
    let nested =
        br#"<GAEB xmlns="http://www.gaeb.de/GAEB_DA_XML/DA86/3.3"><?xml version="1.0"?></GAEB>"#;
    assert!(matches!(Document::parse(nested), Err(Error::Xml(_))));

    let repeated = br#"<?xml version="1.0"?><?xml version="1.0"?><GAEB xmlns="http://www.gaeb.de/GAEB_DA_XML/DA86/3.3"/>"#;
    assert!(matches!(Document::parse(repeated), Err(Error::Xml(_))));
}

#[test]
fn local_byte_corruptions_and_truncations_return_without_panicking() {
    let seed = br#"<?xml version="1.0"?><GAEB xmlns="http://www.gaeb.de/GAEB_DA_XML/DA86/3.3"><GAEBInfo><Version>3.3</Version></GAEBInfo><Award><DP>86</DP><Item ID="i"><Qty><![CDATA[1.5]]></Qty></Item></Award></GAEB>"#;

    for end in 0..seed.len() {
        let _ = Document::parse(&seed[..end]);
    }
    for index in 0..seed.len() {
        for replacement in [0, b'<', b'&', 0xff] {
            let mut candidate = seed.to_vec();
            candidate[index] = replacement;
            let _ = Document::parse(candidate);
        }
    }
}

#[test]
fn malformed_xml_is_an_error() {
    assert!(matches!(
        Document::parse(br#"<GAEB xmlns="http://www.gaeb.de/GAEB_DA_XML/DA83/3.3">"#),
        Err(Error::Xml(_))
    ));
}