energyml_storage/graph.rs
1//! Checking that the references inside a package point somewhere.
2//!
3//! A `DataObjectReference` carries three things: a uuid, a declared type, and a
4//! title. Nothing keeps them in agreement with the object they name - the uuid
5//! can point at a part that was never packaged, the declared type can be the
6//! one the writer *thought* it was, and both mistakes survive every XSD
7//! validator ever written, because each reference is well-formed on its own.
8//!
9//! These are the checks a `.rels` file cannot make either: the relationship
10//! system is an index built by the same tool that got the reference wrong.
11//! What an object actually references comes from its DORs, which is why this
12//! walks reflection rather than reading `.rels` (see
13//! ADR-0002: the `.rels` speed things up, the DORs are authoritative).
14//!
15//! Free functions over objects **already in memory**, deliberately: no store,
16//! no I/O, no async colour. The caller reads, this decides.
17
18use std::fmt;
19
20use energyml_core::identifiers;
21use energyml_core::identity;
22use energyml_core::naming;
23use energyml_core::value::Reflect;
24
25use crate::kinds::{Judged, KindBasis};
26use crate::resource::{ObjectSummary, Relationship};
27
28/// Something wrong with one reference.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct RefIssue {
31 /// URI of the object holding the reference.
32 pub source: String,
33 /// Where inside it: `Grid2dPatch.SupportingRepresentation`.
34 pub path: String,
35 /// The uuid the reference names.
36 pub target_uuid: String,
37 /// The type the reference claims its target has, when it declares one.
38 pub declared_type: Option<String>,
39 /// What is wrong.
40 pub kind: RefIssueKind,
41}
42
43/// The four ways a reference is worth reporting.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum RefIssueKind {
46 /// No object with that uuid in the package.
47 ///
48 /// Not always an error: an EPC may legitimately reference an object held
49 /// elsewhere. It is always worth knowing, and it is what breaks a viewer
50 /// that assumes a package is self-contained.
51 Dangling,
52 /// The target is absent, and absence is not evidence of anything: the
53 /// concept it names is defined in a catalogue Energistics publishes, not
54 /// in a dataspace.
55 ///
56 /// `PropertyKind` is the case, and it is not marginal. The dictionary
57 /// shipped with the standard defines several hundred of them and nothing
58 /// asks a package to embed the ones it uses - a package of 120 wellbores
59 /// produced 1 086 of these and not one genuine dangling reference, which
60 /// is precisely how a real one gets buried.
61 ///
62 /// Whether the uuid *is* in the dictionary is a different question, and it
63 /// belongs to the layer that owns the dictionary
64 /// (`energyml_domain::property_kind::Resolver`, which `energyml check`
65 /// already runs). This variant only says: not being here proves nothing.
66 ExternallyDefined,
67 /// The target exists but is a different *concept* - the reference says
68 /// `WellboreInterpretation`, the part is a `HorizonInterpretation`.
69 ///
70 /// Always a real error: whatever reads this will follow the reference and
71 /// find something it cannot use.
72 ConceptMismatch {
73 /// Qualified type of the nearest object found.
74 found: String,
75 /// The **full uri of every object that carries the uuid** - none of them
76 /// is what the reference names. The message built from them reads
77 /// 'uuid found but type or version should be one of the following: '
78 /// followed by the uris of the objects that carry the right uuid but
79 /// not the right type or version number. Sorted.
80 candidates: Vec<String>,
81 },
82 /// Same concept, different version - `resqml20.obj_Activity` naming a
83 /// `resqml22.Activity`.
84 ///
85 /// A warning, not an error. A package may mix versions (EPC §3.2), and a
86 /// reader that matches on uuid finds the right object; but a reader that
87 /// trusts the declared type to pick a parser does not.
88 VersionMismatch {
89 /// Qualified type of the nearest object found.
90 found: String,
91 /// The full uri of every object that carries the uuid, sorted.
92 candidates: Vec<String>,
93 },
94 /// The right object, at another revision: the reference's
95 /// `ObjectVersion` and the object's `objectVersion` disagree.
96 ///
97 /// An **error** when the reference names a revision - one the object is
98 /// not at, or one where the object carries none: whatever follows it is
99 /// promised a revision the package does not hold. A **warning** when the
100 /// reference names none and the object does: the Identifier
101 /// Specification has a reader assume *the latest*, which is what the
102 /// package holds, so nothing breaks - but a package that stamps every
103 /// object with a version and writes its references without one says two
104 /// things, and the Webstudio flags it. It was exactly
105 /// what our own translation produced, and this checker said nothing.
106 ObjectVersionMismatch {
107 /// What the reference says, when it says anything.
108 referenced: Option<String>,
109 /// What the object carries, when it carries anything. `None` also when
110 /// several entries of the uuid and type are held and none is *the*
111 /// object - see `candidates`.
112 found: Option<String>,
113 /// The full uri of every object that carries the uuid, sorted. A
114 /// reference resolves **exactly** - by uuid, type and version - and when
115 /// nothing answers, this is the list a person picks from.
116 candidates: Vec<String>,
117 },
118 /// The right object, under another name: the reference's cached `Title`
119 /// and the object's citation title disagree.
120 ///
121 /// A **warning**, never an error: a reader resolves by uuid and finds the
122 /// right object, so nothing breaks. What it does break is every list built
123 /// from references rather than from the objects - a graph, a tree, a
124 /// picker - which then shows a name the package no longer uses, or no name
125 /// at all. The Webstudio reports it, and this checker did not (seen on a
126 /// package our own translation wrote: the two references of a
127 /// `LocalEngineeringCompoundCrs` carried an empty title).
128 TitleMismatch {
129 /// What the reference calls it, when it calls it anything.
130 referenced: Option<String>,
131 /// What the object calls itself.
132 found: String,
133 },
134 /// The reference names a type **no schema this build carries defines** -
135 /// well formed (`resqml22.NoSuchThing` matches the `QualifiedType`
136 /// pattern) and pointing at nothing a reader could ever open.
137 ///
138 /// A **warning**: the pattern accepts any two-digit version, so a package
139 /// written against a release this build does not carry says the same thing.
140 /// It is the one reference finding no store can make - it needs the
141 /// registry of types, which is why it is [`check_declared_types`] and not
142 /// part of [`check_references`].
143 UnknownType,
144}
145
146impl RefIssue {
147 /// Whether this breaks the package, as opposed to merely being odd.
148 pub fn is_error(&self) -> bool {
149 matches!(
150 self.kind,
151 RefIssueKind::Dangling
152 | RefIssueKind::ConceptMismatch { .. }
153 | RefIssueKind::ObjectVersionMismatch {
154 referenced: Some(_),
155 ..
156 }
157 )
158 }
159
160 /// Whether this is worth printing but says nothing is wrong.
161 ///
162 /// Separate from `!is_error()`: a `VersionMismatch` is a warning about the
163 /// *package*, an `ExternallyDefined` is a remark about how the standard is
164 /// published. Counting them together would put a thousand remarks in the
165 /// warning column and hide the handful that mean something.
166 pub fn is_info(&self) -> bool {
167 matches!(self.kind, RefIssueKind::ExternallyDefined)
168 }
169}
170
171impl fmt::Display for RefIssue {
172 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173 write!(f, "{}: ", self.path)?;
174 match &self.kind {
175 RefIssueKind::Dangling => match &self.declared_type {
176 Some(declared) => {
177 write!(
178 f,
179 "references {} ({}), which is not in this package",
180 self.target_uuid, declared
181 )
182 }
183 None => write!(f, "references {}, which is not in this package", self.target_uuid),
184 },
185 RefIssueKind::ExternallyDefined => write!(
186 f,
187 "references {} ({}), which the Energistics catalogue defines - a package need not carry it",
188 self.target_uuid,
189 self.declared_type.as_deref().unwrap_or("?")
190 ),
191 RefIssueKind::ConceptMismatch { found, candidates } => write!(
192 f,
193 "references {} as `{}`, but that object is a `{found}`{}",
194 self.target_uuid,
195 self.declared_type.as_deref().unwrap_or("?"),
196 should_be_one_of(candidates)
197 ),
198 RefIssueKind::VersionMismatch { found, candidates } => write!(
199 f,
200 "references {} as `{}`, but that object is a `{found}` - same concept, other version{}",
201 self.target_uuid,
202 self.declared_type.as_deref().unwrap_or("?"),
203 should_be_one_of(candidates)
204 ),
205 RefIssueKind::ObjectVersionMismatch {
206 referenced: Some(referenced),
207 found: Some(found),
208 candidates,
209 } => write!(
210 f,
211 "references {} at version `{referenced}`, but that object is at version `{found}`{}",
212 self.target_uuid,
213 should_be_one_of(candidates)
214 ),
215 RefIssueKind::ObjectVersionMismatch {
216 referenced: Some(referenced),
217 found: None,
218 candidates,
219 } => write!(
220 f,
221 "references {} at version `{referenced}`, but {}{}",
222 self.target_uuid,
223 if candidates.len() > 1 {
224 "no object of that uuid and type carries it"
225 } else {
226 "that object carries no objectVersion"
227 },
228 should_be_one_of(candidates)
229 ),
230 RefIssueKind::ObjectVersionMismatch {
231 referenced: None,
232 found,
233 candidates,
234 } if candidates.len() > 1 => write!(
235 f,
236 "references {} without an ObjectVersion, and {} objects of that uuid and type are held{}{}",
237 self.target_uuid,
238 candidates.len(),
239 found
240 .as_deref()
241 .map(|version| format!(" (the nearest at version `{version}`)"))
242 .unwrap_or_default(),
243 should_be_one_of(candidates)
244 ),
245 RefIssueKind::ObjectVersionMismatch {
246 referenced: None,
247 found,
248 ..
249 } => write!(
250 f,
251 "references {} without an ObjectVersion, and that object is at version `{}` - a reader assumes the latest, which is this one; say it",
252 self.target_uuid,
253 found.as_deref().unwrap_or("?")
254 ),
255 RefIssueKind::TitleMismatch {
256 referenced: Some(referenced),
257 found,
258 } => write!(
259 f,
260 "calls {} `{referenced}`, and that object calls itself `{found}` - the title in a reference is a copy, and this one is stale",
261 self.target_uuid
262 ),
263 RefIssueKind::TitleMismatch {
264 referenced: None,
265 found,
266 } => write!(
267 f,
268 "references {} with no title, and that object is called `{found}` - the title in a reference is a copy, and this one is empty",
269 self.target_uuid
270 ),
271 RefIssueKind::UnknownType => write!(
272 f,
273 "references {} as `{}`, a type no schema known to this build defines",
274 self.target_uuid,
275 self.declared_type.as_deref().unwrap_or("?")
276 ),
277 }
278 }
279}
280
281/// The sentence that names the candidates,
282/// or nothing when there are none to name.
283fn should_be_one_of(candidates: &[String]) -> String {
284 if candidates.is_empty() {
285 String::new()
286 } else {
287 format!(
288 ": uuid found but type or version should be one of the following: {}",
289 candidates.join(", ")
290 )
291 }
292}
293
294/// What a reference caches about its target, as the **target** states it.
295///
296/// Two answers and not an `Option`, because *not read* and *states none* are
297/// different news: the first says nothing, the second is compared. A level-1
298/// listing reads no part, so a caller with only summaries answers
299/// [`Cached::Unread`] and gets no finding about either field.
300#[derive(Debug, Clone, PartialEq, Eq)]
301pub enum Cached {
302 /// The object was not read: nothing to compare, nothing said.
303 Unread,
304 /// Read, and this is what it says of itself.
305 Read {
306 /// Its `objectVersion`, `None` when it carries none.
307 object_version: Option<String>,
308 /// Its citation title, `None` when it has none.
309 title: Option<String>,
310 },
311}
312
313/// The reference's `ObjectVersion` against the object's - see
314/// [`RefIssueKind::ObjectVersionMismatch`] for which way each case goes.
315fn revision_check(referenced: Option<&str>, found: &Cached, candidates: &[String]) -> Option<RefIssueKind> {
316 let Cached::Read {
317 object_version: found,
318 ..
319 } = found
320 else {
321 return None;
322 };
323 let referenced = referenced.map(str::trim).filter(|v| !v.is_empty());
324 let found = found.as_deref().map(str::trim).filter(|v| !v.is_empty());
325 match (referenced, found) {
326 (None, None) => None,
327 (Some(r), Some(f)) if r == f => None,
328 (r, f) => Some(RefIssueKind::ObjectVersionMismatch {
329 referenced: r.map(str::to_string),
330 found: f.map(str::to_string),
331 candidates: candidates.to_vec(),
332 }),
333 }
334}
335
336/// The `objectVersion` an entry states, as the target says of itself: the
337/// outer `None` is *not read* (nothing can be compared), the inner one *carries
338/// none*.
339fn stated_version(
340 target: &ObjectSummary,
341 cached_of: &dyn Fn(&ObjectSummary) -> Cached,
342) -> Option<Option<String>> {
343 match cached_of(target) {
344 // Not read - but the store spells **the version into the uri** of an entry
345 // that declares one, so a uri that carries a version says it. One that
346 // carries none says nothing: *not read* and *states none* differ.
347 Cached::Unread => match identity::ObjectId::parse(&target.uri) {
348 Ok((_, id))
349 if id
350 .object_version
351 .as_deref()
352 .is_some_and(|version| !version.trim().is_empty()) =>
353 {
354 Some(id.object_version.map(|version| version.trim().to_string()))
355 }
356 _ => None,
357 },
358 Cached::Read { object_version, .. } => Some(
359 object_version
360 .map(|version| version.trim().to_string())
361 .filter(|version| !version.is_empty()),
362 ),
363 }
364}
365
366/// The reference's `Title` against the object's citation title.
367///
368/// Nothing is said when the target states no title of its own: there is then
369/// no original for the copy to disagree with, and a reference that names one
370/// anyway is the only record of a name the package has otherwise lost.
371fn title_check(referenced: Option<&str>, found: &Cached) -> Option<RefIssueKind> {
372 let Cached::Read {
373 title: Some(found), ..
374 } = found
375 else {
376 return None;
377 };
378 let found = found.trim();
379 if found.is_empty() {
380 return None;
381 }
382 let referenced = referenced.map(str::trim).filter(|value| !value.is_empty());
383 match referenced {
384 Some(value) if value == found => None,
385 other => Some(RefIssueKind::TitleMismatch {
386 referenced: other.map(str::to_string),
387 found: found.to_string(),
388 }),
389 }
390}
391
392/// Check every reference one object holds against what the package contains.
393///
394/// `known` answers "is there an object with this uuid, and what is it?" - a
395/// closure rather than a store, so this function has no idea whether the
396/// package is a file, a dataspace or a test fixture. Its lifetime is named
397/// because elision would otherwise tie the answer to the uuid that was asked
398/// about, which is not where the summary lives.
399///
400/// ```no_run
401/// # use energyml_storage::{EpcStore, graph};
402/// # fn demo(store: &EpcStore, summary: &energyml_storage::ObjectSummary) -> energyml_storage::Result<()> {
403/// let object = store.get_object(&summary.uri)?;
404/// let issues = graph::check_references(summary, object.as_reflect(), &|uuid| store.find_by_uuid(uuid), &|_| graph::Cached::Unread);
405/// # Ok(())
406/// # }
407/// ```
408///
409/// The borrow is not an EPC assumption. A store that hands its inventory over
410/// by value - as [`crate::storage::EnergymlStorage::resources`] does, and as an
411/// ETP client must - indexes the `Vec` it owns and borrows from that:
412///
413/// ```no_run
414/// # use energyml_storage::{ObjectSummary, graph};
415/// # fn demo(resources: Vec<ObjectSummary>, summary: &ObjectSummary, object: &dyn energyml_core::value::Reflect) {
416/// let by_uuid: std::collections::HashMap<String, &ObjectSummary> = resources
417/// .iter()
418/// .map(|s| (s.uuid.to_ascii_lowercase(), s))
419/// .collect();
420/// let issues = graph::check_references(
421/// summary,
422/// object,
423/// &|uuid| by_uuid.get(&uuid.to_ascii_lowercase()).copied(),
424/// &|_| graph::Cached::Unread,
425/// );
426/// # let _ = issues;
427/// # }
428/// ```
429///
430/// `cached_of` answers what the target says of itself - its `objectVersion`
431/// and its title - when the caller has read the object; the summary carries
432/// neither, since a level-1 listing reads no part. [`check_all_references`]
433/// answers it from the objects it is given; a caller with none to hand answers
434/// [`Cached::Unread`] and gets no finding about either.
435pub fn check_references<'i>(
436 source: &ObjectSummary,
437 object: &dyn Reflect,
438 known: &dyn Fn(&str) -> Option<&'i ObjectSummary>,
439 cached_of: &dyn Fn(&str) -> Cached,
440) -> Vec<RefIssue> {
441 check_references_by(
442 source,
443 object,
444 &|uuid| known(uuid).into_iter().collect(),
445 &|target| cached_of(&target.uuid),
446 )
447}
448
449/// [`check_references`] for a package that may hold **one uuid several times** -
450/// under two `objectVersion`s, or two releases. `held` is asked for **every
451/// entry that carries the uuid** the reference names; the check then resolves
452/// the reference **exactly**, by the identity read from it - uuid, qualified
453/// type, `objectVersion` when it carries one - and when nothing answers while
454/// the uuid is held, the finding names every candidate by its full uri.
455/// `cached_of` is asked about an entry and not about its
456/// uuid, so the revision of the reference is compared with the revision of the
457/// object it names rather than with a namesake's.
458pub fn check_references_by<'i>(
459 source: &ObjectSummary,
460 object: &dyn Reflect,
461 held: &dyn Fn(&str) -> Vec<&'i ObjectSummary>,
462 cached_of: &dyn Fn(&ObjectSummary) -> Cached,
463) -> Vec<RefIssue> {
464 let mut out = Vec::new();
465 for edge in identity::collect_refs(object) {
466 let uuid = edge.target.uuid;
467 if uuid.is_empty() {
468 continue;
469 }
470 let declared = edge.target.qualified_type();
471 let issue = |kind| RefIssue {
472 source: source.uri.clone(),
473 path: edge.path.clone(),
474 target_uuid: uuid.to_string(),
475 declared_type: Some(declared.clone()),
476 kind,
477 };
478
479 // Dangling is the one case where the **uuid itself** is absent.
480 let candidates = held(uuid);
481 if candidates.is_empty() {
482 out.push(issue(if is_externally_defined(&declared) {
483 RefIssueKind::ExternallyDefined
484 } else {
485 RefIssueKind::Dangling
486 }));
487 continue;
488 }
489 // Every entry of the uuid, by the uri that names it alone.
490 let mut uris: Vec<String> = candidates.iter().map(|one| one.uri.clone()).collect();
491 uris.sort();
492 uris.dedup();
493 let wanted = edge
494 .target
495 .object_version
496 .map(str::trim)
497 .filter(|version| !version.is_empty());
498
499 // **Exact resolution**: the entries of the right type - *a wrong version
500 // is not a wrong type*, so a release that differs is not exact - and,
501 // among them, the one at the version the reference names.
502 let same_type: Vec<&ObjectSummary> = candidates
503 .iter()
504 .copied()
505 .filter(|one| same_qualified_type(&declared, &one.qualified_type))
506 .collect();
507 match exact_among(&same_type, wanted, cached_of) {
508 Some(target) => {
509 // The revision and the title: the four fields of a reference are
510 // four copies, and each one can be stale on its own.
511 let cached = cached_of(target);
512 if let Some(kind) = revision_check(wanted, &cached, &uris) {
513 out.push(issue(kind));
514 }
515 if let Some(kind) = title_check(edge.target.title, &cached) {
516 out.push(issue(kind));
517 }
518 }
519 // The right type, and no entry at the version named - or several
520 // and none that is *the* one a reference without a version means.
521 None if !same_type.is_empty() => {
522 let sole = if same_type.len() == 1 {
523 Some(same_type[0])
524 } else {
525 None
526 };
527 let found = sole.and_then(|one| stated_version(one, cached_of)).flatten();
528 out.push(issue(RefIssueKind::ObjectVersionMismatch {
529 referenced: wanted.map(str::to_string),
530 found,
531 candidates: uris.clone(),
532 }));
533 if let Some(one) = sole
534 && let Some(kind) = title_check(edge.target.title, &cached_of(one))
535 {
536 out.push(issue(kind));
537 }
538 }
539 // No entry of the uuid has the type. Compare the concept first, the
540 // version second: a package is allowed to mix versions, so
541 // "different version" and "different thing" must not come out as the
542 // same complaint - the nearest candidate decides which this is.
543 None => {
544 let near = candidates
545 .iter()
546 .copied()
547 .find(|one| same_concept(&declared, &one.qualified_type));
548 let kind = match near {
549 Some(one) => RefIssueKind::VersionMismatch {
550 found: one.qualified_type.clone(),
551 candidates: uris.clone(),
552 },
553 None => RefIssueKind::ConceptMismatch {
554 found: candidates[0].qualified_type.clone(),
555 candidates: uris.clone(),
556 },
557 };
558 out.push(issue(kind));
559 // One candidate is *the* object the reference means to name: its
560 // revision and title are still compared.
561 if let [only] = candidates[..] {
562 let cached = cached_of(only);
563 if let Some(kind) = revision_check(wanted, &cached, &uris) {
564 out.push(issue(kind));
565 }
566 if let Some(kind) = title_check(edge.target.title, &cached) {
567 out.push(issue(kind));
568 }
569 }
570 }
571 }
572 }
573 out
574}
575
576/// The entry a reference names **exactly** among the entries of its uuid and
577/// type: the one at the version it states, or - when it states none - the only
578/// one, else the only one that declares none. `None` when nothing answers, and
579/// then nothing is guessed: the finding lists the candidates instead. An entry
580/// whose version was not read is not disqualified (nothing says it differs).
581fn exact_among<'a>(
582 same_type: &[&'a ObjectSummary],
583 wanted: Option<&str>,
584 cached_of: &dyn Fn(&ObjectSummary) -> Cached,
585) -> Option<&'a ObjectSummary> {
586 match wanted {
587 Some(wanted) => same_type
588 .iter()
589 .copied()
590 .find(|one| match stated_version(one, cached_of) {
591 None => true,
592 Some(version) => version.as_deref() == Some(wanted),
593 }),
594 None => match same_type {
595 [only] => Some(*only),
596 many => {
597 let bare: Vec<&'a ObjectSummary> = many
598 .iter()
599 .copied()
600 .filter(|one| matches!(stated_version(one, cached_of), Some(None)))
601 .collect();
602 match bare[..] {
603 [only] => Some(only),
604 _ => None,
605 }
606 }
607 },
608 }
609}
610
611/// Every reference that names a type `knows` does not recognise.
612///
613/// `knows` answers "does any schema define this qualified type?" - a closure,
614/// so that the storage layer keeps no registry of its own. References to a
615/// concept the standard publishes in a catalogue (`PropertyKind` and the like)
616/// are skipped: their types are known, and absence of the object is not news.
617pub fn check_declared_types<'a>(
618 objects: impl IntoIterator<Item = (&'a ObjectSummary, &'a dyn Reflect)>,
619 knows: &dyn Fn(&str) -> bool,
620) -> Vec<RefIssue> {
621 let mut out = Vec::new();
622 for (source, object) in objects {
623 for edge in identity::collect_refs(object) {
624 if edge.target.uuid.is_empty() {
625 continue;
626 }
627 let declared = edge.target.qualified_type();
628 if knows(&declared) {
629 continue;
630 }
631 out.push(RefIssue {
632 source: source.uri.clone(),
633 path: edge.path.clone(),
634 target_uuid: edge.target.uuid.to_string(),
635 declared_type: Some(declared),
636 kind: RefIssueKind::UnknownType,
637 });
638 }
639 }
640 out
641}
642
643/// The dataspace path a uri names, `None` for the default dataspace.
644///
645/// A reference (a DOR) names no dataspace: it resolves **inside the dataspace of
646/// the object that writes it** (ETP v1.2 §25.3, a dataspace being the scope in
647/// which a uri is resolved). So the uri of a reference's target is spelled with
648/// the dataspace of its **source**, read here from the source's own uri through
649/// the one door, [`identifiers::EtpUri::parse`].
650fn dataspace_of(uri: &str) -> Option<String> {
651 identifiers::EtpUri::parse(uri)
652 .ok()
653 .and_then(|parsed| parsed.dataspace)
654}
655
656/// The data objects nested inside the objects given, by uuid - each with a
657/// summary of its own that names **the part holding it** as its locator, and
658/// the uuid of that part's object.
659///
660/// A reference may name a nested object: a WITSML `Channel` inside its
661/// `ChannelSet`, which PRODML flow-test data references by uuid (see
662/// [`identity::nested_objects`]). Such a reference resolves to the part that
663/// holds it. An object that is a part of its own is never shadowed by a nested
664/// copy of the same uuid.
665pub fn nested_index<'a>(
666 objects: impl IntoIterator<Item = (&'a ObjectSummary, &'a dyn Reflect)>,
667) -> std::collections::HashMap<String, (ObjectSummary, String, Cached)> {
668 let objects: Vec<(&ObjectSummary, &dyn Reflect)> = objects.into_iter().collect();
669 let parts: std::collections::HashSet<String> = objects
670 .iter()
671 .map(|(summary, _)| summary.uuid.to_ascii_lowercase())
672 .collect();
673 let mut out = std::collections::HashMap::new();
674 for (container, object) in objects {
675 for nested in identity::nested_objects(object) {
676 let uuid = nested.key.uuid.to_ascii_lowercase();
677 if parts.contains(&uuid) {
678 continue;
679 }
680 // The dataspace of the part that holds it: a nested object is in
681 // the dataspace of its container.
682 let summary = ObjectSummary::new(
683 nested.key.etp_uri(dataspace_of(&container.uri).as_deref()),
684 nested.key.uuid.to_string(),
685 nested.key.qualified_type(),
686 )
687 .with_content_type(nested.key.content_type())
688 .with_locator(container.locator.clone());
689 let cached = Cached::Read {
690 object_version: nested.key.object_version.map(str::to_string),
691 title: nested.key.title.map(str::to_string),
692 };
693 out.entry(uuid)
694 .or_insert((summary, container.uuid.to_ascii_lowercase(), cached));
695 }
696 }
697 out
698}
699
700/// Every reference in the package, checked in one pass.
701///
702/// `objects` pairs each summary with the object already deserialized, because
703/// deciding when to read is the caller's business, not this function's.
704///
705/// The objects given are also what answers a target's revision: every one of
706/// them has been read, so its `objectVersion` is at hand, and a target outside
707/// the batch is `Unread`.
708pub fn check_all_references<'a, 'i>(
709 objects: impl IntoIterator<Item = (&'a ObjectSummary, &'a dyn Reflect)>,
710 known: &dyn Fn(&str) -> Option<&'i ObjectSummary>,
711) -> Vec<RefIssue> {
712 check_all_references_with(objects, &|uuid| known(uuid).into_iter().collect())
713}
714
715/// [`check_all_references`] for a caller that can name **every** entry of a
716/// uuid beyond the batch - the unreadable part of a package, a store's whole
717/// listing. `elsewhere` answers all of them, not the first: a reference that
718/// names a version no readable object carries is still answered by the
719/// entries of its uuid, each by its full uri, and an entry that cannot be read
720/// is not a reason to report the uuid dangling or to call it by another
721/// version's name. Entries it names that the batch holds too (same uri) count
722/// once.
723pub fn check_all_references_with<'a, 'i>(
724 objects: impl IntoIterator<Item = (&'a ObjectSummary, &'a dyn Reflect)>,
725 elsewhere: &dyn Fn(&str) -> Vec<&'i ObjectSummary>,
726) -> Vec<RefIssue> {
727 let objects: Vec<(&ObjectSummary, &dyn Reflect)> = objects.into_iter().collect();
728 // What each object says of itself, by **summary**, not by uuid: one uuid may
729 // be held several times (two `objectVersion`s, two releases), and a
730 // reference's revision has to be compared with the revision of the entry it
731 // names.
732 let stated: std::collections::HashMap<&str, Cached> = objects
733 .iter()
734 .map(|(summary, object)| {
735 (
736 summary.uri.as_str(),
737 Cached::Read {
738 object_version: identity::object_version_of_reflect(*object).map(str::to_string),
739 title: identity::title_of_reflect(*object).map(str::to_string),
740 },
741 )
742 })
743 .collect();
744 // The batch's own entries, by uuid: what a reference naming that uuid has to
745 // choose among.
746 let mut in_batch: std::collections::HashMap<String, Vec<usize>> = std::collections::HashMap::new();
747 for (at, (summary, _)) in objects.iter().enumerate() {
748 in_batch
749 .entry(summary.uuid.to_ascii_lowercase())
750 .or_default()
751 .push(at);
752 }
753 // A reference naming an object nested in one of these resolves to it
754 // (see [`nested_index`]).
755 let nested = nested_index(objects.iter().copied());
756 let cached_of = |target: &ObjectSummary| {
757 stated
758 .get(target.uri.as_str())
759 .cloned()
760 .or_else(|| {
761 nested
762 .get(&target.uuid.to_ascii_lowercase())
763 .map(|(_, _, cached)| cached.clone())
764 })
765 .unwrap_or(Cached::Unread)
766 };
767 // Every entry that carries the uuid: the batch's, then what the caller knows
768 // beyond it (entries it could not read, another store's listing) - one per
769 // uri - and, when nothing holds the uuid, the object nested in a part.
770 let held = |uuid: &str| -> Vec<&ObjectSummary> {
771 let key = uuid.to_ascii_lowercase();
772 let mut out: Vec<&ObjectSummary> = in_batch
773 .get(&key)
774 .map(|ats| ats.iter().map(|at| objects[*at].0).collect())
775 .unwrap_or_default();
776 for other in elsewhere(uuid) {
777 if !out.iter().any(|one| one.uri == other.uri) {
778 out.push(other);
779 }
780 }
781 if out.is_empty()
782 && let Some((summary, _, _)) = nested.get(&key)
783 {
784 out.push(summary);
785 }
786 out
787 };
788 objects
789 .iter()
790 .flat_map(|(summary, object)| check_references_by(summary, *object, &held, &cached_of))
791 .collect()
792}
793
794// ---------------------------------------------------------------------------
795// Repair
796// ---------------------------------------------------------------------------
797
798/// What a repair pass did - or refused to do - to one reference.
799#[derive(Debug, Clone, PartialEq, Eq)]
800pub struct RefRepair {
801 /// URI of the object holding the reference.
802 pub source: String,
803 /// Where inside it.
804 pub path: String,
805 /// The uuid the reference names.
806 pub target_uuid: String,
807 /// What happened.
808 pub outcome: RepairOutcome,
809}
810
811/// The outcome of trying to bring one reference back in line with its target.
812#[derive(Debug, Clone, PartialEq, Eq)]
813pub enum RepairOutcome {
814 /// Rewritten from the target, which is the authority for its own type,
815 /// title and revision.
816 Done {
817 /// What the declared type said before, when it said anything.
818 was_type: Option<String>,
819 /// What it says now.
820 now_type: String,
821 /// The title written, when the title was the thing that drifted.
822 now_title: Option<String>,
823 /// The `ObjectVersion` now written, when the target carries one.
824 now_version: Option<String>,
825 },
826 /// Left alone, and why.
827 Refused(Refusal),
828}
829
830/// Why a reference was not repaired.
831///
832/// Every variant is a case where **the package does not say which half is
833/// wrong**. Rewriting one of them would not fix anything; it would make a
834/// broken link look sound, which is worse than leaving it visible.
835#[derive(Debug, Clone, PartialEq, Eq)]
836pub enum Refusal {
837 /// The uuid names nothing in the package. There is no target to copy from,
838 /// and inventing one is not a repair.
839 Dangling,
840 /// The target exists but is another concept entirely. Either the uuid is a
841 /// typo and the type is right, or the reverse - and nothing here can tell.
842 /// Rewriting the type would point the reference confidently at the wrong
843 /// object.
844 ConceptMismatch {
845 /// What the reference claims.
846 declared: String,
847 /// What the object with that uuid actually is.
848 found: String,
849 },
850 /// The reference lives in a subtree the model could not type, kept as
851 /// source text. It is *found* - the graph draws it - but reflection cannot
852 /// walk down to it, so nothing can write to it either.
853 /// `GraphicalInformationSet` is the case that exists in the corpus.
854 Unreachable {
855 /// What the walk said.
856 reason: String,
857 },
858}
859
860impl RefRepair {
861 /// Whether anything was written.
862 pub fn changed(&self) -> bool {
863 matches!(self.outcome, RepairOutcome::Done { .. })
864 }
865}
866
867impl fmt::Display for RefRepair {
868 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
869 write!(f, "{}: ", self.path)?;
870 match &self.outcome {
871 RepairOutcome::Done {
872 was_type,
873 now_type,
874 now_title,
875 now_version,
876 } => {
877 match was_type {
878 Some(was) if was != now_type => write!(f, "`{was}` → `{now_type}`")?,
879 _ => write!(f, "`{now_type}`")?,
880 }
881 if let Some(title) = now_title {
882 write!(f, ", title → `{title}`")?;
883 }
884 match now_version {
885 Some(version) => write!(f, ", version → `{version}`"),
886 None => Ok(()),
887 }
888 }
889 RepairOutcome::Refused(Refusal::Dangling) => {
890 write!(f, "left alone - {} is not in this package", self.target_uuid)
891 }
892 RepairOutcome::Refused(Refusal::ConceptMismatch { declared, found }) => write!(
893 f,
894 "left alone - says `{declared}`, but {} is a `{found}`; which half is wrong is not \
895 something this package says",
896 self.target_uuid
897 ),
898 RepairOutcome::Refused(Refusal::Unreachable { reason }) => {
899 write!(f, "left alone - {reason}")
900 }
901 }
902 }
903}
904
905/// What a repair needs to know about a reference's target.
906///
907/// A closure rather than a store, for the same reason [`check_references`] takes
908/// one: this decides, the caller reads.
909pub struct Target {
910 /// The target's real qualified type.
911 pub qualified_type: String,
912 /// Its citation title, when it has one.
913 pub title: Option<String>,
914 /// Its `objectVersion`, when it carries one.
915 ///
916 /// `None` means *the object states no revision*, and a reference to it
917 /// says none either - which is what the Identifier Specification reads as
918 /// *the only or latest version*. A reference that names a revision such an
919 /// object does not have is **left alone**: which half is wrong is not
920 /// something the package says, the same rule the uuid follows.
921 pub object_version: Option<String>,
922}
923
924/// Bring one object's references back in line with what the package holds.
925///
926/// # What is repaired, and on whose authority
927///
928/// A `DataObjectReference` caches four things about its target: the uuid, the
929/// declared type, the title, and the revision. **The target object is the
930/// authority for its own type, its own title and its own `objectVersion`** -
931/// those are copies, and a copy that disagrees with the original is simply
932/// stale. Those are rewritten.
933///
934/// The revision was added on 10/09, after `energyml check` learned to compare
935/// it: a translated package stamps every object `objectVersion="0"` and wrote
936/// every reference without one, so the package said two things and nothing
937/// could put it right. A reference naming a revision the target does **not**
938/// carry is left alone - clearing it would be deciding which half is wrong.
939///
940/// The target is *not* an authority for **which object was meant**. So a
941/// reference naming a uuid that is not there, or naming an object of another
942/// concept entirely, is reported and left exactly as it was: see [`Refusal`].
943/// The rule this follows is that a repair may fix a copy, never a decision.
944///
945/// The uuid is never rewritten - it is the one field that says what was meant.
946///
947/// Only references that changed, or that were refused, come back; a reference
948/// already in agreement produces nothing.
949pub fn repair_references(
950 source: &ObjectSummary,
951 object: &mut dyn Reflect,
952 target_of: &dyn Fn(&str) -> Option<Target>,
953) -> Vec<RefRepair> {
954 // The walk borrows the object, so the decisions are all taken first and the
955 // writes happen after: what to write is settled before anything is written,
956 // and a failure part-way cannot leave a reference half-updated.
957 #[allow(clippy::type_complexity)]
958 let mut planned: Vec<(String, String, Option<String>, Option<String>, Option<String>)> = Vec::new();
959 let mut out = Vec::new();
960
961 for edge in identity::collect_refs(&*object) {
962 let uuid = edge.target.uuid;
963 if uuid.is_empty() {
964 continue;
965 }
966 let declared = edge.target.qualified_type();
967 let repair = |outcome| RefRepair {
968 source: source.uri.clone(),
969 path: edge.path.clone(),
970 target_uuid: uuid.to_string(),
971 outcome,
972 };
973
974 let Some(target) = target_of(uuid) else {
975 out.push(repair(RepairOutcome::Refused(Refusal::Dangling)));
976 continue;
977 };
978 if !same_concept(&declared, &target.qualified_type) {
979 out.push(repair(RepairOutcome::Refused(Refusal::ConceptMismatch {
980 declared,
981 found: target.qualified_type,
982 })));
983 continue;
984 }
985
986 let type_drifted = !same_qualified_type(&declared, &target.qualified_type);
987 let title_drifted = match (&target.title, edge.target.title) {
988 (Some(theirs), Some(ours)) => theirs != ours,
989 (Some(_), None) => true,
990 (None, _) => false,
991 };
992 // Same shape as the title, with one difference that is the whole
993 // point: a target carrying no revision leaves the reference as it is.
994 let version_drifted = match (&target.object_version, edge.target.object_version) {
995 (Some(theirs), Some(ours)) => theirs != ours,
996 (Some(_), None) => true,
997 (None, _) => false,
998 };
999 if !type_drifted && !title_drifted && !version_drifted {
1000 continue;
1001 }
1002 planned.push((
1003 edge.path.clone(),
1004 target.qualified_type,
1005 title_drifted.then(|| target.title.clone()).flatten(),
1006 target.object_version.clone(),
1007 type_drifted.then(|| declared.clone()),
1008 ));
1009 }
1010
1011 for (path, qualified_type, title, version, was_type) in planned {
1012 let reference = match energyml_core::path::object_mut(object, &path) {
1013 Ok(reference) => reference,
1014 // The `AnyXml` case, and anything else reflection cannot walk into.
1015 // Named rather than silently skipped: a repair that quietly does
1016 // nothing to some references is a repair nobody can trust.
1017 Err(e) => {
1018 out.push(RefRepair {
1019 source: source.uri.clone(),
1020 path,
1021 target_uuid: String::new(),
1022 outcome: RepairOutcome::Refused(Refusal::Unreachable {
1023 reason: format!("{e}"),
1024 }),
1025 });
1026 continue;
1027 }
1028 };
1029 let uuid = identity::uuid_of_reflect(&*reference)
1030 .unwrap_or_default()
1031 .to_string();
1032 let Ok(id) = identity::ObjectId::from_qualified_type(&qualified_type, &uuid) else {
1033 continue;
1034 };
1035 let mut key = id.as_key();
1036 key.title = title.as_deref();
1037 key.object_version = version.as_deref();
1038 if energyml_core::identity::set_target_reflect(reference, &key).is_ok() {
1039 out.push(RefRepair {
1040 source: source.uri.clone(),
1041 path,
1042 target_uuid: uuid,
1043 outcome: RepairOutcome::Done {
1044 was_type,
1045 now_type: qualified_type,
1046 now_title: title,
1047 now_version: version,
1048 },
1049 });
1050 }
1051 }
1052 out
1053}
1054
1055/// Two qualified types naming the same type: `obj_` and case are noise.
1056fn same_qualified_type(a: &str, b: &str) -> bool {
1057 match (
1058 identity::parse_qualified_type(a),
1059 identity::parse_qualified_type(b),
1060 ) {
1061 (Ok(a), Ok(b)) => {
1062 a.domain.eq_ignore_ascii_case(b.domain)
1063 && a.version == b.version
1064 && naming::type_eq_loose(a.object_type, b.object_type)
1065 }
1066 _ => a.eq_ignore_ascii_case(b),
1067 }
1068}
1069
1070/// Whether a declared type names a concept the standard publishes in a
1071/// catalogue, so that not finding it in the package means nothing.
1072///
1073/// Matched on the type name alone, deliberately: `eml20` through `eml23` all
1074/// have `PropertyKind`, and a package may reference any of them.
1075pub fn is_externally_defined(qualified_type: &str) -> bool {
1076 let object_type = match identity::parse_qualified_type(qualified_type) {
1077 Ok(parsed) => parsed.object_type,
1078 // No domain prefix: take the whole string as the type name rather than
1079 // giving up, since a reference may declare only `PropertyKind`.
1080 Err(_) => qualified_type,
1081 };
1082 naming::type_eq_loose(object_type, "PropertyKind")
1083}
1084
1085/// Same concept whatever the version: `resqml20.obj_Activity` and
1086/// `resqml22.Activity`.
1087fn same_concept(a: &str, b: &str) -> bool {
1088 match (
1089 identity::parse_qualified_type(a),
1090 identity::parse_qualified_type(b),
1091 ) {
1092 (Ok(a), Ok(b)) => {
1093 a.domain.eq_ignore_ascii_case(b.domain) && naming::type_eq_loose(a.object_type, b.object_type)
1094 }
1095 _ => false,
1096 }
1097}
1098
1099#[cfg(test)]
1100mod tests {
1101 use super::*;
1102
1103 fn summary(uuid: &str, qualified_type: &str) -> ObjectSummary {
1104 ObjectSummary::new(
1105 format!("eml:///{qualified_type}({uuid})"),
1106 uuid.to_string(),
1107 qualified_type.to_string(),
1108 )
1109 .with_locator(format!("{qualified_type}_{uuid}.xml"))
1110 }
1111
1112 #[test]
1113 fn the_obj_prefix_is_not_a_difference() {
1114 assert!(same_qualified_type("resqml20.obj_Activity", "resqml20.Activity"));
1115 // Case is noise on both halves - writers disagree about it, and a
1116 // reference is not wrong for having spelled the domain `RESQML`.
1117 assert!(same_qualified_type("RESQML20.obj_activity", "resqml20.Activity"));
1118 assert!(!same_qualified_type("resqml20.obj_Activity", "resqml22.Activity"));
1119 assert!(same_concept("resqml20.obj_Activity", "resqml22.Activity"));
1120 assert!(!same_concept("resqml20.obj_Activity", "resqml20.obj_Well"));
1121 }
1122
1123 #[test]
1124 fn a_version_difference_is_not_a_concept_difference() {
1125 let target = summary("00000000-0000-4000-8000-000000000002", "resqml22.Activity");
1126 let same = summary("00000000-0000-4000-8000-000000000002", "resqml20.obj_Well");
1127
1128 // Only the classification is exercised here; the walk over a real
1129 // object is covered by the corpus test, which has real DORs to walk.
1130 assert!(same_concept("resqml20.obj_Activity", &target.qualified_type));
1131 assert!(!same_concept("resqml20.obj_Activity", &same.qualified_type));
1132 }
1133
1134 #[test]
1135 fn a_revision_is_compared_only_once_read_and_a_silent_reference_is_a_warning() {
1136 // To break: the absent-versus-absent case reported, `Unread` compared
1137 // as "carries none", or a reference naming a revision the object
1138 // lacks let through as a warning.
1139 let read = |version: Option<&str>| Cached::Read {
1140 object_version: version.map(str::to_string),
1141 title: None,
1142 };
1143 let revision = |referenced: Option<&str>, found: &Cached| revision_check(referenced, found, &[]);
1144 assert_eq!(revision(None, &Cached::Unread), None);
1145 assert_eq!(revision(Some("3"), &Cached::Unread), None);
1146 assert_eq!(revision(None, &read(None)), None);
1147 assert_eq!(revision(Some("3"), &read(Some("3"))), None);
1148 assert_eq!(
1149 revision(Some(" 3 "), &read(Some("3"))),
1150 None,
1151 "whitespace is not a revision"
1152 );
1153
1154 let issue = |kind| RefIssue {
1155 source: "eml:///resqml22.HorizonInterpretation(a)".into(),
1156 path: "InterpretedFeature".into(),
1157 target_uuid: "b".into(),
1158 declared_type: None,
1159 kind,
1160 };
1161 let differ = revision(Some("2"), &read(Some("3"))).expect("a finding");
1162 assert_eq!(
1163 differ,
1164 RefIssueKind::ObjectVersionMismatch {
1165 referenced: Some("2".into()),
1166 found: Some("3".into()),
1167 candidates: vec![]
1168 }
1169 );
1170 assert!(issue(differ.clone()).is_error());
1171 assert!(issue(differ).to_string().contains("`2`"));
1172
1173 let named_none = revision(Some("2"), &read(None)).expect("a finding");
1174 assert!(
1175 issue(named_none).is_error(),
1176 "a revision the object does not carry is promised"
1177 );
1178
1179 // What our own translation used to write: objects at `0`, references
1180 // silent. The Identifier Specification lets a reader assume the
1181 // latest, so it is said and not counted as an error.
1182 let silent = revision(None, &read(Some("0"))).expect("a finding");
1183 assert_eq!(
1184 silent,
1185 RefIssueKind::ObjectVersionMismatch {
1186 referenced: None,
1187 found: Some("0".into()),
1188 candidates: vec![]
1189 }
1190 );
1191 assert!(!issue(silent.clone()).is_error());
1192 assert!(!issue(silent.clone()).is_info());
1193 assert!(issue(silent).to_string().contains("assumes the latest"));
1194 }
1195
1196 #[test]
1197 fn a_title_is_a_copy_and_a_stale_one_is_said_without_being_an_error() {
1198 // A real case: the two references of a
1199 // `LocalEngineeringCompoundCrs` that our own translation wrote
1200 // carried an empty title, the Webstudio flagged it and this
1201 // checker did not look at titles at all. What it would take to
1202 // break this: comparing a title the target does not have, or making it
1203 // an error - a reader resolves by uuid and finds the right object.
1204 let read = |title: Option<&str>| Cached::Read {
1205 object_version: None,
1206 title: title.map(str::to_string),
1207 };
1208 assert_eq!(title_check(Some("The base"), &Cached::Unread), None);
1209 assert_eq!(
1210 title_check(Some("The base"), &read(None)),
1211 None,
1212 "no original, no stale copy"
1213 );
1214 assert_eq!(
1215 title_check(None, &read(Some(" "))),
1216 None,
1217 "a blank title is not a title"
1218 );
1219 assert_eq!(title_check(Some("The base"), &read(Some("The base"))), None);
1220 assert_eq!(
1221 title_check(Some(" The base "), &read(Some("The base"))),
1222 None,
1223 "whitespace is not a name"
1224 );
1225
1226 let issue = |kind| RefIssue {
1227 source: "eml:///eml23.LocalEngineeringCompoundCrs(a)".into(),
1228 path: "VerticalCrs".into(),
1229 target_uuid: "b".into(),
1230 declared_type: None,
1231 kind,
1232 };
1233 let empty = title_check(None, &read(Some("Default local time CRS"))).expect("un constat");
1234 assert_eq!(
1235 empty,
1236 RefIssueKind::TitleMismatch {
1237 referenced: None,
1238 found: "Default local time CRS".into()
1239 }
1240 );
1241 assert!(
1242 !issue(empty.clone()).is_error(),
1243 "the uuid resolves: this is not an error"
1244 );
1245 assert!(!issue(empty.clone()).is_info());
1246 assert!(issue(empty).to_string().contains("no title"));
1247
1248 let stale = title_check(Some("Ancien nom"), &read(Some("Nouveau nom"))).expect("un constat");
1249 assert!(issue(stale).to_string().contains("`Ancien nom`"));
1250 }
1251
1252 #[test]
1253 fn a_property_kind_is_not_expected_to_be_in_the_package() {
1254 // Every eml version, and the bare name a reference may carry alone.
1255 for declared in [
1256 "eml20.PropertyKind",
1257 "eml23.PropertyKind",
1258 "EML23.propertykind",
1259 "PropertyKind",
1260 ] {
1261 assert!(is_externally_defined(declared), "{declared}");
1262 }
1263 // Nothing else gets the exemption - a missing representation is a
1264 // missing representation, and `PropertyKindFacet` is not a kind.
1265 for declared in [
1266 "resqml22.WellboreFrameRepresentation",
1267 "eml23.PropertyKindDictionary",
1268 "eml23.ColumnBasedTable",
1269 ] {
1270 assert!(!is_externally_defined(declared), "{declared}");
1271 }
1272 }
1273
1274 #[test]
1275 fn an_absent_node_says_whether_its_absence_means_anything() {
1276 // The counterpart of `a_property_kind_is_not_expected_to_be_in_the
1277 // _package` one layer up: the predicate existed and the graph did not
1278 // carry it, so every viewer drawn from the graph counted a well-known
1279 // kind as a reference into nothing. `assemble` rather than `build`
1280 // because it is the path with no object to walk - the one an ETP client
1281 // takes - and because it needs no reflection here.
1282 let held = summary(
1283 "00000000-0000-4000-8000-000000000001",
1284 "resqml22.ContinuousProperty",
1285 );
1286 let kind = "eml:///eml23.PropertyKind(00000000-0000-4000-8000-000000000002)";
1287 let gone = "eml:///resqml22.HorizonInterpretation(00000000-0000-4000-8000-000000000003)";
1288 let graph = assemble(
1289 [&held],
1290 &|_| None,
1291 [
1292 Edge::new(held.uri.clone(), kind, "PropertyKind"),
1293 Edge::new(held.uri.clone(), gone, "Represents"),
1294 ],
1295 );
1296
1297 let node = |uri: &str| graph.nodes.iter().find(|n| n.uri == uri).expect(uri);
1298 assert!(!node(kind).present && node(kind).externally_defined);
1299 // The one that must stay a fault: absent, and nothing publishes it.
1300 assert!(!node(gone).present && !node(gone).externally_defined);
1301 // A property of the type, not of the absence - so a consumer's rule is
1302 // the conjunction and never a flag read in a particular order.
1303 assert!(node(&held.uri).present && !node(&held.uri).externally_defined);
1304 assert!(graph.to_json().contains("\"externallyDefined\": true"));
1305 }
1306
1307 #[test]
1308 fn a_catalogue_reference_is_neither_error_nor_warning() {
1309 let issue = |kind| RefIssue {
1310 source: "eml:///resqml22.ContinuousProperty(a)".into(),
1311 path: "PropertyKind".into(),
1312 target_uuid: "00000000-0000-4000-8000-000000000003".into(),
1313 declared_type: Some("eml23.PropertyKind".into()),
1314 kind,
1315 };
1316 let catalogue = issue(RefIssueKind::ExternallyDefined);
1317 assert!(!catalogue.is_error());
1318 assert!(catalogue.is_info());
1319 // The distinction the split exists for: a version mismatch is still a
1320 // warning about this package, and must not fall into the same bucket.
1321 let version = issue(RefIssueKind::VersionMismatch {
1322 found: "eml20.PropertyKind".into(),
1323 candidates: vec![],
1324 });
1325 assert!(!version.is_error());
1326 assert!(!version.is_info());
1327 // And a genuine dangling reference is still an error.
1328 assert!(issue(RefIssueKind::Dangling).is_error());
1329 }
1330}
1331
1332// ---------------------------------------------------------------------------
1333// The `.rels` against the objects
1334// ---------------------------------------------------------------------------
1335
1336/// A disagreement between the `.rels` index and the objects themselves.
1337///
1338/// The rule this crate follows everywhere - the `.rels` speed things up, the DORs
1339/// are authoritative - makes none of these fatal: whatever the index says, the reader
1340/// follows the `DataObjectReference`s and gets the right answer. They matter
1341/// for two other reasons.
1342///
1343/// A tool that **does** trust the index gets a different graph from the one the
1344/// data describes, and there are such tools. And the EPC specification §3.4.1
1345/// makes both directions mandatory - *"for every relationship between two XML
1346/// data objects there are two .rels files"* - so a disagreement is a departure
1347/// from the specification, which is exactly what `energyml check` is for.
1348#[derive(Debug, Clone, PartialEq, Eq)]
1349pub struct RelIssue {
1350 /// Part whose `.rels` this concerns.
1351 pub part: String,
1352 /// The other end: a part name, or a uuid when no part holds it.
1353 pub target: String,
1354 /// What is wrong.
1355 pub kind: RelIssueKind,
1356}
1357
1358/// The four ways the index and the objects disagree.
1359#[derive(Debug, Clone, PartialEq, Eq)]
1360pub enum RelIssueKind {
1361 /// The object references the target; no `destinationObject` says so.
1362 ///
1363 /// The common one, and the one that costs a viewer built on `.rels`: it
1364 /// draws a graph missing an edge the data plainly has.
1365 Undeclared {
1366 /// Where in the object the reference is.
1367 path: String,
1368 },
1369 /// A `destinationObject` declares a link the object does not make.
1370 ///
1371 /// The index remembers a reference that was edited away - an edge drawn
1372 /// from nothing.
1373 Unbacked {
1374 /// Relationship id, so it can be found in the file.
1375 id: String,
1376 },
1377 /// A relationship points at a part the package does not hold.
1378 Missing {
1379 /// Relationship id.
1380 id: String,
1381 },
1382 /// A `sourceObject` claims an object references this one, and it does not.
1383 ///
1384 /// The reverse direction §3.4.1 asks for, checked the same way.
1385 UnbackedSource {
1386 /// Relationship id.
1387 id: String,
1388 },
1389 /// The link exists, and the index has it the wrong way round.
1390 ///
1391 /// Reported as one finding rather than as the two it would otherwise
1392 /// produce - an unbacked relationship *and* an undeclared reference - since
1393 /// it is one mistake, and the producer's, not two of the reader's.
1394 /// `ALWYN-RESQML.epc` is full of them.
1395 Reversed {
1396 /// Relationship id.
1397 id: String,
1398 /// What it says now: `sourceObject` or `destinationObject`.
1399 declared: String,
1400 },
1401}
1402
1403impl fmt::Display for RelIssue {
1404 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1405 match &self.kind {
1406 RelIssueKind::Undeclared { path } => write!(
1407 f,
1408 "{}: references {} at `{path}`, and no `destinationObject` relationship declares it",
1409 self.part, self.target
1410 ),
1411 RelIssueKind::Unbacked { id } => write!(
1412 f,
1413 "{}: `{id}` declares a link to {}, which the object itself does not reference",
1414 self.part, self.target
1415 ),
1416 RelIssueKind::Missing { id } => write!(
1417 f,
1418 "{}: `{id}` points at {}, which is not a part of this package",
1419 self.part, self.target
1420 ),
1421 RelIssueKind::UnbackedSource { id } => write!(
1422 f,
1423 "{}: `{id}` says {} references it, and it does not",
1424 self.part, self.target
1425 ),
1426 RelIssueKind::Reversed { id, declared } => write!(
1427 f,
1428 "{}: `{id}` is written `{declared}` and the link goes the other way",
1429 self.part
1430 ),
1431 }
1432 }
1433}
1434
1435/// One object, with what its `.rels` declare and what its DORs actually say.
1436pub struct Related<'a> {
1437 /// The object's place in the inventory.
1438 pub summary: &'a ObjectSummary,
1439 /// The object itself, already deserialized.
1440 pub object: &'a dyn Reflect,
1441 /// The relationships recorded for its part.
1442 pub relationships: &'a [Relationship],
1443}
1444
1445/// Confront the declared `.rels` with the references the objects really hold.
1446///
1447/// Both directions, because §3.4.1 asks for both and because they fail
1448/// differently: a link the objects make and the index omits leaves an
1449/// index-driven viewer short an edge; a link the index makes and the objects do
1450/// not leaves it drawing one from nothing.
1451///
1452/// A part name is matched on its **file name** rather than its path. A `.rels`
1453/// target is relative to the `_rels` folder's parent, so the same object is
1454/// written `Foo.xml` from the root and `../Foo.xml` from a subfolder - and an
1455/// EPC part name is unique across the package whichever layout it uses.
1456///
1457/// # Why this one *is* EPC-only, and stays so
1458///
1459/// Everything else in this module works from objects and would work over ETP
1460/// unchanged. This does not, and it should not be made to: it needs
1461/// [`crate::storage::LocalPackage::relationships`], and a `.rels` file is an
1462/// OPC artefact that exists nowhere else. There is nothing to confront in a
1463/// dataspace - the server *is* the index, so it cannot disagree with itself.
1464///
1465/// The reader-independent question - "what references what" - is
1466/// [`crate::storage::EnergymlStorage::edges`], and that one an ETP client
1467/// answers natively. This is the narrower question "does the package's own
1468/// index agree with its objects", and it is a question only a package has.
1469pub fn check_relationships(objects: &[Related<'_>]) -> Vec<RelIssue> {
1470 let leaf = |part: &str| -> String {
1471 part.rsplit(['/', '\\'])
1472 .next()
1473 .unwrap_or(part)
1474 .to_ascii_lowercase()
1475 };
1476
1477 // Part file name → uuid, for turning a relationship target into an object.
1478 let mut by_part: std::collections::HashMap<String, &ObjectSummary> = std::collections::HashMap::new();
1479 for item in objects {
1480 by_part.insert(leaf(&item.summary.locator), item.summary);
1481 }
1482 // Uuid → object, for the undeclared references below. It was a linear
1483 // `find` over every object **per reference**: 108 s on a package of
1484 // 77 090 objects and 319 244 references), invisible on the corpus.
1485 // The first object holding a uuid wins, as `find` did.
1486 let mut by_uuid: std::collections::HashMap<String, &ObjectSummary> = std::collections::HashMap::new();
1487 for item in objects {
1488 by_uuid
1489 .entry(item.summary.uuid.to_ascii_lowercase())
1490 .or_insert(item.summary);
1491 }
1492 // A reference to a nested object is a link to the part that holds it -
1493 // what the writer records (see [`nested_index`]).
1494 let nested = nested_index(objects.iter().map(|item| (item.summary, item.object)));
1495 // Who references whom, from the objects themselves.
1496 let mut references: std::collections::HashMap<String, Vec<(String, String)>> =
1497 std::collections::HashMap::new();
1498 for item in objects {
1499 let mut edges: Vec<(String, String)> = identity::collect_refs(item.object)
1500 .into_iter()
1501 .filter(|edge| !edge.target.uuid.is_empty())
1502 .filter_map(|edge| {
1503 let uuid = edge.target.uuid.to_ascii_lowercase();
1504 match nested.get(&uuid) {
1505 // Nested in this very object: no link between two parts.
1506 Some((_, container, _)) if *container == item.summary.uuid.to_ascii_lowercase() => None,
1507 Some((_, container, _)) => Some((container.clone(), edge.path.clone())),
1508 None => Some((uuid, edge.path.clone())),
1509 }
1510 })
1511 .collect();
1512 edges.sort();
1513 edges.dedup_by(|a, b| a.0 == b.0);
1514 references.insert(item.summary.uuid.to_ascii_lowercase(), edges);
1515 }
1516
1517 let mut out = Vec::new();
1518 for item in objects {
1519 let part = &item.summary.locator;
1520 let mine = references
1521 .get(&item.summary.uuid.to_ascii_lowercase())
1522 .cloned()
1523 .unwrap_or_default();
1524
1525 let mut declared: std::collections::HashSet<String> = std::collections::HashSet::new();
1526 for relationship in item.relationships {
1527 // An external relationship names a file, not a part: that is how an
1528 // HDF5 proxy is bound, and it has no DOR to agree with.
1529 if relationship.external {
1530 continue;
1531 }
1532 let kind = relationship.short_kind().to_string();
1533 // `mlToExternalPartProxy` **is** how a package declares a reference
1534 // to an HDF5 proxy - EPC §3.4.1 names it, and this library's own
1535 // writer emits it rather than the generic pair. Not counting it as a
1536 // declaration made every package this library saved complain about
1537 // a link it had itself written correctly: two components of the
1538 // house disagreeing about one fact, which is the shape the rules
1539 // forbid. Found by cutting a real package and running `check` on the
1540 // result - not by reading the code.
1541 //
1542 // Its reverse, `externalPartProxyToMl`, is the proxy's own side and
1543 // reads like `sourceObject`.
1544 let kind = match kind.as_str() {
1545 "mlToExternalPartProxy" => "destinationObject".to_string(),
1546 "externalPartProxyToMl" => "sourceObject".to_string(),
1547 _ => kind,
1548 };
1549 if kind != "destinationObject" && kind != "sourceObject" {
1550 continue;
1551 }
1552 let Some(target) = by_part.get(&leaf(&relationship.target)) else {
1553 out.push(RelIssue {
1554 part: part.clone(),
1555 target: relationship.target.clone(),
1556 kind: RelIssueKind::Missing {
1557 id: relationship.id.clone(),
1558 },
1559 });
1560 continue;
1561 };
1562 let target_uuid = target.uuid.to_ascii_lowercase();
1563 let me = item.summary.uuid.to_ascii_lowercase();
1564 // Which way the link really goes, from the objects.
1565 let forward = mine.iter().any(|(uuid, _)| *uuid == target_uuid);
1566 let backward = references
1567 .get(&target_uuid)
1568 .is_some_and(|edges| edges.iter().any(|(uuid, _)| *uuid == me));
1569
1570 if kind == "destinationObject" {
1571 declared.insert(target_uuid.clone());
1572 if !forward {
1573 out.push(RelIssue {
1574 part: part.clone(),
1575 target: target.uri.clone(),
1576 kind: if backward {
1577 RelIssueKind::Reversed {
1578 id: relationship.id.clone(),
1579 declared: kind,
1580 }
1581 } else {
1582 RelIssueKind::Unbacked {
1583 id: relationship.id.clone(),
1584 }
1585 },
1586 });
1587 }
1588 } else if !backward {
1589 // A `sourceObject` that is really this object's own reference
1590 // is the same mistake seen from the other side; and the entry
1591 // is what makes the forward link "declared", so the second
1592 // complaint below does not fire as well.
1593 if forward {
1594 declared.insert(target_uuid.clone());
1595 }
1596 out.push(RelIssue {
1597 part: part.clone(),
1598 target: target.uri.clone(),
1599 kind: if forward {
1600 RelIssueKind::Reversed {
1601 id: relationship.id.clone(),
1602 declared: kind,
1603 }
1604 } else {
1605 RelIssueKind::UnbackedSource {
1606 id: relationship.id.clone(),
1607 }
1608 },
1609 });
1610 }
1611 }
1612
1613 // The other direction: what the object references and the index omits.
1614 // Only for targets the package holds - a reference out of the package
1615 // cannot have a relationship, and `check_references` already reports it
1616 // as dangling.
1617 for (uuid, path) in &mine {
1618 if declared.contains(uuid) {
1619 continue;
1620 }
1621 // `uuid` is already lower-cased (see `references` above).
1622 let Some(target) = by_uuid.get(uuid) else {
1623 continue;
1624 };
1625 out.push(RelIssue {
1626 part: part.clone(),
1627 target: target.uri.clone(),
1628 kind: RelIssueKind::Undeclared { path: path.clone() },
1629 });
1630 }
1631 }
1632 out
1633}
1634
1635// ---------------------------------------------------------------------------
1636// The graph, for something to draw
1637// ---------------------------------------------------------------------------
1638
1639/// One object, as a viewer needs to label it.
1640#[derive(Debug, Clone, PartialEq, Eq)]
1641pub struct Node {
1642 /// ETP URI - the identity that means the same thing in an EPC and in a
1643 /// dataspace, which is why it is the node's key rather than the part name.
1644 pub uri: String,
1645 /// Uuid on its own, for a viewer that wants the short form.
1646 pub uuid: String,
1647 /// `resqml22.TriangulatedSetRepresentation`.
1648 pub qualified_type: String,
1649 /// The concept, identical across versions: `TriangulatedSetRepresentation`.
1650 ///
1651 /// What to colour or group by. Grouping on the qualified type splits one
1652 /// concept into as many colours as the package has versions of it.
1653 pub type_key: String,
1654 /// `Citation/Title`, when there is one.
1655 pub title: Option<String>,
1656 /// Whether the package actually holds this object.
1657 ///
1658 /// A referenced object that is not here still gets a node - a graph that
1659 /// silently drops the edges leading out of the package is a graph that
1660 /// says the package is self-contained when it is not.
1661 pub present: bool,
1662 /// Whether the **type** names a concept the standard publishes in a
1663 /// catalogue, so that not finding it in the package means nothing.
1664 ///
1665 /// A property of the type and not of this node's presence, deliberately:
1666 /// a consumer's rule is then the plain conjunction - *absent **and** not
1667 /// externally defined is a dangling reference* - rather than a flag whose
1668 /// meaning depends on reading another flag first.
1669 ///
1670 /// It exists because a count that lacked it was **wrong in the one
1671 /// direction that matters**: on a BRGM package every one of the four
1672 /// absent nodes was a well-known `PropertyKind` - `code`, `description`,
1673 /// `formation name`, `unknown` - reported as four references into nothing.
1674 /// That is the case the project rules name: a reference to a well-known
1675 /// kind must not be reported as dangling. [`check_all_references`] already
1676 /// made the distinction ([`RefIssueKind::ExternallyDefined`]); the graph
1677 /// did not, so every viewer built on it made the same mistake - and
1678 /// re-deriving the predicate on the far side of the JSON would be two
1679 /// readers of one fact, which is what this crate does not do.
1680 pub externally_defined: bool,
1681}
1682
1683/// One reference, from the object holding it to the object it names.
1684///
1685/// Built with [`Edge::new`]; `#[non_exhaustive]`, so that what ETP adds to an
1686/// edge is not a breaking change for every caller.
1687#[derive(Debug, Clone, PartialEq, Eq)]
1688#[non_exhaustive]
1689pub struct Edge {
1690 /// URI of the object holding the reference.
1691 pub from: String,
1692 /// URI of the object it names.
1693 pub to: String,
1694 /// Where inside the source: `Grid2dPatch.SupportingRepresentation`.
1695 pub path: String,
1696 /// ETP `RelationshipKind`: whether the reference is primary or secondary -
1697 /// what `navigableEdges` filters on. `None` when no rule gives it: an ETP
1698 /// server states it; a store deriving its edges applies the rules of
1699 /// [`crate::kinds`], which refuse by name (`basis` is then
1700 /// [`KindBasis::Unknown`]) where the specification is silent. Never guessed.
1701 pub kind: Option<EdgeKind>,
1702 /// Where `kind` comes from: stated by a server, a rule of the ETP
1703 /// specification, or this crate's derivation, each labelled. `None` for an
1704 /// edge nobody has judged (a reference as written, `Edge::new`).
1705 pub basis: Option<KindBasis>,
1706 /// Whether this is a by-value containment (the contained object is the
1707 /// source, its direct container the target; ETP 1.2 section 8.1.1.1.1,
1708 /// p. 156) and not a reference written in the source. It has no path.
1709 pub by_value: bool,
1710}
1711
1712impl Edge {
1713 /// A reference whose kind is not stated.
1714 pub fn new(from: impl Into<String>, to: impl Into<String>, path: impl Into<String>) -> Self {
1715 Self {
1716 from: from.into(),
1717 to: to.into(),
1718 path: path.into(),
1719 kind: None,
1720 basis: None,
1721 by_value: false,
1722 }
1723 }
1724
1725 /// With the kind a server states ([`KindBasis::Stated`]).
1726 pub fn with_kind(mut self, kind: EdgeKind) -> Self {
1727 self.kind = Some(kind);
1728 self.basis = Some(KindBasis::Stated);
1729 self
1730 }
1731
1732 /// With the kind the rules of [`crate::kinds`] give it, and what that rests on.
1733 /// A judgement without a kind leaves the edge unkinded and records why.
1734 pub fn with_judgement(mut self, judged: Judged) -> Self {
1735 self.kind = judged.kind;
1736 self.basis = Some(judged.basis);
1737 self
1738 }
1739
1740 /// As a by-value containment ([`Edge::by_value`]).
1741 pub fn contained(mut self) -> Self {
1742 self.by_value = true;
1743 self
1744 }
1745}
1746
1747/// ETP `RelationshipKind`, for one edge (`Both` is a filter, not an edge).
1748#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1749pub enum EdgeKind {
1750 /// A primary relationship: the objects one reads together.
1751 Primary,
1752 /// A secondary relationship: one followed only on request.
1753 Secondary,
1754}
1755
1756/// The reference graph of a set of objects.
1757///
1758/// # Why this is a free function over objects rather than a method on a store
1759///
1760/// The requirement asks for a graph on the storage parent class (parent of
1761/// epc and etp) so that graphs can be generated in every case. The way to
1762/// get that is not to put it on the trait - a default method would still have
1763/// to read every object, and deciding when to read is the caller's business
1764/// and nobody else's. It is to build it from what any implementation can hand
1765/// over: a summary and a deserialized object. An `EpcStore` produces those; an
1766/// ETP client will produce the same, and this will not have changed.
1767#[derive(Debug, Clone, Default, PartialEq, Eq)]
1768pub struct Graph {
1769 /// Every object, and every object referenced from one.
1770 pub nodes: Vec<Node>,
1771 /// Every reference.
1772 pub edges: Vec<Edge>,
1773}
1774
1775/// One node of a graph, among the nodes of its uuid.
1776struct Held {
1777 at: usize,
1778 qualified_type: String,
1779 version: Option<String>,
1780}
1781
1782/// The `objectVersion` an object states, as an identity: blank is none.
1783fn version_of(object: &dyn Reflect) -> Option<String> {
1784 identity::object_version_of_reflect(object)
1785 .map(str::trim)
1786 .filter(|version| !version.is_empty())
1787 .map(str::to_string)
1788}
1789
1790/// The nodes a reference names among the nodes of its uuid: by type when the
1791/// type matches something (*a wrong version is not a wrong type*), by version
1792/// when the reference carries one - a version nobody carries still names the one
1793/// candidate there is - and when it names no version of an object held in
1794/// several, the one that declares none if exactly one does, **else all of them**.
1795fn select(held: &[Held], qualified_type: &str, version: Option<&str>) -> Vec<usize> {
1796 let mut left: Vec<&Held> = held.iter().collect();
1797 if left.iter().any(|one| one.qualified_type == qualified_type) {
1798 left.retain(|one| one.qualified_type == qualified_type);
1799 }
1800 if let Some(wanted) = version {
1801 if left.iter().any(|one| one.version.as_deref() == Some(wanted)) {
1802 left.retain(|one| one.version.as_deref() == Some(wanted));
1803 }
1804 } else if left.len() > 1 {
1805 let bare: Vec<&&Held> = left.iter().filter(|one| one.version.is_none()).collect();
1806 if let [only] = bare[..] {
1807 return vec![only.at];
1808 }
1809 }
1810 left.iter().map(|one| one.at).collect()
1811}
1812
1813/// Build the graph of what references what.
1814///
1815/// `objects` pairs each summary with the object already deserialized, the same
1816/// shape [`check_all_references`] takes.
1817pub fn build<'a>(objects: impl IntoIterator<Item = (&'a ObjectSummary, &'a dyn Reflect)>) -> Graph {
1818 let items: Vec<(&ObjectSummary, &dyn Reflect)> = objects.into_iter().collect();
1819 let mut nodes: Vec<Node> = Vec::with_capacity(items.len());
1820 // Lowercased uuid → its nodes. **A list, because an object is (uuid, type,
1821 // objectVersion)** and a package may hold a uuid under two versions or two
1822 // releases: a map that kept "the last" made the second shadow the first and
1823 // sent every reference to it.
1824 let mut index: std::collections::HashMap<String, Vec<Held>> = std::collections::HashMap::new();
1825
1826 for (summary, object) in &items {
1827 let key = summary.uuid.to_ascii_lowercase();
1828 index.entry(key).or_default().push(Held {
1829 at: nodes.len(),
1830 qualified_type: summary.qualified_type.clone(),
1831 version: version_of(*object),
1832 });
1833 nodes.push(Node {
1834 uri: summary.uri.clone(),
1835 uuid: summary.uuid.clone(),
1836 qualified_type: summary.qualified_type.clone(),
1837 type_key: identity::parse_qualified_type(&summary.qualified_type)
1838 .map(|parts| naming::strip_obj_prefix(parts.object_type).to_string())
1839 .unwrap_or_else(|_| summary.qualified_type.clone()),
1840 title: title_of(*object),
1841 present: true,
1842 externally_defined: is_externally_defined(&summary.qualified_type),
1843 });
1844 }
1845
1846 // **A reference to an object nested in a part is a link to that part** -
1847 // a PRODML flow test naming a WITSML `ChannelSet` held in a `Log`, or its
1848 // `Channel`s. On the Lutetia model the statistics kept showing
1849 // "references to an absent object" - the front end counts
1850 // a node that is not present, and these were nodes for objects the package
1851 // does hold, one level down. The rule `derived_graph` already follows (see
1852 // [`nested_index`]): an edge to the container, none within one object.
1853 let nested = nested_index(items.iter().copied());
1854 let mut edges = Vec::new();
1855 for (summary, object) in &items {
1856 // A reference names no dataspace; it resolves in its source's.
1857 let dataspace = dataspace_of(&summary.uri);
1858 for edge in identity::collect_refs(*object) {
1859 if edge.target.uuid.is_empty() {
1860 continue;
1861 }
1862 let key = edge.target.uuid.to_ascii_lowercase();
1863 if let Some((_, container, _)) = nested.get(&key) {
1864 if *container == summary.uuid.to_ascii_lowercase() {
1865 continue;
1866 }
1867 if let Some(at) = index
1868 .get(container)
1869 .and_then(|held| held.first())
1870 .map(|held| held.at)
1871 {
1872 edges.push(Edge::new(
1873 summary.uri.clone(),
1874 nodes[at].uri.clone(),
1875 edge.path.clone(),
1876 ));
1877 continue;
1878 }
1879 }
1880 let wanted_version = edge
1881 .target
1882 .object_version
1883 .map(str::trim)
1884 .filter(|version| !version.is_empty());
1885 let held = index.entry(key).or_default();
1886 if held.is_empty() {
1887 // Referenced and not in the package. It gets a node saying so
1888 // rather than an edge into nothing.
1889 let qualified_type = edge.target.qualified_type();
1890 held.push(Held {
1891 at: nodes.len(),
1892 qualified_type: qualified_type.clone(),
1893 version: wanted_version.map(str::to_string),
1894 });
1895 nodes.push(Node {
1896 uri: identifiers::EtpUri::object_versioned(
1897 dataspace.as_deref(),
1898 &qualified_type,
1899 edge.target.uuid,
1900 wanted_version,
1901 ),
1902 uuid: edge.target.uuid.to_string(),
1903 type_key: naming::strip_obj_prefix(edge.target.object_type).to_string(),
1904 externally_defined: is_externally_defined(&qualified_type),
1905 qualified_type,
1906 title: edge.target.title.map(str::to_string),
1907 present: false,
1908 });
1909 }
1910 // The object the reference names - and, where it names no version of
1911 // an object held in several, **each** of them: this graph is what a
1912 // closure and a "who depends on this" are drawn from, and a
1913 // dependency that is too wide costs a copy, one that is too narrow
1914 // breaks a package.
1915 for at in select(held, &edge.target.qualified_type(), wanted_version) {
1916 edges.push(Edge::new(
1917 summary.uri.clone(),
1918 nodes[at].uri.clone(),
1919 edge.path.clone(),
1920 ));
1921 }
1922 }
1923 }
1924 edges.sort_by(|a, b| (&a.from, &a.to, &a.path).cmp(&(&b.from, &b.to, &b.path)));
1925 edges.dedup();
1926 Graph { nodes, edges }
1927}
1928
1929/// Build the graph from an inventory and a set of edges obtained *elsewhere*.
1930///
1931/// The counterpart of [`build`] for a store that can answer "what references
1932/// what" without handing over the objects - which is exactly what ETP does:
1933/// `GetResources` with `includeEdges` replies `GetResourcesEdgesResponse`, a
1934/// list of source/target pairs, and no object XML crosses the wire at all.
1935///
1936/// [`build`] deserializes every object to find the same edges. Both produce a
1937/// [`Graph`]; which one a store uses is the store's business, and that is the
1938/// whole point of [`crate::storage::EnergymlStorage::graph`] being a provided
1939/// method over [`crate::storage::EnergymlStorage::edges`].
1940///
1941/// `titles` is looked up by URI and may be missing entries - a graph without
1942/// labels is still a graph, and forcing a header read to draw one would undo
1943/// the saving this function exists for.
1944///
1945/// Edges naming a URI the inventory does not hold still get a node, marked
1946/// `present: false`, for the reason [`build`] does the same: a graph that drops
1947/// the edges leading out of the store claims the store is self-contained.
1948pub fn assemble<'a>(
1949 inventory: impl IntoIterator<Item = &'a ObjectSummary>,
1950 titles: &dyn Fn(&str) -> Option<String>,
1951 edges: impl IntoIterator<Item = Edge>,
1952) -> Graph {
1953 let mut nodes: Vec<Node> = Vec::new();
1954 let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1955
1956 for summary in inventory {
1957 index.insert(summary.uri.clone(), nodes.len());
1958 nodes.push(Node {
1959 uri: summary.uri.clone(),
1960 uuid: summary.uuid.clone(),
1961 qualified_type: summary.qualified_type.clone(),
1962 type_key: identity::parse_qualified_type(&summary.qualified_type)
1963 .map(|parts| naming::strip_obj_prefix(parts.object_type).to_string())
1964 .unwrap_or_else(|_| summary.qualified_type.clone()),
1965 title: titles(&summary.uri),
1966 present: true,
1967 externally_defined: is_externally_defined(&summary.qualified_type),
1968 });
1969 }
1970
1971 let mut collected: Vec<Edge> = Vec::new();
1972 for edge in edges {
1973 for uri in [&edge.from, &edge.to] {
1974 if index.contains_key(uri) {
1975 continue;
1976 }
1977 // Only ever the target in practice, but a store may report an edge
1978 // from an object it no longer holds and the graph should say so
1979 // rather than lose the edge.
1980 let parsed = identifiers::EtpUri::parse(uri).ok();
1981 let qualified_type = parsed
1982 .as_ref()
1983 .and_then(|u| u.qualified_type())
1984 .map(|q| q.to_string())
1985 .unwrap_or_default();
1986 let uuid = parsed.as_ref().and_then(|u| u.uuid.clone()).unwrap_or_default();
1987 index.insert(uri.clone(), nodes.len());
1988 nodes.push(Node {
1989 uri: uri.clone(),
1990 uuid,
1991 type_key: identity::parse_qualified_type(&qualified_type)
1992 .map(|parts| naming::strip_obj_prefix(parts.object_type).to_string())
1993 .unwrap_or_else(|_| qualified_type.clone()),
1994 externally_defined: is_externally_defined(&qualified_type),
1995 qualified_type,
1996 title: None,
1997 present: false,
1998 });
1999 }
2000 collected.push(edge);
2001 }
2002 collected.sort_by(|a, b| (&a.from, &a.to, &a.path).cmp(&(&b.from, &b.to, &b.path)));
2003 collected.dedup();
2004 Graph {
2005 nodes,
2006 edges: collected,
2007 }
2008}
2009
2010/// One object pointing at another, resolved far enough to act on.
2011///
2012/// Not a uuid: what a caller needs before deleting or copying something is
2013/// **which** object points at it and **where inside it** - so an interface can
2014/// take a person there, and a copy can follow the field rather than guess.
2015#[derive(Debug, Clone, PartialEq, Eq)]
2016pub struct Pointer {
2017 /// URI of the object holding the reference.
2018 pub uri: String,
2019 /// Its uuid on its own.
2020 pub uuid: String,
2021 /// Its qualified type.
2022 pub qualified_type: String,
2023 /// Its citation title, when it has one.
2024 pub title: Option<String>,
2025 /// Where inside it the reference sits: `Grid2dPatch.SupportingRepresentation`.
2026 pub path: String,
2027}
2028
2029/// The references of [`build`], each given back to the data object that
2030/// **writes** it.
2031///
2032/// [`build`] gives every reference to the part it was read from: right for a
2033/// dependency (a part needs what anything inside it names), wrong for what an
2034/// object writes. A DOR written inside a contained object - a WITSML `Channel`
2035/// in a `ChannelSet` in a `Log` - has that object for its source (ETP 1.2
2036/// section 8.1.1.1.1, p. 156), and its path is relative to it (`Wellbore`, not
2037/// `ChannelSet[0].Channel[2].Wellbore`), which is what a kind rule keyed on the
2038/// source type and the path reads (section 8.1.1.1.2, p. 157: `Channel` to
2039/// `Wellbore` is the secondary example).
2040///
2041/// The writer is the innermost nested data object
2042/// ([`identity::nested_objects`]) whose path begins the reference's path. Its
2043/// URI is the one the store lists it under: the entry of `contained` (the
2044/// objects of [`crate::storage::Contents`]) with its uuid inside this part, or
2045/// else the part of `objects` with its uuid (the part is the object). A nested
2046/// object the store lists nowhere keeps its references on the part, as
2047/// [`build`] gives them: there is no URI to give them to.
2048pub fn attribute_to_writers<'a>(
2049 edges: Vec<Edge>,
2050 objects: impl IntoIterator<Item = (&'a ObjectSummary, &'a dyn Reflect)>,
2051 contained: &[ObjectSummary],
2052) -> Vec<Edge> {
2053 let objects: Vec<(&ObjectSummary, &dyn Reflect)> = objects.into_iter().collect();
2054 let by_uri: std::collections::HashMap<&str, &ObjectSummary> = contained
2055 .iter()
2056 .map(|summary| (summary.uri.as_str(), summary))
2057 .collect();
2058 // Part URI -> (path of a nested object in it, the URI it is listed under),
2059 // innermost first.
2060 let mut writers: std::collections::HashMap<&str, Vec<(String, String)>> =
2061 std::collections::HashMap::new();
2062 for (part, object) in &objects {
2063 let mut found = Vec::new();
2064 for nested in identity::nested_objects(*object) {
2065 let uuid = nested.key.uuid.to_ascii_lowercase();
2066 let listed = contained
2067 .iter()
2068 .find(|entry| entry.uuid.to_ascii_lowercase() == uuid && within(&by_uri, entry, &part.uri))
2069 .or_else(|| {
2070 objects
2071 .iter()
2072 .map(|(summary, _)| *summary)
2073 .find(|summary| summary.uri != part.uri && summary.uuid.to_ascii_lowercase() == uuid)
2074 });
2075 if let Some(listed) = listed {
2076 found.push((nested.path, listed.uri.clone()));
2077 }
2078 }
2079 found.sort_by_key(|(path, _)| std::cmp::Reverse(path.len()));
2080 writers.insert(part.uri.as_str(), found);
2081 }
2082 edges
2083 .into_iter()
2084 .map(|mut edge| {
2085 let writer = writers.get(edge.from.as_str()).and_then(|found| {
2086 found.iter().find(|(path, _)| {
2087 edge.path.len() > path.len()
2088 && edge.path.starts_with(path.as_str())
2089 && edge.path.as_bytes()[path.len()] == b'.'
2090 })
2091 });
2092 if let Some((path, uri)) = writer {
2093 edge.path = edge.path[path.len() + 1..].to_string();
2094 edge.from = uri.clone();
2095 }
2096 edge
2097 })
2098 .collect()
2099}
2100
2101/// Whether the contained object `summary` sits, at any depth, inside the object
2102/// at `container`: up its [`ObjectSummary::contained_in`], through the other
2103/// entries of `contained` (by URI).
2104pub(crate) fn within(
2105 contained: &std::collections::HashMap<&str, &ObjectSummary>,
2106 summary: &ObjectSummary,
2107 container: &str,
2108) -> bool {
2109 let mut seen = std::collections::HashSet::new();
2110 let mut up: Vec<&str> = summary.contained_in.iter().map(String::as_str).collect();
2111 while let Some(uri) = up.pop() {
2112 if uri == container {
2113 return true;
2114 }
2115 if !seen.insert(uri) {
2116 continue;
2117 }
2118 if let Some(entry) = contained.get(uri) {
2119 up.extend(entry.contained_in.iter().map(String::as_str));
2120 }
2121 }
2122 false
2123}
2124
2125/// The parts (the objects not in `contained`) that hold the contained object
2126/// `summary`, up its containers.
2127pub(crate) fn parts_holding(
2128 contained: &std::collections::HashMap<&str, &ObjectSummary>,
2129 summary: &ObjectSummary,
2130) -> Vec<String> {
2131 let mut seen = std::collections::HashSet::new();
2132 let mut parts = Vec::new();
2133 let mut up: Vec<&str> = summary.contained_in.iter().map(String::as_str).collect();
2134 while let Some(uri) = up.pop() {
2135 if !seen.insert(uri) {
2136 continue;
2137 }
2138 match contained.get(uri) {
2139 Some(entry) => up.extend(entry.contained_in.iter().map(String::as_str)),
2140 None => parts.push(uri.to_string()),
2141 }
2142 }
2143 parts
2144}
2145
2146/// What this object points at.
2147///
2148/// The cheap direction: an object carries its own references, so this reads one
2149/// object where [`dependents`] reads every one of them. The target's uri is
2150/// built exactly as [`build`] builds it - same crate, one spelling - and the
2151/// title is the **hint the reference carries**, which is not a source of truth:
2152/// what the target is actually called is a question for the package.
2153pub fn references(object: &dyn Reflect) -> Vec<Pointer> {
2154 references_in(object, None)
2155}
2156
2157/// [`references`], the targets spelled in the dataspace `dataspace` - that of
2158/// the object holding the references, since a reference names no dataspace of
2159/// its own and resolves inside its source's (ETP v1.2 §25.3). `None` is the
2160/// default dataspace, `eml:///`.
2161pub fn references_in(object: &dyn Reflect, dataspace: Option<&str>) -> Vec<Pointer> {
2162 identity::collect_refs(object)
2163 .into_iter()
2164 .filter(|edge| !edge.target.uuid.is_empty())
2165 .map(|edge| {
2166 let qualified_type = edge.target.qualified_type();
2167 Pointer {
2168 // The one constructor: the version the reference names, when it
2169 // names one, is part of the uri.
2170 uri: edge.target.etp_uri(dataspace),
2171 uuid: edge.target.uuid.to_string(),
2172 qualified_type,
2173 title: edge.target.title.map(str::to_string),
2174 path: edge.path.clone(),
2175 }
2176 })
2177 .collect()
2178}
2179
2180/// Who points at this uuid.
2181///
2182/// **The question to answer before deleting anything**, and the one direction a
2183/// package has no index for: a `DataObjectReference` says what it names, and
2184/// nothing says who named it. Every object has to be asked, which is why this
2185/// takes the objects rather than a store - the caller decides when to pay for
2186/// reading them, exactly as [`build`] does.
2187///
2188/// The comparison is on the uuid and case-insensitive, because that is what an
2189/// EPC writer varies and what the identifier specification says is the same
2190/// object.
2191pub fn dependents<'a>(
2192 objects: impl IntoIterator<Item = (&'a ObjectSummary, &'a dyn Reflect)>,
2193 uuid: &str,
2194) -> Vec<Pointer> {
2195 let wanted = uuid.to_ascii_lowercase();
2196 let mut out = Vec::new();
2197 for (summary, object) in objects {
2198 if summary.uuid.to_ascii_lowercase() == wanted {
2199 continue;
2200 }
2201 for edge in identity::collect_refs(object) {
2202 if edge.target.uuid.to_ascii_lowercase() != wanted {
2203 continue;
2204 }
2205 out.push(Pointer {
2206 uri: summary.uri.clone(),
2207 uuid: summary.uuid.clone(),
2208 qualified_type: summary.qualified_type.clone(),
2209 title: title_of(object),
2210 path: edge.path.clone(),
2211 });
2212 }
2213 }
2214 out
2215}
2216
2217/// What a chosen set needs, and what it cannot get.
2218#[derive(Debug, Clone, Default, PartialEq, Eq)]
2219pub struct Closure {
2220 /// The seeds and everything they reach, in the graph's own order.
2221 ///
2222 /// The set [`crate::extract::plan`] would call closed - pass it as
2223 /// `wanted` and nothing breaks.
2224 pub needed: Vec<String>,
2225 /// Referenced, and the package does not hold it.
2226 ///
2227 /// **Named rather than dropped**: a closure that quietly ignored a
2228 /// reference into nothing would report a set as closed when the package it
2229 /// came from was already broken. Entries the standard publishes in a
2230 /// catalogue - a well-known `PropertyKind` - are not in here, because not
2231 /// holding them means nothing.
2232 pub missing: Vec<String>,
2233}
2234
2235/// Everything the chosen objects need, following references as far as they go.
2236///
2237/// # Why this is a walk of its own
2238///
2239/// [`crate::extract::plan`] deliberately does **not** close a set: it takes the
2240/// set the caller decided on and reports what would break, because two
2241/// implementations of one walk would be two answers to the same question. This
2242/// is that one walk, in the library, and `plan` still checks the result - so a
2243/// caller can close a set here and be told by a different piece of code whether
2244/// it worked.
2245///
2246/// Seeds the graph does not hold are ignored rather than invented; `plan`
2247/// reports them as `unknown`, which is where that news belongs.
2248pub fn closure(graph: &Graph, seeds: &[String]) -> Closure {
2249 let mut out_edges: std::collections::HashMap<&str, Vec<&Edge>> = std::collections::HashMap::new();
2250 for edge in &graph.edges {
2251 out_edges.entry(edge.from.as_str()).or_default().push(edge);
2252 }
2253 let node_of: std::collections::HashMap<&str, &Node> =
2254 graph.nodes.iter().map(|node| (node.uri.as_str(), node)).collect();
2255
2256 let mut reached: std::collections::HashSet<String> = std::collections::HashSet::new();
2257 let mut missing: Vec<String> = Vec::new();
2258 let mut stack: Vec<String> = Vec::new();
2259 for seed in seeds {
2260 if node_of.get(seed.as_str()).is_some_and(|node| node.present) && reached.insert(seed.clone()) {
2261 stack.push(seed.clone());
2262 }
2263 }
2264 while let Some(here) = stack.pop() {
2265 for edge in out_edges.get(here.as_str()).into_iter().flatten() {
2266 match node_of.get(edge.to.as_str()) {
2267 Some(node) if node.present => {
2268 if reached.insert(edge.to.clone()) {
2269 stack.push(edge.to.clone());
2270 }
2271 }
2272 // Absent, and the standard does not publish it: the package
2273 // names something it does not hold, and that is the caller's
2274 // news to have.
2275 Some(node) if !node.externally_defined && !missing.contains(&edge.to) => {
2276 missing.push(edge.to.clone());
2277 }
2278 _ => {}
2279 }
2280 }
2281 }
2282
2283 Closure {
2284 needed: graph
2285 .nodes
2286 .iter()
2287 .filter(|node| reached.contains(&node.uri))
2288 .map(|node| node.uri.clone())
2289 .collect(),
2290 missing,
2291 }
2292}
2293
2294/// An object's `Citation.Title`, the title a graph node carries - shared with
2295/// the package index (`energyml_domain::package_index`) so the two say the same.
2296pub fn title_of(object: &dyn Reflect) -> Option<String> {
2297 let energyml_core::value::FieldValue::Object(citation) = object.field("Citation")? else {
2298 return None;
2299 };
2300 match citation.field("Title")? {
2301 energyml_core::value::FieldValue::Scalar(scalar) => {
2302 scalar.as_str().map(str::to_string).filter(|t| !t.is_empty())
2303 }
2304 _ => None,
2305 }
2306}
2307
2308impl Graph {
2309 /// The graph as JSON: `{"nodes":[…],"edges":[…]}`.
2310 ///
2311 /// Written by hand rather than derived, for the reason the rest of this
2312 /// workspace writes its own serializers: the shape is a **contract with a
2313 /// viewer**, and a derive would let a field rename in Rust silently rename
2314 /// a key that somebody's JavaScript reads.
2315 ///
2316 /// Keys: `uri`, `uuid`, `type` (the qualified type), `concept` (the
2317 /// version-independent one, for colouring), `title`, `present`,
2318 /// `externallyDefined`; and on an edge `from`, `to`, `path`.
2319 pub fn to_json(&self) -> String {
2320 let string = |value: &str| -> String {
2321 let mut out = String::with_capacity(value.len() + 2);
2322 out.push('"');
2323 for c in value.chars() {
2324 match c {
2325 '"' => out.push_str("\\\""),
2326 '\\' => out.push_str("\\\\"),
2327 '\n' => out.push_str("\\n"),
2328 '\r' => out.push_str("\\r"),
2329 '\t' => out.push_str("\\t"),
2330 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
2331 c => out.push(c),
2332 }
2333 }
2334 out.push('"');
2335 out
2336 };
2337
2338 let mut out = String::from("{\n \"nodes\": [\n");
2339 for (n, node) in self.nodes.iter().enumerate() {
2340 out.push_str(" {");
2341 out.push_str(&format!("\"uri\": {}, ", string(&node.uri)));
2342 out.push_str(&format!("\"uuid\": {}, ", string(&node.uuid)));
2343 out.push_str(&format!("\"type\": {}, ", string(&node.qualified_type)));
2344 out.push_str(&format!("\"concept\": {}, ", string(&node.type_key)));
2345 match &node.title {
2346 Some(title) => out.push_str(&format!("\"title\": {}, ", string(title))),
2347 None => out.push_str("\"title\": null, "),
2348 }
2349 out.push_str(&format!("\"present\": {}, ", node.present));
2350 out.push_str(&format!("\"externallyDefined\": {}", node.externally_defined));
2351 out.push('}');
2352 if n + 1 < self.nodes.len() {
2353 out.push(',');
2354 }
2355 out.push('\n');
2356 }
2357 out.push_str(" ],\n \"edges\": [\n");
2358 for (n, edge) in self.edges.iter().enumerate() {
2359 out.push_str(&format!(
2360 " {{\"from\": {}, \"to\": {}, \"path\": {}}}",
2361 string(&edge.from),
2362 string(&edge.to),
2363 string(&edge.path)
2364 ));
2365 if n + 1 < self.edges.len() {
2366 out.push(',');
2367 }
2368 out.push('\n');
2369 }
2370 out.push_str(" ]\n}\n");
2371 out
2372 }
2373
2374 /// The graph as Graphviz DOT, for a look without writing a viewer first.
2375 ///
2376 /// An object the package does not hold is drawn dashed: an edge leaving the
2377 /// package is the thing worth seeing at a glance.
2378 pub fn to_dot(&self) -> String {
2379 let escape = |value: &str| value.replace('"', "\\\"");
2380 let mut out = String::from("digraph energyml {\n rankdir=LR;\n node [shape=box];\n");
2381 for node in &self.nodes {
2382 let label = match &node.title {
2383 Some(title) => format!("{}\\n{}", escape(title), escape(&node.type_key)),
2384 None => escape(&node.type_key),
2385 };
2386 let style = if node.present {
2387 String::new()
2388 } else {
2389 ", style=dashed".to_string()
2390 };
2391 // The node's **uri** is its id: it names one entry, where the uuid
2392 // names every version of it - two versions of an object would be
2393 // one box, and an edge to either would land on the same one.
2394 out.push_str(&format!(
2395 " \"{}\" [label=\"{label}\"{style}];\n",
2396 escape(&node.uri)
2397 ));
2398 }
2399 for edge in &self.edges {
2400 // An edge already holds the uris of its two nodes: no text is cut.
2401 out.push_str(&format!(
2402 " \"{}\" -> \"{}\" [label=\"{}\"];\n",
2403 escape(&edge.from),
2404 escape(&edge.to),
2405 escape(&edge.path)
2406 ));
2407 }
2408 out.push_str("}\n");
2409 out
2410 }
2411}