use std::borrow::Cow;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::naming;
use crate::reflect::TypeInfo;
use crate::value::{FieldValue, OwnedValue, Reflect, Scalar};
#[derive(Clone, Copy, Debug)]
pub enum VersionRef<'a> {
Dotted(&'a str),
Compact(&'a str),
}
impl<'a> VersionRef<'a> {
pub fn dotted(&self) -> Cow<'a, str> {
match self {
VersionRef::Dotted(v) => Cow::Borrowed(v),
VersionRef::Compact(v) => Cow::Owned(naming::dotted_version(v)),
}
}
pub fn compact(&self) -> Cow<'a, str> {
match self {
VersionRef::Compact(v) => Cow::Borrowed(v),
VersionRef::Dotted(v) => Cow::Owned(naming::compact_version(v)),
}
}
pub fn same_as(&self, other: &VersionRef<'_>) -> bool {
self.compact() == other.compact()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EpcLayout {
Flat,
PerPackage,
}
#[derive(Clone, Copy, Debug)]
pub struct ObjectKey<'a> {
pub domain: &'a str,
pub version: VersionRef<'a>,
pub object_type: &'a str,
pub uuid: &'a str,
pub object_version: Option<&'a str>,
pub title: Option<&'a str>,
}
impl<'a> ObjectKey<'a> {
pub fn type_key(&self) -> &'a str {
naming::strip_obj_prefix(self.object_type)
}
pub fn content_type(&self) -> String {
format!(
"application/x-{}+xml;version={};type={}",
self.domain,
self.version.dotted(),
self.object_type
)
}
pub fn qualified_type(&self) -> String {
format!("{}{}.{}", self.domain, self.version.compact(), self.object_type)
}
pub fn etp_uri(&self, dataspace: Option<&str>) -> String {
crate::identifiers::EtpUri::object_versioned(
dataspace,
&self.qualified_type(),
self.uuid,
self.object_version
.map(str::trim)
.filter(|version| !version.is_empty()),
)
}
pub fn part_name(&self) -> String {
format!("{}_{}.xml", self.object_type, self.uuid)
}
pub fn part_path(&self, layout: EpcLayout) -> String {
match layout {
EpcLayout::Flat => self.part_name(),
EpcLayout::PerPackage => {
format!("{}{}/{}", self.domain, self.version.compact(), self.part_name())
}
}
}
pub fn same_object(&self, other: &ObjectKey<'_>) -> bool {
self.uuid.eq_ignore_ascii_case(other.uuid)
&& versions_agree(self.object_version, other.object_version)
}
pub fn same_concept(&self, other: &ObjectKey<'_>) -> bool {
self.domain.eq_ignore_ascii_case(other.domain)
&& naming::type_eq_loose(self.object_type, other.object_type)
}
pub fn to_owned_id(&self) -> ObjectId {
ObjectId {
domain: self.domain.to_string(),
version: self.version.dotted().into_owned(),
object_type: self.object_type.to_string(),
uuid: self.uuid.to_string(),
object_version: self.object_version.map(str::to_string),
title: self.title.map(str::to_string),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ObjectId {
pub domain: String,
pub version: String,
pub object_type: String,
pub uuid: String,
pub object_version: Option<String>,
pub title: Option<String>,
}
impl ObjectId {
pub fn as_key(&self) -> ObjectKey<'_> {
ObjectKey {
domain: &self.domain,
version: VersionRef::Dotted(&self.version),
object_type: &self.object_type,
uuid: &self.uuid,
object_version: self.object_version.as_deref(),
title: self.title.as_deref(),
}
}
pub fn type_key(&self) -> &str {
naming::strip_obj_prefix(&self.object_type)
}
pub fn content_type(&self) -> String {
self.as_key().content_type()
}
pub fn qualified_type(&self) -> String {
self.as_key().qualified_type()
}
pub fn etp_uri(&self, dataspace: Option<&str>) -> String {
self.as_key().etp_uri(dataspace)
}
pub fn from_content_type(content_type: &str, uuid: &str) -> Result<Self> {
let parsed = parse_content_type(content_type)?;
Ok(ObjectId {
domain: parsed.domain.to_string(),
version: parsed.version.to_string(),
object_type: parsed.object_type.to_string(),
uuid: uuid.trim().to_string(),
object_version: None,
title: None,
})
}
pub fn from_qualified_type(qualified_type: &str, uuid: &str) -> Result<Self> {
let parsed = parse_qualified_type(qualified_type)?;
Ok(ObjectId {
domain: parsed.domain.to_string(),
version: naming::dotted_version(parsed.version),
object_type: parsed.object_type.to_string(),
uuid: uuid.trim().to_string(),
object_version: None,
title: None,
})
}
pub fn from_etp_uri(uri: &str) -> Result<(Option<String>, Self)> {
let (dataspace, qualified_type, uuid) = split_etp_uri(uri)?;
let (uuid, object_version) = match uuid.split_once(",version='") {
Some((uuid, version)) => (
uuid.trim(),
Some(version.strip_suffix('\'').unwrap_or(version).replace("''", "'")),
),
None => (uuid, None),
};
let mut id = ObjectId::from_qualified_type(qualified_type, uuid)?;
id.object_version = object_version;
Ok((dataspace.map(str::to_string), id))
}
pub fn of_type(info: &'static TypeInfo, uuid: &str) -> Option<Self> {
if !info.is_top_level {
return None;
}
Some(ObjectId {
domain: info.package.to_string(),
version: info.schema_version.to_string(),
object_type: info.object_type.to_string(),
uuid: uuid.trim().to_string(),
object_version: None,
title: None,
})
}
pub fn of_reflect(object: &dyn Reflect) -> Option<ObjectId> {
key_of_reflect(object).map(|key| key.to_owned_id())
}
pub fn parse(uri: &str) -> Result<(Option<String>, ObjectId)> {
Self::from_etp_uri(uri)
}
pub fn label(&self, title: Option<&str>) -> String {
let title = title
.map(str::trim)
.filter(|title| !title.is_empty())
.unwrap_or(&self.uuid);
match self
.object_version
.as_deref()
.map(str::trim)
.filter(|version| !version.is_empty())
{
Some(version) => format!("{} - {title} (version {version})", self.qualified_type()),
None => format!("{} - {title}", self.qualified_type()),
}
}
pub fn same_object(&self, other: &ObjectId) -> bool {
self.as_key().same_object(&other.as_key())
}
}
impl PartialEq for ObjectId {
fn eq(&self, other: &Self) -> bool {
self.uuid.eq_ignore_ascii_case(&other.uuid)
&& self.domain == other.domain
&& self.version == other.version
&& self.object_type == other.object_type
&& self.object_version == other.object_version
}
}
impl Eq for ObjectId {}
impl std::hash::Hash for ObjectId {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.uuid.to_ascii_lowercase().hash(state);
self.domain.hash(state);
self.version.hash(state);
self.object_type.hash(state);
self.object_version.hash(state);
}
}
impl std::fmt::Display for ObjectId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}({})", self.qualified_type(), self.uuid)
}
}
pub struct ContentTypeParts<'a> {
pub domain: &'a str,
pub version: &'a str,
pub object_type: &'a str,
}
pub fn parse_content_type(value: &str) -> Result<ContentTypeParts<'_>> {
let err = || {
Error::parse(
value,
"contentType",
"expected application/x-<domain>+xml;version=..;type=..",
)
};
let mut parts = value.split(';').map(str::trim);
let media = parts.next().ok_or_else(err)?;
let domain = media
.strip_prefix("application/x-")
.and_then(|rest| rest.strip_suffix("+xml"))
.ok_or_else(err)?;
let mut version = None;
let mut object_type = None;
for part in parts {
if let Some(v) = part.strip_prefix("version=") {
version = Some(v.trim());
} else if let Some(t) = part.strip_prefix("type=") {
object_type = Some(t.trim());
}
}
Ok(ContentTypeParts {
domain,
version: version.ok_or_else(err)?,
object_type: object_type.ok_or_else(err)?,
})
}
pub struct QualifiedTypeParts<'a> {
pub domain: &'a str,
pub version: &'a str,
pub object_type: &'a str,
}
pub fn parse_qualified_type(value: &str) -> Result<QualifiedTypeParts<'_>> {
let err = || Error::parse(value, "qualifiedType", "expected <domain><version>.<Type>");
let value = value.trim();
let (head, object_type) = value.split_once('.').ok_or_else(err)?;
let split = head.find(|c: char| c.is_ascii_digit()).ok_or_else(err)?;
let (domain, version) = head.split_at(split);
if domain.is_empty() || version.is_empty() || object_type.is_empty() {
return Err(err());
}
if !version.chars().all(|c| c.is_ascii_digit()) {
return Err(err());
}
Ok(QualifiedTypeParts {
domain,
version,
object_type,
})
}
fn split_etp_uri(uri: &str) -> Result<(Option<&str>, &str, &str)> {
let err = || {
Error::parse(
uri,
"etpUri",
"expected eml:///[dataspace('..')/]<qualifiedType>(<uuid>)",
)
};
let rest = uri.trim().strip_prefix("eml:///").ok_or_else(err)?;
let (dataspace, rest) = match rest.strip_prefix("dataspace('") {
Some(after) => {
let (ds, tail) = after.split_once("')").ok_or_else(err)?;
(Some(ds), tail.strip_prefix('/').unwrap_or(tail))
}
None => (None, rest),
};
let open = rest.find('(').ok_or_else(err)?;
let close = rest.rfind(')').ok_or_else(err)?;
if close < open {
return Err(err());
}
let qualified_type = &rest[..open];
let uuid = rest[open + 1..close].trim().trim_start_matches("uuid=");
if qualified_type.is_empty() || uuid.is_empty() {
return Err(err());
}
Ok((dataspace, qualified_type, uuid))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum IdentityKind {
TopLevel,
ObjectRef,
None,
}
#[derive(Clone, Copy, Debug)]
pub enum DeclaredType<'a> {
ContentType(&'a str),
QualifiedType(&'a str),
}
pub trait ObjectIdentity {
fn identity_kind(&self) -> IdentityKind;
fn identity_type(&self) -> &'static TypeInfo;
fn uuid(&self) -> Option<&str>;
fn title(&self) -> Option<&str>;
fn object_version(&self) -> Option<&str>;
fn declared_type(&self) -> Option<DeclaredType<'_>> {
None
}
}
pub trait ObjectIdentityMut: ObjectIdentity {
fn set_uuid(&mut self, uuid: &str) -> Result<()>;
fn set_title(&mut self, title: &str) -> Result<()>;
fn set_object_version(&mut self, version: Option<&str>) -> Result<()>;
fn set_target(&mut self, key: &ObjectKey<'_>) -> Result<()>;
}
pub trait ObjectRef: ObjectIdentity + Default + Sized {
fn from_key(key: &ObjectKey<'_>) -> Self;
fn referencing<T: ObjectIdentity + ?Sized>(source: &T) -> Result<Self> {
let key = key_of(source)
.ok_or_else(|| Error::other(format!("`{}` carries no identity", source.identity_type().name)))?;
Ok(Self::from_key(&key))
}
}
pub trait AsObjectRef: ObjectIdentity {
fn as_object_ref<D: ObjectRef>(&self) -> Result<D>;
}
impl<T: ObjectIdentity + ?Sized> AsObjectRef for T {
fn as_object_ref<D: ObjectRef>(&self) -> Result<D> {
D::referencing(self)
}
}
pub fn key_of<'a, T: ObjectIdentity + ?Sized>(source: &'a T) -> Option<ObjectKey<'a>> {
key_from_parts(
source.identity_kind(),
source.identity_type(),
source.uuid(),
source.title(),
source.object_version(),
source.declared_type(),
)
}
pub fn key_from_parts<'a>(
kind: IdentityKind,
info: &'static TypeInfo,
uuid: Option<&'a str>,
title: Option<&'a str>,
object_version: Option<&'a str>,
declared: Option<DeclaredType<'a>>,
) -> Option<ObjectKey<'a>> {
let uuid = uuid?;
match kind {
IdentityKind::None => None,
IdentityKind::TopLevel => Some(ObjectKey {
domain: info.package,
version: VersionRef::Dotted(info.schema_version),
object_type: info.object_type,
uuid,
object_version,
title,
}),
IdentityKind::ObjectRef => match declared? {
DeclaredType::ContentType(ct) => {
let parts = parse_content_type(ct).ok()?;
Some(ObjectKey {
domain: parts.domain,
version: VersionRef::Dotted(parts.version),
object_type: parts.object_type,
uuid,
object_version,
title,
})
}
DeclaredType::QualifiedType(qt) => {
let parts = parse_qualified_type(qt).ok()?;
Some(ObjectKey {
domain: parts.domain,
version: VersionRef::Compact(parts.version),
object_type: parts.object_type,
uuid,
object_version,
title,
})
}
},
}
}
pub fn content_type_of<'a, T: ObjectIdentity + ?Sized>(source: &'a T) -> Option<Cow<'a, str>> {
if let Some(DeclaredType::ContentType(ct)) = source.declared_type() {
return Some(Cow::Borrowed(ct));
}
if source.identity_kind() == IdentityKind::TopLevel
&& let Some(ct) = source.identity_type().content_type
{
return Some(Cow::Borrowed(ct));
}
key_of(source).map(|k| Cow::Owned(k.content_type()))
}
pub fn qualified_type_of<'a, T: ObjectIdentity + ?Sized>(source: &'a T) -> Option<Cow<'a, str>> {
if let Some(DeclaredType::QualifiedType(qt)) = source.declared_type() {
return Some(Cow::Borrowed(qt));
}
if source.identity_kind() == IdentityKind::TopLevel
&& let Some(qt) = source.identity_type().qualified_type
{
return Some(Cow::Borrowed(qt));
}
key_of(source).map(|k| Cow::Owned(k.qualified_type()))
}
pub fn etp_uri_of<T: ObjectIdentity + ?Sized>(source: &T, dataspace: Option<&str>) -> Option<String> {
key_of(source).map(|k| k.etp_uri(dataspace))
}
fn role_str(object: &dyn Reflect, index: Option<u16>) -> Option<&str> {
let info = object.type_info();
let field = info.fields.get(index? as usize)?;
match object.field(field.name)? {
FieldValue::Scalar(Scalar::Str(s)) => Some(s),
_ => None,
}
}
pub fn uuid_of_reflect(object: &dyn Reflect) -> Option<&str> {
role_str(object, object.type_info().roles.uuid)
}
pub fn title_of_reflect(object: &dyn Reflect) -> Option<&str> {
let roles = object.type_info().roles;
if let Some(title) = role_str(object, roles.title) {
return Some(title);
}
let citation = object.field(object.type_info().fields.get(roles.citation? as usize)?.name)?;
let citation = citation.as_object()?;
role_str(citation, citation.type_info().roles.title)
}
pub fn object_version_of_reflect(object: &dyn Reflect) -> Option<&str> {
let roles = object.type_info().roles;
if let Some(version) = role_str(object, roles.object_version) {
return Some(version);
}
let citation = object.field(object.type_info().fields.get(roles.citation? as usize)?.name)?;
let citation = citation.as_object()?;
role_str(citation, citation.type_info().roles.object_version)
}
pub fn declared_type_of_reflect(object: &dyn Reflect) -> Option<DeclaredType<'_>> {
let roles = object.type_info().roles;
if let Some(qt) = role_str(object, roles.qualified_type) {
return Some(DeclaredType::QualifiedType(qt));
}
role_str(object, roles.content_type).map(DeclaredType::ContentType)
}
pub fn key_of_reflect(object: &dyn Reflect) -> Option<ObjectKey<'_>> {
let info = object.type_info();
key_from_parts(
identity_kind_of(info),
info,
uuid_of_reflect(object),
title_of_reflect(object),
object_version_of_reflect(object),
declared_type_of_reflect(object),
)
}
pub fn uri_of_reflect(object: &dyn Reflect) -> Option<String> {
key_of_reflect(object).map(|key| key.etp_uri(None))
}
pub fn address_of_reflect(object: &dyn Reflect) -> Option<String> {
uri_of_reflect(object).or_else(|| {
uuid_of_reflect(object)
.map(str::trim)
.filter(|uuid| !uuid.is_empty())
.map(str::to_string)
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum VersionFit {
Exact,
Compatible,
Contradicts,
}
pub fn version_fit(wanted: Option<&str>, held: Option<&str>) -> VersionFit {
fn named(version: Option<&str>) -> Option<&str> {
version.map(str::trim).filter(|version| !version.is_empty())
}
match (named(wanted), named(held)) {
(Some(wanted), Some(held)) if wanted == held => VersionFit::Exact,
(Some(_), Some(_)) => VersionFit::Contradicts,
(None, None) => VersionFit::Exact,
_ => VersionFit::Compatible,
}
}
pub fn versions_agree(wanted: Option<&str>, held: Option<&str>) -> bool {
version_fit(wanted, held) != VersionFit::Contradicts
}
pub fn address_of_parts(declared_type: Option<&str>, uuid: &str, object_version: Option<&str>) -> String {
let uuid = uuid.trim();
let parsed = declared_type
.map(str::trim)
.filter(|declared| !declared.is_empty())
.and_then(|declared| {
if declared.contains("+xml") {
ObjectId::from_content_type(declared, uuid).ok()
} else {
ObjectId::from_qualified_type(declared, uuid).ok()
}
});
match parsed {
Some(mut id) => {
id.object_version = object_version
.map(str::trim)
.filter(|version| !version.is_empty())
.map(str::to_string);
id.etp_uri(None)
}
None => uuid.to_string(),
}
}
pub fn parse_address(address: &str) -> (String, Option<String>) {
let address = address.trim();
if address
.get(..4)
.is_some_and(|scheme| scheme.eq_ignore_ascii_case("eml:"))
&& let Ok(parsed) = crate::identifiers::EtpUri::parse(address)
&& let Some(uuid) = parsed.uuid
{
return (uuid.to_ascii_lowercase(), parsed.object_version);
}
(address.to_ascii_lowercase(), None)
}
pub fn identity_kind_of(info: &'static TypeInfo) -> IdentityKind {
if info.is_object_ref {
IdentityKind::ObjectRef
} else if info.is_top_level {
IdentityKind::TopLevel
} else {
IdentityKind::None
}
}
fn set_role_str(object: &mut dyn Reflect, index: Option<u16>, value: &str) -> Result<bool> {
let Some(index) = index else { return Ok(false) };
let Some(field) = object.type_info().fields.get(index as usize) else {
return Ok(false);
};
object.set_field(field.name, OwnedValue::Str(value.to_string()))?;
Ok(true)
}
pub fn set_uuid_reflect(object: &mut dyn Reflect, uuid: &str) -> Result<bool> {
let normalised = crate::identifiers::normalize_uuid(uuid)?;
let index = object.type_info().roles.uuid;
set_role_str(object, index, &normalised)
}
pub fn set_target_reflect(object: &mut dyn Reflect, key: &ObjectKey<'_>) -> Result<()> {
let roles = object.type_info().roles;
let mut named = false;
if roles.qualified_type.is_some() {
named |= set_role_str(object, roles.qualified_type, &key.qualified_type())?;
}
if roles.content_type.is_some() {
named |= set_role_str(object, roles.content_type, &key.content_type())?;
}
if !named {
return Err(Error::other(format!(
"`{}` would carry no QualifiedType and no ContentType: a reference that \
names no type cannot be resolved by anything that reads it",
object.type_info().name
)));
}
set_role_str(object, roles.uuid, key.uuid)?;
if let Some(title) = key.title {
set_role_str(object, roles.title, title)?;
}
if let Some(version) = key.object_version {
set_role_str(object, roles.object_version, version)?;
}
Ok(())
}
pub fn set_title_reflect(object: &mut dyn Reflect, title: &str) -> Result<()> {
let roles = object.type_info().roles;
if set_role_str(object, roles.title, title)? {
return Ok(());
}
let Some(index) = roles.citation else {
return Err(Error::other(format!(
"`{}` has no title",
object.type_info().name
)));
};
let Some(field) = object.type_info().fields.get(index as usize) else {
return Err(Error::other(format!(
"`{}` has no title",
object.type_info().name
)));
};
let name = field.name;
let Some(citation) = object.field_object_mut(name, None) else {
return Err(Error::other(format!("`{name}` is absent, cannot set its title")));
};
let citation_roles = citation.type_info().roles;
set_role_str(citation, citation_roles.title, title)?;
Ok(())
}
#[derive(Clone, Debug)]
pub struct RefEdge<'a> {
pub path: String,
pub target: ObjectKey<'a>,
pub expected: Option<&'static TypeInfo>,
}
pub fn collect_refs(root: &dyn Reflect) -> Vec<RefEdge<'_>> {
let mut out = Vec::new();
walk_refs(root, String::new(), &mut out);
out
}
fn walk_refs<'a>(object: &'a dyn Reflect, prefix: String, out: &mut Vec<RefEdge<'a>>) {
for field in object.type_info().fields {
let Some(value) = object.field(field.name) else {
continue;
};
let base = if prefix.is_empty() {
field.xml_name.to_string()
} else {
format!("{prefix}.{}", field.xml_name)
};
visit_value(value, base, field.ty.type_info(), out);
}
}
fn visit_value<'a>(
value: FieldValue<'a>,
path: String,
expected: Option<&'static TypeInfo>,
out: &mut Vec<RefEdge<'a>>,
) {
match value {
FieldValue::Object(child) => {
if child.type_info().is_object_ref {
if let Some(target) = key_of_reflect(child) {
out.push(RefEdge {
path,
target,
expected,
});
}
} else if let Some(any) = child.as_any().downcast_ref::<crate::any_xml::AnyXml>() {
refs_in_source(&any.content, &path, out);
} else {
walk_refs(child, path, out);
}
}
FieldValue::List(items) => {
for (i, item) in items.into_iter().enumerate() {
visit_value(item, format!("{path}[{i}]"), expected, out);
}
}
_ => {}
}
}
#[derive(Clone, Debug)]
pub struct NestedObject<'a> {
pub path: String,
pub key: ObjectKey<'a>,
}
pub fn nested_objects(root: &dyn Reflect) -> Vec<NestedObject<'_>> {
let mut out = Vec::new();
walk_nested(root, String::new(), &mut out);
out
}
fn walk_nested<'a>(object: &'a dyn Reflect, prefix: String, out: &mut Vec<NestedObject<'a>>) {
for field in object.type_info().fields {
let Some(value) = object.field(field.name) else {
continue;
};
let base = if prefix.is_empty() {
field.xml_name.to_string()
} else {
format!("{prefix}.{}", field.xml_name)
};
visit_nested(value, base, out);
}
}
fn visit_nested<'a>(value: FieldValue<'a>, path: String, out: &mut Vec<NestedObject<'a>>) {
match value {
FieldValue::Object(child) => {
let info = child.type_info();
if info.is_object_ref {
return;
}
if info.is_top_level
&& let Some(key) = key_of_reflect(child)
&& !key.uuid.trim().is_empty()
{
out.push(NestedObject {
path: path.clone(),
key,
});
}
walk_nested(child, path, out);
}
FieldValue::List(items) => {
for (i, item) in items.into_iter().enumerate() {
visit_nested(item, format!("{path}[{i}]"), out);
}
}
_ => {}
}
}
fn refs_in_source<'a>(source: &'a str, prefix: &str, out: &mut Vec<RefEdge<'a>>) {
use quick_xml::events::Event;
struct Frame<'a> {
path: String,
uuid: Option<&'a str>,
declared: Option<&'a str>,
object_version: Option<&'a str>,
title: Option<&'a str>,
seen: std::collections::HashMap<String, usize>,
}
let mut reader = quick_xml::Reader::from_str(source);
reader.config_mut().trim_text(true);
let mut stack: Vec<Frame<'a>> = Vec::new();
let mut root_counts: std::collections::HashMap<String, usize> = Default::default();
let mut text: Option<&'a str> = None;
loop {
match reader.read_event() {
Err(_) | Ok(Event::Eof) => break,
Ok(Event::Start(e)) => {
let name = local_name_of(e.name().as_ref());
let counter = match stack.last_mut() {
Some(frame) => &mut frame.seen,
None => &mut root_counts,
};
let index = {
let n = counter.entry(name.clone()).or_insert(0);
let at = *n;
*n += 1;
at
};
let parent = stack
.last()
.map(|f| f.path.clone())
.unwrap_or_else(|| prefix.to_string());
let path = match (parent.is_empty(), index) {
(true, 0) => name,
(true, n) => format!("{name}[{n}]"),
(false, 0) => format!("{parent}.{name}"),
(false, n) => format!("{parent}.{name}[{n}]"),
};
stack.push(Frame {
path,
uuid: None,
declared: None,
object_version: None,
title: None,
seen: Default::default(),
});
text = None;
}
Ok(Event::Text(e)) => {
text = match e.decode() {
Ok(std::borrow::Cow::Borrowed(value)) => Some(value.trim()),
_ => None,
};
}
Ok(Event::End(e)) => {
let name = local_name_of(e.name().as_ref());
let Some(frame) = stack.pop() else { continue };
let value = text.take().filter(|v| !v.is_empty());
if let (Some(parent), Some(value)) = (stack.last_mut(), value) {
match name.as_str() {
"Uuid" | "UUID" => parent.uuid = Some(value),
"QualifiedType" | "ContentType" => parent.declared = Some(value),
"ObjectVersion" | "VersionString" => parent.object_version = Some(value),
"Title" => parent.title = Some(value),
_ => {}
}
}
let Some(uuid) = frame.uuid else { continue };
let (domain, version, object_type) = match frame.declared {
Some(declared) if declared.contains("+xml") => match parse_content_type(declared) {
Ok(parts) => (parts.domain, VersionRef::Dotted(parts.version), parts.object_type),
Err(_) => ("", VersionRef::Dotted(""), ""),
},
Some(declared) => match parse_qualified_type(declared) {
Ok(parts) => (
parts.domain,
VersionRef::Compact(parts.version),
parts.object_type,
),
Err(_) => ("", VersionRef::Dotted(""), ""),
},
None => ("", VersionRef::Dotted(""), ""),
};
out.push(RefEdge {
path: frame.path,
target: ObjectKey {
domain,
version,
object_type,
uuid,
object_version: frame.object_version,
title: frame.title,
},
expected: None,
});
}
_ => {}
}
}
}
fn local_name_of(qualified: &[u8]) -> String {
let name = String::from_utf8_lossy(qualified);
name.rsplit(':').next().unwrap_or(&name).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_reference_in_kept_text_carries_its_version() {
let source = concat!(
r#"<eml23:TargetObject><eml23:Uuid>aaaa</eml23:Uuid><eml23:ObjectVersion>0</eml23:ObjectVersion>"#,
r#"<eml23:QualifiedType>resqml22.BoundaryFeature</eml23:QualifiedType><eml23:Title>t</eml23:Title></eml23:TargetObject>"#,
r#"<resqml20:Old><eml20:ContentType>application/x-resqml+xml;version=2.0;type=obj_Well</eml20:ContentType>"#,
r#"<eml20:UUID>bbbb</eml20:UUID><eml20:VersionString>7</eml20:VersionString></resqml20:Old>"#,
r#"<eml23:Silent><eml23:Uuid>cccc</eml23:Uuid><eml23:QualifiedType>resqml22.Fault</eml23:QualifiedType></eml23:Silent>"#,
);
let mut out = Vec::new();
refs_in_source(source, "GraphicalInformation[0]", &mut out);
let by_uuid = |uuid: &str| {
out.iter()
.find(|e| e.target.uuid == uuid)
.unwrap_or_else(|| panic!("{uuid}"))
};
assert_eq!(out.len(), 3);
assert_eq!(by_uuid("aaaa").target.object_version, Some("0"));
assert_eq!(
by_uuid("aaaa").target.title,
Some("t"),
"the title is there too, and reading it avoids taking it for empty"
);
assert_eq!(by_uuid("aaaa").path, "GraphicalInformation[0].TargetObject");
assert_eq!(by_uuid("bbbb").target.object_version, Some("7"));
assert_eq!(by_uuid("cccc").target.object_version, None);
}
#[test]
fn content_type_keeps_the_obj_prefix() {
let parts = parse_content_type("application/x-resqml+xml;version=2.0;type=obj_Activity").unwrap();
assert_eq!(parts.domain, "resqml");
assert_eq!(parts.version, "2.0");
assert_eq!(parts.object_type, "obj_Activity");
}
#[test]
fn content_type_round_trips_verbatim() {
let raw = "application/x-resqml+xml;version=2.0;type=obj_Activity";
let id = ObjectId::from_content_type(raw, "0e2ec4e1-1b0c-4b9f-8e9f-2a0d4c1f6a11").unwrap();
assert_eq!(id.content_type(), raw);
assert_eq!(id.qualified_type(), "resqml20.obj_Activity");
assert_eq!(id.type_key(), "Activity");
}
#[test]
fn qualified_type_round_trips_verbatim() {
let id = ObjectId::from_qualified_type("resqml20.obj_Activity", "AAAA").unwrap();
assert_eq!(id.version, "2.0");
assert_eq!(id.object_type, "obj_Activity");
assert_eq!(id.qualified_type(), "resqml20.obj_Activity");
}
#[test]
fn etp_uri_round_trips() {
let uuid = "0e2ec4e1-1b0c-4b9f-8e9f-2a0d4c1f6a11";
let uri = format!("eml:///dataspace('demo')/resqml22.TriangulatedSetRepresentation({uuid})");
let (ds, id) = ObjectId::from_etp_uri(&uri).unwrap();
assert_eq!(ds.as_deref(), Some("demo"));
assert_eq!(id.object_type, "TriangulatedSetRepresentation");
assert_eq!(id.etp_uri(Some("demo")), uri);
}
#[test]
fn part_names_follow_the_type_verbatim() {
let id = ObjectId::from_qualified_type("resqml20.obj_Activity", "u1").unwrap();
assert_eq!(id.as_key().part_name(), "obj_Activity_u1.xml");
assert_eq!(
id.as_key().part_path(EpcLayout::PerPackage),
"resqml20/obj_Activity_u1.xml"
);
}
#[test]
fn concepts_match_across_versions() {
let old = ObjectId::from_qualified_type("resqml20.obj_Activity", "u").unwrap();
let new = ObjectId::from_qualified_type("resqml22.Activity", "u").unwrap();
assert!(old.as_key().same_concept(&new.as_key()));
assert_eq!(old.type_key(), new.type_key());
}
#[test]
fn a_named_version_is_read_and_a_short_one_is_accepted_when_nothing_contradicts_it() {
use VersionFit::*;
assert_eq!(version_fit(Some("1"), Some("1")), Exact);
assert_eq!(version_fit(Some("1"), Some("2")), Contradicts);
assert_eq!(version_fit(Some("1"), None), Compatible);
assert_eq!(version_fit(None, Some("2")), Compatible);
assert_eq!(version_fit(None, None), Exact);
assert_eq!(
version_fit(Some(" 1 "), Some("1")),
Exact,
"spaces are not part of a version"
);
assert_eq!(
version_fit(Some("1"), Some(" ")),
Compatible,
"a blank version says nothing"
);
assert!(versions_agree(None, Some("2")) && !versions_agree(Some("1"), Some("2")));
}
#[test]
fn uuid_case_does_not_split_identities() {
let a = ObjectId::from_qualified_type("resqml22.Activity", "ABCD").unwrap();
let b = ObjectId::from_qualified_type("resqml22.Activity", "abcd").unwrap();
assert_eq!(a, b);
}
}