Skip to main content

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}