use std::fmt;
use energyml_core::identifiers;
use energyml_core::identity;
use energyml_core::naming;
use energyml_core::value::Reflect;
use crate::kinds::{Judged, KindBasis};
use crate::resource::{ObjectSummary, Relationship};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RefIssue {
pub source: String,
pub path: String,
pub target_uuid: String,
pub declared_type: Option<String>,
pub kind: RefIssueKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RefIssueKind {
Dangling,
ExternallyDefined,
ConceptMismatch {
found: String,
candidates: Vec<String>,
},
VersionMismatch {
found: String,
candidates: Vec<String>,
},
ObjectVersionMismatch {
referenced: Option<String>,
found: Option<String>,
candidates: Vec<String>,
},
TitleMismatch {
referenced: Option<String>,
found: String,
},
UnknownType,
}
impl RefIssue {
pub fn is_error(&self) -> bool {
matches!(
self.kind,
RefIssueKind::Dangling
| RefIssueKind::ConceptMismatch { .. }
| RefIssueKind::ObjectVersionMismatch {
referenced: Some(_),
..
}
)
}
pub fn is_info(&self) -> bool {
matches!(self.kind, RefIssueKind::ExternallyDefined)
}
}
impl fmt::Display for RefIssue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: ", self.path)?;
match &self.kind {
RefIssueKind::Dangling => match &self.declared_type {
Some(declared) => {
write!(
f,
"references {} ({}), which is not in this package",
self.target_uuid, declared
)
}
None => write!(f, "references {}, which is not in this package", self.target_uuid),
},
RefIssueKind::ExternallyDefined => write!(
f,
"references {} ({}), which the Energistics catalogue defines - a package need not carry it",
self.target_uuid,
self.declared_type.as_deref().unwrap_or("?")
),
RefIssueKind::ConceptMismatch { found, candidates } => write!(
f,
"references {} as `{}`, but that object is a `{found}`{}",
self.target_uuid,
self.declared_type.as_deref().unwrap_or("?"),
should_be_one_of(candidates)
),
RefIssueKind::VersionMismatch { found, candidates } => write!(
f,
"references {} as `{}`, but that object is a `{found}` - same concept, other version{}",
self.target_uuid,
self.declared_type.as_deref().unwrap_or("?"),
should_be_one_of(candidates)
),
RefIssueKind::ObjectVersionMismatch {
referenced: Some(referenced),
found: Some(found),
candidates,
} => write!(
f,
"references {} at version `{referenced}`, but that object is at version `{found}`{}",
self.target_uuid,
should_be_one_of(candidates)
),
RefIssueKind::ObjectVersionMismatch {
referenced: Some(referenced),
found: None,
candidates,
} => write!(
f,
"references {} at version `{referenced}`, but {}{}",
self.target_uuid,
if candidates.len() > 1 {
"no object of that uuid and type carries it"
} else {
"that object carries no objectVersion"
},
should_be_one_of(candidates)
),
RefIssueKind::ObjectVersionMismatch {
referenced: None,
found,
candidates,
} if candidates.len() > 1 => write!(
f,
"references {} without an ObjectVersion, and {} objects of that uuid and type are held{}{}",
self.target_uuid,
candidates.len(),
found
.as_deref()
.map(|version| format!(" (the nearest at version `{version}`)"))
.unwrap_or_default(),
should_be_one_of(candidates)
),
RefIssueKind::ObjectVersionMismatch {
referenced: None,
found,
..
} => write!(
f,
"references {} without an ObjectVersion, and that object is at version `{}` - a reader assumes the latest, which is this one; say it",
self.target_uuid,
found.as_deref().unwrap_or("?")
),
RefIssueKind::TitleMismatch {
referenced: Some(referenced),
found,
} => write!(
f,
"calls {} `{referenced}`, and that object calls itself `{found}` - the title in a reference is a copy, and this one is stale",
self.target_uuid
),
RefIssueKind::TitleMismatch {
referenced: None,
found,
} => write!(
f,
"references {} with no title, and that object is called `{found}` - the title in a reference is a copy, and this one is empty",
self.target_uuid
),
RefIssueKind::UnknownType => write!(
f,
"references {} as `{}`, a type no schema known to this build defines",
self.target_uuid,
self.declared_type.as_deref().unwrap_or("?")
),
}
}
}
fn should_be_one_of(candidates: &[String]) -> String {
if candidates.is_empty() {
String::new()
} else {
format!(
": uuid found but type or version should be one of the following: {}",
candidates.join(", ")
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Cached {
Unread,
Read {
object_version: Option<String>,
title: Option<String>,
},
}
fn revision_check(referenced: Option<&str>, found: &Cached, candidates: &[String]) -> Option<RefIssueKind> {
let Cached::Read {
object_version: found,
..
} = found
else {
return None;
};
let referenced = referenced.map(str::trim).filter(|v| !v.is_empty());
let found = found.as_deref().map(str::trim).filter(|v| !v.is_empty());
match (referenced, found) {
(None, None) => None,
(Some(r), Some(f)) if r == f => None,
(r, f) => Some(RefIssueKind::ObjectVersionMismatch {
referenced: r.map(str::to_string),
found: f.map(str::to_string),
candidates: candidates.to_vec(),
}),
}
}
fn stated_version(
target: &ObjectSummary,
cached_of: &dyn Fn(&ObjectSummary) -> Cached,
) -> Option<Option<String>> {
match cached_of(target) {
Cached::Unread => match identity::ObjectId::parse(&target.uri) {
Ok((_, id))
if id
.object_version
.as_deref()
.is_some_and(|version| !version.trim().is_empty()) =>
{
Some(id.object_version.map(|version| version.trim().to_string()))
}
_ => None,
},
Cached::Read { object_version, .. } => Some(
object_version
.map(|version| version.trim().to_string())
.filter(|version| !version.is_empty()),
),
}
}
fn title_check(referenced: Option<&str>, found: &Cached) -> Option<RefIssueKind> {
let Cached::Read {
title: Some(found), ..
} = found
else {
return None;
};
let found = found.trim();
if found.is_empty() {
return None;
}
let referenced = referenced.map(str::trim).filter(|value| !value.is_empty());
match referenced {
Some(value) if value == found => None,
other => Some(RefIssueKind::TitleMismatch {
referenced: other.map(str::to_string),
found: found.to_string(),
}),
}
}
pub fn check_references<'i>(
source: &ObjectSummary,
object: &dyn Reflect,
known: &dyn Fn(&str) -> Option<&'i ObjectSummary>,
cached_of: &dyn Fn(&str) -> Cached,
) -> Vec<RefIssue> {
check_references_by(
source,
object,
&|uuid| known(uuid).into_iter().collect(),
&|target| cached_of(&target.uuid),
)
}
pub fn check_references_by<'i>(
source: &ObjectSummary,
object: &dyn Reflect,
held: &dyn Fn(&str) -> Vec<&'i ObjectSummary>,
cached_of: &dyn Fn(&ObjectSummary) -> Cached,
) -> Vec<RefIssue> {
let mut out = Vec::new();
for edge in identity::collect_refs(object) {
let uuid = edge.target.uuid;
if uuid.is_empty() {
continue;
}
let declared = edge.target.qualified_type();
let issue = |kind| RefIssue {
source: source.uri.clone(),
path: edge.path.clone(),
target_uuid: uuid.to_string(),
declared_type: Some(declared.clone()),
kind,
};
let candidates = held(uuid);
if candidates.is_empty() {
out.push(issue(if is_externally_defined(&declared) {
RefIssueKind::ExternallyDefined
} else {
RefIssueKind::Dangling
}));
continue;
}
let mut uris: Vec<String> = candidates.iter().map(|one| one.uri.clone()).collect();
uris.sort();
uris.dedup();
let wanted = edge
.target
.object_version
.map(str::trim)
.filter(|version| !version.is_empty());
let same_type: Vec<&ObjectSummary> = candidates
.iter()
.copied()
.filter(|one| same_qualified_type(&declared, &one.qualified_type))
.collect();
match exact_among(&same_type, wanted, cached_of) {
Some(target) => {
let cached = cached_of(target);
if let Some(kind) = revision_check(wanted, &cached, &uris) {
out.push(issue(kind));
}
if let Some(kind) = title_check(edge.target.title, &cached) {
out.push(issue(kind));
}
}
None if !same_type.is_empty() => {
let sole = if same_type.len() == 1 {
Some(same_type[0])
} else {
None
};
let found = sole.and_then(|one| stated_version(one, cached_of)).flatten();
out.push(issue(RefIssueKind::ObjectVersionMismatch {
referenced: wanted.map(str::to_string),
found,
candidates: uris.clone(),
}));
if let Some(one) = sole
&& let Some(kind) = title_check(edge.target.title, &cached_of(one))
{
out.push(issue(kind));
}
}
None => {
let near = candidates
.iter()
.copied()
.find(|one| same_concept(&declared, &one.qualified_type));
let kind = match near {
Some(one) => RefIssueKind::VersionMismatch {
found: one.qualified_type.clone(),
candidates: uris.clone(),
},
None => RefIssueKind::ConceptMismatch {
found: candidates[0].qualified_type.clone(),
candidates: uris.clone(),
},
};
out.push(issue(kind));
if let [only] = candidates[..] {
let cached = cached_of(only);
if let Some(kind) = revision_check(wanted, &cached, &uris) {
out.push(issue(kind));
}
if let Some(kind) = title_check(edge.target.title, &cached) {
out.push(issue(kind));
}
}
}
}
}
out
}
fn exact_among<'a>(
same_type: &[&'a ObjectSummary],
wanted: Option<&str>,
cached_of: &dyn Fn(&ObjectSummary) -> Cached,
) -> Option<&'a ObjectSummary> {
match wanted {
Some(wanted) => same_type
.iter()
.copied()
.find(|one| match stated_version(one, cached_of) {
None => true,
Some(version) => version.as_deref() == Some(wanted),
}),
None => match same_type {
[only] => Some(*only),
many => {
let bare: Vec<&'a ObjectSummary> = many
.iter()
.copied()
.filter(|one| matches!(stated_version(one, cached_of), Some(None)))
.collect();
match bare[..] {
[only] => Some(only),
_ => None,
}
}
},
}
}
pub fn check_declared_types<'a>(
objects: impl IntoIterator<Item = (&'a ObjectSummary, &'a dyn Reflect)>,
knows: &dyn Fn(&str) -> bool,
) -> Vec<RefIssue> {
let mut out = Vec::new();
for (source, object) in objects {
for edge in identity::collect_refs(object) {
if edge.target.uuid.is_empty() {
continue;
}
let declared = edge.target.qualified_type();
if knows(&declared) {
continue;
}
out.push(RefIssue {
source: source.uri.clone(),
path: edge.path.clone(),
target_uuid: edge.target.uuid.to_string(),
declared_type: Some(declared),
kind: RefIssueKind::UnknownType,
});
}
}
out
}
fn dataspace_of(uri: &str) -> Option<String> {
identifiers::EtpUri::parse(uri)
.ok()
.and_then(|parsed| parsed.dataspace)
}
pub fn nested_index<'a>(
objects: impl IntoIterator<Item = (&'a ObjectSummary, &'a dyn Reflect)>,
) -> std::collections::HashMap<String, (ObjectSummary, String, Cached)> {
let objects: Vec<(&ObjectSummary, &dyn Reflect)> = objects.into_iter().collect();
let parts: std::collections::HashSet<String> = objects
.iter()
.map(|(summary, _)| summary.uuid.to_ascii_lowercase())
.collect();
let mut out = std::collections::HashMap::new();
for (container, object) in objects {
for nested in identity::nested_objects(object) {
let uuid = nested.key.uuid.to_ascii_lowercase();
if parts.contains(&uuid) {
continue;
}
let summary = ObjectSummary::new(
nested.key.etp_uri(dataspace_of(&container.uri).as_deref()),
nested.key.uuid.to_string(),
nested.key.qualified_type(),
)
.with_content_type(nested.key.content_type())
.with_locator(container.locator.clone());
let cached = Cached::Read {
object_version: nested.key.object_version.map(str::to_string),
title: nested.key.title.map(str::to_string),
};
out.entry(uuid)
.or_insert((summary, container.uuid.to_ascii_lowercase(), cached));
}
}
out
}
pub fn check_all_references<'a, 'i>(
objects: impl IntoIterator<Item = (&'a ObjectSummary, &'a dyn Reflect)>,
known: &dyn Fn(&str) -> Option<&'i ObjectSummary>,
) -> Vec<RefIssue> {
check_all_references_with(objects, &|uuid| known(uuid).into_iter().collect())
}
pub fn check_all_references_with<'a, 'i>(
objects: impl IntoIterator<Item = (&'a ObjectSummary, &'a dyn Reflect)>,
elsewhere: &dyn Fn(&str) -> Vec<&'i ObjectSummary>,
) -> Vec<RefIssue> {
let objects: Vec<(&ObjectSummary, &dyn Reflect)> = objects.into_iter().collect();
let stated: std::collections::HashMap<&str, Cached> = objects
.iter()
.map(|(summary, object)| {
(
summary.uri.as_str(),
Cached::Read {
object_version: identity::object_version_of_reflect(*object).map(str::to_string),
title: identity::title_of_reflect(*object).map(str::to_string),
},
)
})
.collect();
let mut in_batch: std::collections::HashMap<String, Vec<usize>> = std::collections::HashMap::new();
for (at, (summary, _)) in objects.iter().enumerate() {
in_batch
.entry(summary.uuid.to_ascii_lowercase())
.or_default()
.push(at);
}
let nested = nested_index(objects.iter().copied());
let cached_of = |target: &ObjectSummary| {
stated
.get(target.uri.as_str())
.cloned()
.or_else(|| {
nested
.get(&target.uuid.to_ascii_lowercase())
.map(|(_, _, cached)| cached.clone())
})
.unwrap_or(Cached::Unread)
};
let held = |uuid: &str| -> Vec<&ObjectSummary> {
let key = uuid.to_ascii_lowercase();
let mut out: Vec<&ObjectSummary> = in_batch
.get(&key)
.map(|ats| ats.iter().map(|at| objects[*at].0).collect())
.unwrap_or_default();
for other in elsewhere(uuid) {
if !out.iter().any(|one| one.uri == other.uri) {
out.push(other);
}
}
if out.is_empty()
&& let Some((summary, _, _)) = nested.get(&key)
{
out.push(summary);
}
out
};
objects
.iter()
.flat_map(|(summary, object)| check_references_by(summary, *object, &held, &cached_of))
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RefRepair {
pub source: String,
pub path: String,
pub target_uuid: String,
pub outcome: RepairOutcome,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RepairOutcome {
Done {
was_type: Option<String>,
now_type: String,
now_title: Option<String>,
now_version: Option<String>,
},
Refused(Refusal),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refusal {
Dangling,
ConceptMismatch {
declared: String,
found: String,
},
Unreachable {
reason: String,
},
}
impl RefRepair {
pub fn changed(&self) -> bool {
matches!(self.outcome, RepairOutcome::Done { .. })
}
}
impl fmt::Display for RefRepair {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: ", self.path)?;
match &self.outcome {
RepairOutcome::Done {
was_type,
now_type,
now_title,
now_version,
} => {
match was_type {
Some(was) if was != now_type => write!(f, "`{was}` → `{now_type}`")?,
_ => write!(f, "`{now_type}`")?,
}
if let Some(title) = now_title {
write!(f, ", title → `{title}`")?;
}
match now_version {
Some(version) => write!(f, ", version → `{version}`"),
None => Ok(()),
}
}
RepairOutcome::Refused(Refusal::Dangling) => {
write!(f, "left alone - {} is not in this package", self.target_uuid)
}
RepairOutcome::Refused(Refusal::ConceptMismatch { declared, found }) => write!(
f,
"left alone - says `{declared}`, but {} is a `{found}`; which half is wrong is not \
something this package says",
self.target_uuid
),
RepairOutcome::Refused(Refusal::Unreachable { reason }) => {
write!(f, "left alone - {reason}")
}
}
}
}
pub struct Target {
pub qualified_type: String,
pub title: Option<String>,
pub object_version: Option<String>,
}
pub fn repair_references(
source: &ObjectSummary,
object: &mut dyn Reflect,
target_of: &dyn Fn(&str) -> Option<Target>,
) -> Vec<RefRepair> {
#[allow(clippy::type_complexity)]
let mut planned: Vec<(String, String, Option<String>, Option<String>, Option<String>)> = Vec::new();
let mut out = Vec::new();
for edge in identity::collect_refs(&*object) {
let uuid = edge.target.uuid;
if uuid.is_empty() {
continue;
}
let declared = edge.target.qualified_type();
let repair = |outcome| RefRepair {
source: source.uri.clone(),
path: edge.path.clone(),
target_uuid: uuid.to_string(),
outcome,
};
let Some(target) = target_of(uuid) else {
out.push(repair(RepairOutcome::Refused(Refusal::Dangling)));
continue;
};
if !same_concept(&declared, &target.qualified_type) {
out.push(repair(RepairOutcome::Refused(Refusal::ConceptMismatch {
declared,
found: target.qualified_type,
})));
continue;
}
let type_drifted = !same_qualified_type(&declared, &target.qualified_type);
let title_drifted = match (&target.title, edge.target.title) {
(Some(theirs), Some(ours)) => theirs != ours,
(Some(_), None) => true,
(None, _) => false,
};
let version_drifted = match (&target.object_version, edge.target.object_version) {
(Some(theirs), Some(ours)) => theirs != ours,
(Some(_), None) => true,
(None, _) => false,
};
if !type_drifted && !title_drifted && !version_drifted {
continue;
}
planned.push((
edge.path.clone(),
target.qualified_type,
title_drifted.then(|| target.title.clone()).flatten(),
target.object_version.clone(),
type_drifted.then(|| declared.clone()),
));
}
for (path, qualified_type, title, version, was_type) in planned {
let reference = match energyml_core::path::object_mut(object, &path) {
Ok(reference) => reference,
Err(e) => {
out.push(RefRepair {
source: source.uri.clone(),
path,
target_uuid: String::new(),
outcome: RepairOutcome::Refused(Refusal::Unreachable {
reason: format!("{e}"),
}),
});
continue;
}
};
let uuid = identity::uuid_of_reflect(&*reference)
.unwrap_or_default()
.to_string();
let Ok(id) = identity::ObjectId::from_qualified_type(&qualified_type, &uuid) else {
continue;
};
let mut key = id.as_key();
key.title = title.as_deref();
key.object_version = version.as_deref();
if energyml_core::identity::set_target_reflect(reference, &key).is_ok() {
out.push(RefRepair {
source: source.uri.clone(),
path,
target_uuid: uuid,
outcome: RepairOutcome::Done {
was_type,
now_type: qualified_type,
now_title: title,
now_version: version,
},
});
}
}
out
}
fn same_qualified_type(a: &str, b: &str) -> bool {
match (
identity::parse_qualified_type(a),
identity::parse_qualified_type(b),
) {
(Ok(a), Ok(b)) => {
a.domain.eq_ignore_ascii_case(b.domain)
&& a.version == b.version
&& naming::type_eq_loose(a.object_type, b.object_type)
}
_ => a.eq_ignore_ascii_case(b),
}
}
pub fn is_externally_defined(qualified_type: &str) -> bool {
let object_type = match identity::parse_qualified_type(qualified_type) {
Ok(parsed) => parsed.object_type,
Err(_) => qualified_type,
};
naming::type_eq_loose(object_type, "PropertyKind")
}
fn same_concept(a: &str, b: &str) -> bool {
match (
identity::parse_qualified_type(a),
identity::parse_qualified_type(b),
) {
(Ok(a), Ok(b)) => {
a.domain.eq_ignore_ascii_case(b.domain) && naming::type_eq_loose(a.object_type, b.object_type)
}
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn summary(uuid: &str, qualified_type: &str) -> ObjectSummary {
ObjectSummary::new(
format!("eml:///{qualified_type}({uuid})"),
uuid.to_string(),
qualified_type.to_string(),
)
.with_locator(format!("{qualified_type}_{uuid}.xml"))
}
#[test]
fn the_obj_prefix_is_not_a_difference() {
assert!(same_qualified_type("resqml20.obj_Activity", "resqml20.Activity"));
assert!(same_qualified_type("RESQML20.obj_activity", "resqml20.Activity"));
assert!(!same_qualified_type("resqml20.obj_Activity", "resqml22.Activity"));
assert!(same_concept("resqml20.obj_Activity", "resqml22.Activity"));
assert!(!same_concept("resqml20.obj_Activity", "resqml20.obj_Well"));
}
#[test]
fn a_version_difference_is_not_a_concept_difference() {
let target = summary("00000000-0000-4000-8000-000000000002", "resqml22.Activity");
let same = summary("00000000-0000-4000-8000-000000000002", "resqml20.obj_Well");
assert!(same_concept("resqml20.obj_Activity", &target.qualified_type));
assert!(!same_concept("resqml20.obj_Activity", &same.qualified_type));
}
#[test]
fn a_revision_is_compared_only_once_read_and_a_silent_reference_is_a_warning() {
let read = |version: Option<&str>| Cached::Read {
object_version: version.map(str::to_string),
title: None,
};
let revision = |referenced: Option<&str>, found: &Cached| revision_check(referenced, found, &[]);
assert_eq!(revision(None, &Cached::Unread), None);
assert_eq!(revision(Some("3"), &Cached::Unread), None);
assert_eq!(revision(None, &read(None)), None);
assert_eq!(revision(Some("3"), &read(Some("3"))), None);
assert_eq!(
revision(Some(" 3 "), &read(Some("3"))),
None,
"whitespace is not a revision"
);
let issue = |kind| RefIssue {
source: "eml:///resqml22.HorizonInterpretation(a)".into(),
path: "InterpretedFeature".into(),
target_uuid: "b".into(),
declared_type: None,
kind,
};
let differ = revision(Some("2"), &read(Some("3"))).expect("a finding");
assert_eq!(
differ,
RefIssueKind::ObjectVersionMismatch {
referenced: Some("2".into()),
found: Some("3".into()),
candidates: vec![]
}
);
assert!(issue(differ.clone()).is_error());
assert!(issue(differ).to_string().contains("`2`"));
let named_none = revision(Some("2"), &read(None)).expect("a finding");
assert!(
issue(named_none).is_error(),
"a revision the object does not carry is promised"
);
let silent = revision(None, &read(Some("0"))).expect("a finding");
assert_eq!(
silent,
RefIssueKind::ObjectVersionMismatch {
referenced: None,
found: Some("0".into()),
candidates: vec![]
}
);
assert!(!issue(silent.clone()).is_error());
assert!(!issue(silent.clone()).is_info());
assert!(issue(silent).to_string().contains("assumes the latest"));
}
#[test]
fn a_title_is_a_copy_and_a_stale_one_is_said_without_being_an_error() {
let read = |title: Option<&str>| Cached::Read {
object_version: None,
title: title.map(str::to_string),
};
assert_eq!(title_check(Some("The base"), &Cached::Unread), None);
assert_eq!(
title_check(Some("The base"), &read(None)),
None,
"no original, no stale copy"
);
assert_eq!(
title_check(None, &read(Some(" "))),
None,
"a blank title is not a title"
);
assert_eq!(title_check(Some("The base"), &read(Some("The base"))), None);
assert_eq!(
title_check(Some(" The base "), &read(Some("The base"))),
None,
"whitespace is not a name"
);
let issue = |kind| RefIssue {
source: "eml:///eml23.LocalEngineeringCompoundCrs(a)".into(),
path: "VerticalCrs".into(),
target_uuid: "b".into(),
declared_type: None,
kind,
};
let empty = title_check(None, &read(Some("Default local time CRS"))).expect("un constat");
assert_eq!(
empty,
RefIssueKind::TitleMismatch {
referenced: None,
found: "Default local time CRS".into()
}
);
assert!(
!issue(empty.clone()).is_error(),
"the uuid resolves: this is not an error"
);
assert!(!issue(empty.clone()).is_info());
assert!(issue(empty).to_string().contains("no title"));
let stale = title_check(Some("Ancien nom"), &read(Some("Nouveau nom"))).expect("un constat");
assert!(issue(stale).to_string().contains("`Ancien nom`"));
}
#[test]
fn a_property_kind_is_not_expected_to_be_in_the_package() {
for declared in [
"eml20.PropertyKind",
"eml23.PropertyKind",
"EML23.propertykind",
"PropertyKind",
] {
assert!(is_externally_defined(declared), "{declared}");
}
for declared in [
"resqml22.WellboreFrameRepresentation",
"eml23.PropertyKindDictionary",
"eml23.ColumnBasedTable",
] {
assert!(!is_externally_defined(declared), "{declared}");
}
}
#[test]
fn an_absent_node_says_whether_its_absence_means_anything() {
let held = summary(
"00000000-0000-4000-8000-000000000001",
"resqml22.ContinuousProperty",
);
let kind = "eml:///eml23.PropertyKind(00000000-0000-4000-8000-000000000002)";
let gone = "eml:///resqml22.HorizonInterpretation(00000000-0000-4000-8000-000000000003)";
let graph = assemble(
[&held],
&|_| None,
[
Edge::new(held.uri.clone(), kind, "PropertyKind"),
Edge::new(held.uri.clone(), gone, "Represents"),
],
);
let node = |uri: &str| graph.nodes.iter().find(|n| n.uri == uri).expect(uri);
assert!(!node(kind).present && node(kind).externally_defined);
assert!(!node(gone).present && !node(gone).externally_defined);
assert!(node(&held.uri).present && !node(&held.uri).externally_defined);
assert!(graph.to_json().contains("\"externallyDefined\": true"));
}
#[test]
fn a_catalogue_reference_is_neither_error_nor_warning() {
let issue = |kind| RefIssue {
source: "eml:///resqml22.ContinuousProperty(a)".into(),
path: "PropertyKind".into(),
target_uuid: "00000000-0000-4000-8000-000000000003".into(),
declared_type: Some("eml23.PropertyKind".into()),
kind,
};
let catalogue = issue(RefIssueKind::ExternallyDefined);
assert!(!catalogue.is_error());
assert!(catalogue.is_info());
let version = issue(RefIssueKind::VersionMismatch {
found: "eml20.PropertyKind".into(),
candidates: vec![],
});
assert!(!version.is_error());
assert!(!version.is_info());
assert!(issue(RefIssueKind::Dangling).is_error());
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RelIssue {
pub part: String,
pub target: String,
pub kind: RelIssueKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RelIssueKind {
Undeclared {
path: String,
},
Unbacked {
id: String,
},
Missing {
id: String,
},
UnbackedSource {
id: String,
},
Reversed {
id: String,
declared: String,
},
}
impl fmt::Display for RelIssue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.kind {
RelIssueKind::Undeclared { path } => write!(
f,
"{}: references {} at `{path}`, and no `destinationObject` relationship declares it",
self.part, self.target
),
RelIssueKind::Unbacked { id } => write!(
f,
"{}: `{id}` declares a link to {}, which the object itself does not reference",
self.part, self.target
),
RelIssueKind::Missing { id } => write!(
f,
"{}: `{id}` points at {}, which is not a part of this package",
self.part, self.target
),
RelIssueKind::UnbackedSource { id } => write!(
f,
"{}: `{id}` says {} references it, and it does not",
self.part, self.target
),
RelIssueKind::Reversed { id, declared } => write!(
f,
"{}: `{id}` is written `{declared}` and the link goes the other way",
self.part
),
}
}
}
pub struct Related<'a> {
pub summary: &'a ObjectSummary,
pub object: &'a dyn Reflect,
pub relationships: &'a [Relationship],
}
pub fn check_relationships(objects: &[Related<'_>]) -> Vec<RelIssue> {
let leaf = |part: &str| -> String {
part.rsplit(['/', '\\'])
.next()
.unwrap_or(part)
.to_ascii_lowercase()
};
let mut by_part: std::collections::HashMap<String, &ObjectSummary> = std::collections::HashMap::new();
for item in objects {
by_part.insert(leaf(&item.summary.locator), item.summary);
}
let mut by_uuid: std::collections::HashMap<String, &ObjectSummary> = std::collections::HashMap::new();
for item in objects {
by_uuid
.entry(item.summary.uuid.to_ascii_lowercase())
.or_insert(item.summary);
}
let nested = nested_index(objects.iter().map(|item| (item.summary, item.object)));
let mut references: std::collections::HashMap<String, Vec<(String, String)>> =
std::collections::HashMap::new();
for item in objects {
let mut edges: Vec<(String, String)> = identity::collect_refs(item.object)
.into_iter()
.filter(|edge| !edge.target.uuid.is_empty())
.filter_map(|edge| {
let uuid = edge.target.uuid.to_ascii_lowercase();
match nested.get(&uuid) {
Some((_, container, _)) if *container == item.summary.uuid.to_ascii_lowercase() => None,
Some((_, container, _)) => Some((container.clone(), edge.path.clone())),
None => Some((uuid, edge.path.clone())),
}
})
.collect();
edges.sort();
edges.dedup_by(|a, b| a.0 == b.0);
references.insert(item.summary.uuid.to_ascii_lowercase(), edges);
}
let mut out = Vec::new();
for item in objects {
let part = &item.summary.locator;
let mine = references
.get(&item.summary.uuid.to_ascii_lowercase())
.cloned()
.unwrap_or_default();
let mut declared: std::collections::HashSet<String> = std::collections::HashSet::new();
for relationship in item.relationships {
if relationship.external {
continue;
}
let kind = relationship.short_kind().to_string();
let kind = match kind.as_str() {
"mlToExternalPartProxy" => "destinationObject".to_string(),
"externalPartProxyToMl" => "sourceObject".to_string(),
_ => kind,
};
if kind != "destinationObject" && kind != "sourceObject" {
continue;
}
let Some(target) = by_part.get(&leaf(&relationship.target)) else {
out.push(RelIssue {
part: part.clone(),
target: relationship.target.clone(),
kind: RelIssueKind::Missing {
id: relationship.id.clone(),
},
});
continue;
};
let target_uuid = target.uuid.to_ascii_lowercase();
let me = item.summary.uuid.to_ascii_lowercase();
let forward = mine.iter().any(|(uuid, _)| *uuid == target_uuid);
let backward = references
.get(&target_uuid)
.is_some_and(|edges| edges.iter().any(|(uuid, _)| *uuid == me));
if kind == "destinationObject" {
declared.insert(target_uuid.clone());
if !forward {
out.push(RelIssue {
part: part.clone(),
target: target.uri.clone(),
kind: if backward {
RelIssueKind::Reversed {
id: relationship.id.clone(),
declared: kind,
}
} else {
RelIssueKind::Unbacked {
id: relationship.id.clone(),
}
},
});
}
} else if !backward {
if forward {
declared.insert(target_uuid.clone());
}
out.push(RelIssue {
part: part.clone(),
target: target.uri.clone(),
kind: if forward {
RelIssueKind::Reversed {
id: relationship.id.clone(),
declared: kind,
}
} else {
RelIssueKind::UnbackedSource {
id: relationship.id.clone(),
}
},
});
}
}
for (uuid, path) in &mine {
if declared.contains(uuid) {
continue;
}
let Some(target) = by_uuid.get(uuid) else {
continue;
};
out.push(RelIssue {
part: part.clone(),
target: target.uri.clone(),
kind: RelIssueKind::Undeclared { path: path.clone() },
});
}
}
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Node {
pub uri: String,
pub uuid: String,
pub qualified_type: String,
pub type_key: String,
pub title: Option<String>,
pub present: bool,
pub externally_defined: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Edge {
pub from: String,
pub to: String,
pub path: String,
pub kind: Option<EdgeKind>,
pub basis: Option<KindBasis>,
pub by_value: bool,
}
impl Edge {
pub fn new(from: impl Into<String>, to: impl Into<String>, path: impl Into<String>) -> Self {
Self {
from: from.into(),
to: to.into(),
path: path.into(),
kind: None,
basis: None,
by_value: false,
}
}
pub fn with_kind(mut self, kind: EdgeKind) -> Self {
self.kind = Some(kind);
self.basis = Some(KindBasis::Stated);
self
}
pub fn with_judgement(mut self, judged: Judged) -> Self {
self.kind = judged.kind;
self.basis = Some(judged.basis);
self
}
pub fn contained(mut self) -> Self {
self.by_value = true;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EdgeKind {
Primary,
Secondary,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Graph {
pub nodes: Vec<Node>,
pub edges: Vec<Edge>,
}
struct Held {
at: usize,
qualified_type: String,
version: Option<String>,
}
fn version_of(object: &dyn Reflect) -> Option<String> {
identity::object_version_of_reflect(object)
.map(str::trim)
.filter(|version| !version.is_empty())
.map(str::to_string)
}
fn select(held: &[Held], qualified_type: &str, version: Option<&str>) -> Vec<usize> {
let mut left: Vec<&Held> = held.iter().collect();
if left.iter().any(|one| one.qualified_type == qualified_type) {
left.retain(|one| one.qualified_type == qualified_type);
}
if let Some(wanted) = version {
if left.iter().any(|one| one.version.as_deref() == Some(wanted)) {
left.retain(|one| one.version.as_deref() == Some(wanted));
}
} else if left.len() > 1 {
let bare: Vec<&&Held> = left.iter().filter(|one| one.version.is_none()).collect();
if let [only] = bare[..] {
return vec![only.at];
}
}
left.iter().map(|one| one.at).collect()
}
pub fn build<'a>(objects: impl IntoIterator<Item = (&'a ObjectSummary, &'a dyn Reflect)>) -> Graph {
let items: Vec<(&ObjectSummary, &dyn Reflect)> = objects.into_iter().collect();
let mut nodes: Vec<Node> = Vec::with_capacity(items.len());
let mut index: std::collections::HashMap<String, Vec<Held>> = std::collections::HashMap::new();
for (summary, object) in &items {
let key = summary.uuid.to_ascii_lowercase();
index.entry(key).or_default().push(Held {
at: nodes.len(),
qualified_type: summary.qualified_type.clone(),
version: version_of(*object),
});
nodes.push(Node {
uri: summary.uri.clone(),
uuid: summary.uuid.clone(),
qualified_type: summary.qualified_type.clone(),
type_key: identity::parse_qualified_type(&summary.qualified_type)
.map(|parts| naming::strip_obj_prefix(parts.object_type).to_string())
.unwrap_or_else(|_| summary.qualified_type.clone()),
title: title_of(*object),
present: true,
externally_defined: is_externally_defined(&summary.qualified_type),
});
}
let nested = nested_index(items.iter().copied());
let mut edges = Vec::new();
for (summary, object) in &items {
let dataspace = dataspace_of(&summary.uri);
for edge in identity::collect_refs(*object) {
if edge.target.uuid.is_empty() {
continue;
}
let key = edge.target.uuid.to_ascii_lowercase();
if let Some((_, container, _)) = nested.get(&key) {
if *container == summary.uuid.to_ascii_lowercase() {
continue;
}
if let Some(at) = index
.get(container)
.and_then(|held| held.first())
.map(|held| held.at)
{
edges.push(Edge::new(
summary.uri.clone(),
nodes[at].uri.clone(),
edge.path.clone(),
));
continue;
}
}
let wanted_version = edge
.target
.object_version
.map(str::trim)
.filter(|version| !version.is_empty());
let held = index.entry(key).or_default();
if held.is_empty() {
let qualified_type = edge.target.qualified_type();
held.push(Held {
at: nodes.len(),
qualified_type: qualified_type.clone(),
version: wanted_version.map(str::to_string),
});
nodes.push(Node {
uri: identifiers::EtpUri::object_versioned(
dataspace.as_deref(),
&qualified_type,
edge.target.uuid,
wanted_version,
),
uuid: edge.target.uuid.to_string(),
type_key: naming::strip_obj_prefix(edge.target.object_type).to_string(),
externally_defined: is_externally_defined(&qualified_type),
qualified_type,
title: edge.target.title.map(str::to_string),
present: false,
});
}
for at in select(held, &edge.target.qualified_type(), wanted_version) {
edges.push(Edge::new(
summary.uri.clone(),
nodes[at].uri.clone(),
edge.path.clone(),
));
}
}
}
edges.sort_by(|a, b| (&a.from, &a.to, &a.path).cmp(&(&b.from, &b.to, &b.path)));
edges.dedup();
Graph { nodes, edges }
}
pub fn assemble<'a>(
inventory: impl IntoIterator<Item = &'a ObjectSummary>,
titles: &dyn Fn(&str) -> Option<String>,
edges: impl IntoIterator<Item = Edge>,
) -> Graph {
let mut nodes: Vec<Node> = Vec::new();
let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for summary in inventory {
index.insert(summary.uri.clone(), nodes.len());
nodes.push(Node {
uri: summary.uri.clone(),
uuid: summary.uuid.clone(),
qualified_type: summary.qualified_type.clone(),
type_key: identity::parse_qualified_type(&summary.qualified_type)
.map(|parts| naming::strip_obj_prefix(parts.object_type).to_string())
.unwrap_or_else(|_| summary.qualified_type.clone()),
title: titles(&summary.uri),
present: true,
externally_defined: is_externally_defined(&summary.qualified_type),
});
}
let mut collected: Vec<Edge> = Vec::new();
for edge in edges {
for uri in [&edge.from, &edge.to] {
if index.contains_key(uri) {
continue;
}
let parsed = identifiers::EtpUri::parse(uri).ok();
let qualified_type = parsed
.as_ref()
.and_then(|u| u.qualified_type())
.map(|q| q.to_string())
.unwrap_or_default();
let uuid = parsed.as_ref().and_then(|u| u.uuid.clone()).unwrap_or_default();
index.insert(uri.clone(), nodes.len());
nodes.push(Node {
uri: uri.clone(),
uuid,
type_key: identity::parse_qualified_type(&qualified_type)
.map(|parts| naming::strip_obj_prefix(parts.object_type).to_string())
.unwrap_or_else(|_| qualified_type.clone()),
externally_defined: is_externally_defined(&qualified_type),
qualified_type,
title: None,
present: false,
});
}
collected.push(edge);
}
collected.sort_by(|a, b| (&a.from, &a.to, &a.path).cmp(&(&b.from, &b.to, &b.path)));
collected.dedup();
Graph {
nodes,
edges: collected,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pointer {
pub uri: String,
pub uuid: String,
pub qualified_type: String,
pub title: Option<String>,
pub path: String,
}
pub fn attribute_to_writers<'a>(
edges: Vec<Edge>,
objects: impl IntoIterator<Item = (&'a ObjectSummary, &'a dyn Reflect)>,
contained: &[ObjectSummary],
) -> Vec<Edge> {
let objects: Vec<(&ObjectSummary, &dyn Reflect)> = objects.into_iter().collect();
let by_uri: std::collections::HashMap<&str, &ObjectSummary> = contained
.iter()
.map(|summary| (summary.uri.as_str(), summary))
.collect();
let mut writers: std::collections::HashMap<&str, Vec<(String, String)>> =
std::collections::HashMap::new();
for (part, object) in &objects {
let mut found = Vec::new();
for nested in identity::nested_objects(*object) {
let uuid = nested.key.uuid.to_ascii_lowercase();
let listed = contained
.iter()
.find(|entry| entry.uuid.to_ascii_lowercase() == uuid && within(&by_uri, entry, &part.uri))
.or_else(|| {
objects
.iter()
.map(|(summary, _)| *summary)
.find(|summary| summary.uri != part.uri && summary.uuid.to_ascii_lowercase() == uuid)
});
if let Some(listed) = listed {
found.push((nested.path, listed.uri.clone()));
}
}
found.sort_by_key(|(path, _)| std::cmp::Reverse(path.len()));
writers.insert(part.uri.as_str(), found);
}
edges
.into_iter()
.map(|mut edge| {
let writer = writers.get(edge.from.as_str()).and_then(|found| {
found.iter().find(|(path, _)| {
edge.path.len() > path.len()
&& edge.path.starts_with(path.as_str())
&& edge.path.as_bytes()[path.len()] == b'.'
})
});
if let Some((path, uri)) = writer {
edge.path = edge.path[path.len() + 1..].to_string();
edge.from = uri.clone();
}
edge
})
.collect()
}
pub(crate) fn within(
contained: &std::collections::HashMap<&str, &ObjectSummary>,
summary: &ObjectSummary,
container: &str,
) -> bool {
let mut seen = std::collections::HashSet::new();
let mut up: Vec<&str> = summary.contained_in.iter().map(String::as_str).collect();
while let Some(uri) = up.pop() {
if uri == container {
return true;
}
if !seen.insert(uri) {
continue;
}
if let Some(entry) = contained.get(uri) {
up.extend(entry.contained_in.iter().map(String::as_str));
}
}
false
}
pub(crate) fn parts_holding(
contained: &std::collections::HashMap<&str, &ObjectSummary>,
summary: &ObjectSummary,
) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut parts = Vec::new();
let mut up: Vec<&str> = summary.contained_in.iter().map(String::as_str).collect();
while let Some(uri) = up.pop() {
if !seen.insert(uri) {
continue;
}
match contained.get(uri) {
Some(entry) => up.extend(entry.contained_in.iter().map(String::as_str)),
None => parts.push(uri.to_string()),
}
}
parts
}
pub fn references(object: &dyn Reflect) -> Vec<Pointer> {
references_in(object, None)
}
pub fn references_in(object: &dyn Reflect, dataspace: Option<&str>) -> Vec<Pointer> {
identity::collect_refs(object)
.into_iter()
.filter(|edge| !edge.target.uuid.is_empty())
.map(|edge| {
let qualified_type = edge.target.qualified_type();
Pointer {
uri: edge.target.etp_uri(dataspace),
uuid: edge.target.uuid.to_string(),
qualified_type,
title: edge.target.title.map(str::to_string),
path: edge.path.clone(),
}
})
.collect()
}
pub fn dependents<'a>(
objects: impl IntoIterator<Item = (&'a ObjectSummary, &'a dyn Reflect)>,
uuid: &str,
) -> Vec<Pointer> {
let wanted = uuid.to_ascii_lowercase();
let mut out = Vec::new();
for (summary, object) in objects {
if summary.uuid.to_ascii_lowercase() == wanted {
continue;
}
for edge in identity::collect_refs(object) {
if edge.target.uuid.to_ascii_lowercase() != wanted {
continue;
}
out.push(Pointer {
uri: summary.uri.clone(),
uuid: summary.uuid.clone(),
qualified_type: summary.qualified_type.clone(),
title: title_of(object),
path: edge.path.clone(),
});
}
}
out
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Closure {
pub needed: Vec<String>,
pub missing: Vec<String>,
}
pub fn closure(graph: &Graph, seeds: &[String]) -> Closure {
let mut out_edges: std::collections::HashMap<&str, Vec<&Edge>> = std::collections::HashMap::new();
for edge in &graph.edges {
out_edges.entry(edge.from.as_str()).or_default().push(edge);
}
let node_of: std::collections::HashMap<&str, &Node> =
graph.nodes.iter().map(|node| (node.uri.as_str(), node)).collect();
let mut reached: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut missing: Vec<String> = Vec::new();
let mut stack: Vec<String> = Vec::new();
for seed in seeds {
if node_of.get(seed.as_str()).is_some_and(|node| node.present) && reached.insert(seed.clone()) {
stack.push(seed.clone());
}
}
while let Some(here) = stack.pop() {
for edge in out_edges.get(here.as_str()).into_iter().flatten() {
match node_of.get(edge.to.as_str()) {
Some(node) if node.present => {
if reached.insert(edge.to.clone()) {
stack.push(edge.to.clone());
}
}
Some(node) if !node.externally_defined && !missing.contains(&edge.to) => {
missing.push(edge.to.clone());
}
_ => {}
}
}
}
Closure {
needed: graph
.nodes
.iter()
.filter(|node| reached.contains(&node.uri))
.map(|node| node.uri.clone())
.collect(),
missing,
}
}
pub fn title_of(object: &dyn Reflect) -> Option<String> {
let energyml_core::value::FieldValue::Object(citation) = object.field("Citation")? else {
return None;
};
match citation.field("Title")? {
energyml_core::value::FieldValue::Scalar(scalar) => {
scalar.as_str().map(str::to_string).filter(|t| !t.is_empty())
}
_ => None,
}
}
impl Graph {
pub fn to_json(&self) -> String {
let string = |value: &str| -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for c in value.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
};
let mut out = String::from("{\n \"nodes\": [\n");
for (n, node) in self.nodes.iter().enumerate() {
out.push_str(" {");
out.push_str(&format!("\"uri\": {}, ", string(&node.uri)));
out.push_str(&format!("\"uuid\": {}, ", string(&node.uuid)));
out.push_str(&format!("\"type\": {}, ", string(&node.qualified_type)));
out.push_str(&format!("\"concept\": {}, ", string(&node.type_key)));
match &node.title {
Some(title) => out.push_str(&format!("\"title\": {}, ", string(title))),
None => out.push_str("\"title\": null, "),
}
out.push_str(&format!("\"present\": {}, ", node.present));
out.push_str(&format!("\"externallyDefined\": {}", node.externally_defined));
out.push('}');
if n + 1 < self.nodes.len() {
out.push(',');
}
out.push('\n');
}
out.push_str(" ],\n \"edges\": [\n");
for (n, edge) in self.edges.iter().enumerate() {
out.push_str(&format!(
" {{\"from\": {}, \"to\": {}, \"path\": {}}}",
string(&edge.from),
string(&edge.to),
string(&edge.path)
));
if n + 1 < self.edges.len() {
out.push(',');
}
out.push('\n');
}
out.push_str(" ]\n}\n");
out
}
pub fn to_dot(&self) -> String {
let escape = |value: &str| value.replace('"', "\\\"");
let mut out = String::from("digraph energyml {\n rankdir=LR;\n node [shape=box];\n");
for node in &self.nodes {
let label = match &node.title {
Some(title) => format!("{}\\n{}", escape(title), escape(&node.type_key)),
None => escape(&node.type_key),
};
let style = if node.present {
String::new()
} else {
", style=dashed".to_string()
};
out.push_str(&format!(
" \"{}\" [label=\"{label}\"{style}];\n",
escape(&node.uri)
));
}
for edge in &self.edges {
out.push_str(&format!(
" \"{}\" -> \"{}\" [label=\"{}\"];\n",
escape(&edge.from),
escape(&edge.to),
escape(&edge.path)
));
}
out.push_str("}\n");
out
}
}