use crate::core::{AsxError, ErrorCode, ErrorContext, Result, escape_xml};
use roxmltree::Document;
pub const SBDH_NAMESPACE: &str =
"http://www.unece.org/cefact/namespaces/StandardBusinessDocumentHeader";
const HEADER_CLOSE_TAG: &[u8] = b"</StandardBusinessDocumentHeader>";
const DOCUMENT_CLOSE_TAG: &[u8] = b"</StandardBusinessDocument>";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SbdhParty {
pub identifier: String,
pub authority: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SbdhDocumentIdentification {
pub standard: String,
pub type_version: String,
pub instance_identifier: String,
pub r#type: String,
pub multiple_type: bool,
pub creation_date_and_time: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SbdhHeader {
pub header_version: String,
pub sender: SbdhParty,
pub receiver: SbdhParty,
pub document_identification: SbdhDocumentIdentification,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StandardBusinessDocument {
pub header: SbdhHeader,
pub payload: Vec<u8>,
}
impl StandardBusinessDocument {
pub fn wrap(&self) -> Result<Vec<u8>> {
let h = &self.header;
let di = &h.document_identification;
let multiple_type = if di.multiple_type { "true" } else { "false" };
let xml = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<StandardBusinessDocument xmlns="{ns}">
<StandardBusinessDocumentHeader>
<HeaderVersion>{hv}</HeaderVersion>
<Sender>
<Identifier Authority="{sender_auth}">{sender_id}</Identifier>
</Sender>
<Receiver>
<Identifier Authority="{receiver_auth}">{receiver_id}</Identifier>
</Receiver>
<DocumentIdentification>
<Standard>{standard}</Standard>
<TypeVersion>{type_version}</TypeVersion>
<InstanceIdentifier>{instance_id}</InstanceIdentifier>
<Type>{doc_type}</Type>
<MultipleType>{multiple_type}</MultipleType>
<CreationDateAndTime>{created_at}</CreationDateAndTime>
</DocumentIdentification>
</StandardBusinessDocumentHeader>
{payload}
</StandardBusinessDocument>"#,
ns = SBDH_NAMESPACE,
hv = escape_xml(&h.header_version),
sender_auth = escape_xml(&h.sender.authority),
sender_id = escape_xml(&h.sender.identifier),
receiver_auth = escape_xml(&h.receiver.authority),
receiver_id = escape_xml(&h.receiver.identifier),
standard = escape_xml(&di.standard),
type_version = escape_xml(&di.type_version),
instance_id = escape_xml(&di.instance_identifier),
doc_type = escape_xml(&di.r#type),
multiple_type = multiple_type,
created_at = escape_xml(&di.creation_date_and_time),
payload = std::str::from_utf8(&self.payload).map_err(|_| {
AsxError::new(
ErrorCode::InvalidInput,
"SBDH payload is not valid UTF-8",
ErrorContext::new("sbdh_wrap"),
)
})?,
);
Ok(xml.into_bytes())
}
pub fn unwrap(bytes: &[u8]) -> Result<Self> {
let ctx = || ErrorContext::new("sbdh_unwrap");
let header_end_pos = find_subsequence(bytes, HEADER_CLOSE_TAG).ok_or_else(|| {
AsxError::new(
ErrorCode::ParseFailed,
"SBDH missing </StandardBusinessDocumentHeader>",
ctx(),
)
})?;
let header = parse_sbdh_header(bytes, ctx)?;
let after_header = &bytes[header_end_pos + HEADER_CLOSE_TAG.len()..];
let payload_end = find_subsequence(after_header, DOCUMENT_CLOSE_TAG).ok_or_else(|| {
AsxError::new(
ErrorCode::ParseFailed,
"SBDH missing </StandardBusinessDocument>",
ctx(),
)
})?;
let payload_slice = &after_header[..payload_end];
let payload = trim_ascii(payload_slice).to_vec();
Ok(Self { header, payload })
}
}
fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || needle.len() > haystack.len() {
return None;
}
haystack.windows(needle.len()).position(|w| w == needle)
}
fn trim_ascii(s: &[u8]) -> &[u8] {
let start = s
.iter()
.position(|b| !b.is_ascii_whitespace())
.unwrap_or(s.len());
let end = s
.iter()
.rposition(|b| !b.is_ascii_whitespace())
.map(|i| i + 1)
.unwrap_or(0);
if start >= end { &[] } else { &s[start..end] }
}
fn parse_sbdh_header(bytes: &[u8], ctx: impl Fn() -> ErrorContext) -> Result<SbdhHeader> {
let xml_str = std::str::from_utf8(bytes).map_err(|_| {
AsxError::new(
ErrorCode::ParseFailed,
"SBDH document is not valid UTF-8",
ctx(),
)
})?;
let doc = Document::parse(xml_str).map_err(|e| {
AsxError::new(
ErrorCode::ParseFailed,
format!("SBDH XML is malformed: {e}"),
ctx(),
)
})?;
let sbdh = doc
.root_element()
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "StandardBusinessDocumentHeader")
.ok_or_else(|| {
AsxError::new(
ErrorCode::ParseFailed,
"SBDH missing StandardBusinessDocumentHeader",
ctx(),
)
})?;
let find_text = |parent: roxmltree::Node<'_, '_>, name: &str| -> Option<String> {
parent
.children()
.find(|n| n.is_element() && n.tag_name().name() == name)
.and_then(|n| n.text())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
};
let header_version = find_text(sbdh, "HeaderVersion").ok_or_else(|| {
AsxError::new(ErrorCode::ParseFailed, "SBDH missing HeaderVersion", ctx())
})?;
let sender_node = sbdh
.children()
.find(|n| n.is_element() && n.tag_name().name() == "Sender")
.ok_or_else(|| AsxError::new(ErrorCode::ParseFailed, "SBDH missing Sender", ctx()))?;
let sender_id_node = sender_node
.children()
.find(|n| n.is_element() && n.tag_name().name() == "Identifier")
.ok_or_else(|| {
AsxError::new(
ErrorCode::ParseFailed,
"SBDH missing Sender/Identifier",
ctx(),
)
})?;
let sender_identifier = sender_id_node
.text()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.ok_or_else(|| {
AsxError::new(
ErrorCode::ParseFailed,
"SBDH Sender/Identifier is empty",
ctx(),
)
})?;
let sender_authority = sender_id_node
.attribute("Authority")
.unwrap_or("")
.to_string();
let receiver_node = sbdh
.children()
.find(|n| n.is_element() && n.tag_name().name() == "Receiver")
.ok_or_else(|| AsxError::new(ErrorCode::ParseFailed, "SBDH missing Receiver", ctx()))?;
let receiver_id_node = receiver_node
.children()
.find(|n| n.is_element() && n.tag_name().name() == "Identifier")
.ok_or_else(|| {
AsxError::new(
ErrorCode::ParseFailed,
"SBDH missing Receiver/Identifier",
ctx(),
)
})?;
let receiver_identifier = receiver_id_node
.text()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.ok_or_else(|| {
AsxError::new(
ErrorCode::ParseFailed,
"SBDH Receiver/Identifier is empty",
ctx(),
)
})?;
let receiver_authority = receiver_id_node
.attribute("Authority")
.unwrap_or("")
.to_string();
let doc_id = sbdh
.children()
.find(|n| n.is_element() && n.tag_name().name() == "DocumentIdentification")
.ok_or_else(|| {
AsxError::new(
ErrorCode::ParseFailed,
"SBDH missing DocumentIdentification",
ctx(),
)
})?;
let standard = find_text(doc_id, "Standard").ok_or_else(|| {
AsxError::new(
ErrorCode::ParseFailed,
"SBDH missing DocumentIdentification/Standard",
ctx(),
)
})?;
let type_version = find_text(doc_id, "TypeVersion").ok_or_else(|| {
AsxError::new(
ErrorCode::ParseFailed,
"SBDH missing DocumentIdentification/TypeVersion",
ctx(),
)
})?;
let instance_identifier = find_text(doc_id, "InstanceIdentifier").ok_or_else(|| {
AsxError::new(
ErrorCode::ParseFailed,
"SBDH missing DocumentIdentification/InstanceIdentifier",
ctx(),
)
})?;
let doc_type = find_text(doc_id, "Type").ok_or_else(|| {
AsxError::new(
ErrorCode::ParseFailed,
"SBDH missing DocumentIdentification/Type",
ctx(),
)
})?;
let multiple_type = find_text(doc_id, "MultipleType")
.map(|t| matches!(t.to_ascii_lowercase().as_str(), "true" | "1"))
.unwrap_or(false);
let creation_date_and_time = find_text(doc_id, "CreationDateAndTime").ok_or_else(|| {
AsxError::new(
ErrorCode::ParseFailed,
"SBDH missing DocumentIdentification/CreationDateAndTime",
ctx(),
)
})?;
Ok(SbdhHeader {
header_version,
sender: SbdhParty {
identifier: sender_identifier,
authority: sender_authority,
},
receiver: SbdhParty {
identifier: receiver_identifier,
authority: receiver_authority,
},
document_identification: SbdhDocumentIdentification {
standard,
type_version,
instance_identifier,
r#type: doc_type,
multiple_type,
creation_date_and_time,
},
})
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_doc() -> StandardBusinessDocument {
StandardBusinessDocument {
header: SbdhHeader {
header_version: "1.0".into(),
sender: SbdhParty {
identifier: "0007:1234567890".into(),
authority: "iso6523-actorid-upis".into(),
},
receiver: SbdhParty {
identifier: "0007:9876543210".into(),
authority: "iso6523-actorid-upis".into(),
},
document_identification: SbdhDocumentIdentification {
standard: "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2".into(),
type_version: "2.1".into(),
instance_identifier: "urn:uuid:550e8400-e29b-41d4-a716-446655440000".into(),
r#type: "Invoice".into(),
multiple_type: false,
creation_date_and_time: "2026-01-01T12:00:00+00:00".into(),
},
},
payload: b"<Invoice xmlns=\"urn:oasis:names:specification:ubl:schema:xsd:Invoice-2\"/>"
.to_vec(),
}
}
#[test]
fn wrap_produces_well_formed_xml() {
let doc = sample_doc();
let bytes = doc.wrap().expect("wrap");
let xml = std::str::from_utf8(&bytes).expect("utf8");
assert!(
xml.contains("<StandardBusinessDocument"),
"outer element present"
);
assert!(
xml.contains("<StandardBusinessDocumentHeader>"),
"header element present"
);
assert!(
xml.contains("<HeaderVersion>1.0</HeaderVersion>"),
"header version"
);
assert!(xml.contains("0007:1234567890"), "sender id");
assert!(xml.contains("0007:9876543210"), "receiver id");
assert!(xml.contains("Invoice"), "doc type");
assert!(xml.contains("<Invoice"), "payload embedded");
}
#[test]
fn unwrap_recovers_header_and_payload() {
let doc = sample_doc();
let bytes = doc.wrap().expect("wrap");
let parsed = StandardBusinessDocument::unwrap(&bytes).expect("unwrap");
assert_eq!(parsed.header.header_version, "1.0");
assert_eq!(parsed.header.sender.identifier, "0007:1234567890");
assert_eq!(parsed.header.sender.authority, "iso6523-actorid-upis");
assert_eq!(parsed.header.receiver.identifier, "0007:9876543210");
assert_eq!(parsed.header.document_identification.r#type, "Invoice");
assert_eq!(parsed.header.document_identification.type_version, "2.1");
assert!(!parsed.header.document_identification.multiple_type);
assert_eq!(parsed.payload, doc.payload);
}
#[test]
fn round_trip_preserves_all_fields() {
let doc = sample_doc();
let parsed = StandardBusinessDocument::unwrap(&doc.wrap().expect("wrap")).expect("unwrap");
assert_eq!(parsed, doc);
}
#[test]
fn unwrap_returns_error_on_missing_header_close_tag() {
let bad = b"<StandardBusinessDocument><StandardBusinessDocumentHeader>";
let result = StandardBusinessDocument::unwrap(bad);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string().contains("StandardBusinessDocumentHeader")
|| err.code == ErrorCode::ParseFailed
);
}
#[test]
fn unwrap_returns_error_on_missing_document_close_tag() {
let bad = b"<x/></StandardBusinessDocumentHeader>";
let result = StandardBusinessDocument::unwrap(bad);
assert!(result.is_err());
}
#[test]
fn find_subsequence_works() {
assert_eq!(find_subsequence(b"hello world", b"world"), Some(6));
assert_eq!(find_subsequence(b"hello", b"xyz"), None);
assert_eq!(find_subsequence(b"abc", b""), None);
}
#[test]
fn trim_ascii_removes_whitespace() {
assert_eq!(trim_ascii(b" hello "), b"hello");
assert_eq!(trim_ascii(b"\n\t<Tag/>\n"), b"<Tag/>");
assert_eq!(trim_ascii(b" "), b"");
}
}