Skip to main content

openbim_ids/
version.rs

1//! Version detection that reports the evidence it used.
2//!
3//! # The hazard
4//!
5//! Every published IDS revision declares the same `targetNamespace`, so the
6//! namespace cannot discriminate. The revisions differ in attribute *names*
7//! and in where occurrence lives, not in element names — so a reader that
8//! guesses wrong does not fail. It produces a *different specification*,
9//! silently, and an audit run against it reports confident wrong answers.
10//!
11//! This module therefore never guesses. It collects the shape signals a
12//! document exhibits, infers a revision from them, and compares that against
13//! whatever the document declared. Disagreement becomes
14//! [`Detected::Conflict`], which [`Detected::resolved`] refuses to collapse.
15//!
16//! # Scope
17//!
18//! [`VersionSignals`] is the evidence *model*, deliberately independent of any
19//! XML reader: it takes observations and decides. Populating it from a document
20//! belongs to the parser (`IDS-PARSE`), which keeps the detection rules
21//! testable now and keeps this crate free of a parser dependency it does not
22//! yet need.
23
24use crate::IdsVersion;
25use openbim_core::Detected;
26
27/// A shape signal observed in a document, and the revision it indicates.
28///
29/// Each variant is an *observation*, not a conclusion — a document can exhibit
30/// signals pointing at different revisions, which is precisely the case that
31/// must be reported rather than resolved.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
33pub enum Signal {
34    /// A requirement facet carries `minOccurs` / `maxOccurs`.
35    ///
36    /// Pre-1.0: 1.0 moved occurrence onto `<applicability>` and gave facets
37    /// `@cardinality` instead. This is the single most reliable tell.
38    OccursOnRequirementFacet,
39    /// A requirement facet carries `@cardinality`.
40    ///
41    /// 1.0 only — the attribute does not exist before it.
42    CardinalityOnRequirementFacet,
43    /// A property facet uses `<name>`.
44    ///
45    /// Pre-1.0: renamed to `<baseName>` in 1.0.
46    PropertyName,
47    /// A property facet uses `<baseName>`.
48    ///
49    /// 1.0 only.
50    PropertyBaseName,
51    /// A property facet carries `@measure`.
52    ///
53    /// Pre-1.0: renamed to `@dataType` in 1.0.
54    MeasureAttribute,
55    /// A property facet carries `@dataType`.
56    ///
57    /// 1.0 only.
58    DataTypeAttribute,
59    /// An `ifcVersion` lists `IFC4X3`.
60    ///
61    /// Pre-1.0: 1.0 spells it `IFC4X3_ADD2`.
62    Ifc4x3Unsuffixed,
63    /// An `ifcVersion` lists `IFC4X3_ADD2`.
64    ///
65    /// 1.0 only.
66    Ifc4x3Add2,
67    /// A classification facet omits `<system>`.
68    ///
69    /// Pre-1.0: `<system>` became required in 1.0. Weaker than the others —
70    /// its *presence* proves nothing, since 0.9 permits it too.
71    ClassificationWithoutSystem,
72}
73
74impl Signal {
75    /// The revision this signal indicates, or `None` if it only excludes 1.0.
76    ///
77    /// Pre-1.0 signals cannot pick between 0.9, 0.9.6 and 0.9.7 — those
78    /// revisions are not distinguished by the shapes listed here, so claiming
79    /// a specific draft would be an invention. They resolve to
80    /// [`IdsVersion::Draft0_9`] as the least specific pre-1.0 revision.
81    #[must_use]
82    pub fn indicates(self) -> IdsVersion {
83        match self {
84            Signal::CardinalityOnRequirementFacet
85            | Signal::PropertyBaseName
86            | Signal::DataTypeAttribute
87            | Signal::Ifc4x3Add2 => IdsVersion::Ids1_0,
88            Signal::OccursOnRequirementFacet
89            | Signal::PropertyName
90            | Signal::MeasureAttribute
91            | Signal::Ifc4x3Unsuffixed
92            | Signal::ClassificationWithoutSystem => IdsVersion::Draft0_9,
93        }
94    }
95
96    /// Whether this signal is exclusive to IDS 1.0.
97    #[must_use]
98    pub fn is_1_0_only(self) -> bool {
99        self.indicates() == IdsVersion::Ids1_0
100    }
101}
102
103/// The shape evidence a document exhibits.
104///
105/// Built by the reader as it walks a document, then interrogated once at the
106/// end — a single facet is not enough to judge a file, and a mixed document
107/// must be reported as mixed rather than decided by whichever facet was seen
108/// last.
109#[derive(Debug, Clone, Default, PartialEq, Eq)]
110pub struct VersionSignals {
111    observed: std::collections::BTreeSet<Signal>,
112}
113
114impl VersionSignals {
115    /// Evidence with nothing observed yet.
116    #[must_use]
117    pub fn new() -> Self {
118        Self::default()
119    }
120
121    /// Records a signal. Repeat observations are idempotent.
122    pub fn observe(&mut self, signal: Signal) -> &mut Self {
123        self.observed.insert(signal);
124        self
125    }
126
127    /// Whether a given signal was observed.
128    #[must_use]
129    pub fn saw(&self, signal: Signal) -> bool {
130        self.observed.contains(&signal)
131    }
132
133    /// Whether any signal was observed at all.
134    #[must_use]
135    pub fn is_empty(&self) -> bool {
136        self.observed.is_empty()
137    }
138
139    /// Every observed signal, in a stable order.
140    pub fn iter(&self) -> impl Iterator<Item = Signal> + '_ {
141        self.observed.iter().copied()
142    }
143
144    /// The observed signals that are exclusive to 1.0.
145    #[must_use]
146    pub fn signals_for_1_0(&self) -> Vec<Signal> {
147        self.iter().filter(|s| s.is_1_0_only()).collect()
148    }
149
150    /// The observed signals that indicate a pre-1.0 draft.
151    #[must_use]
152    pub fn signals_for_draft(&self) -> Vec<Signal> {
153        self.iter().filter(|s| !s.is_1_0_only()).collect()
154    }
155
156    /// Whether the document mixes shapes from incompatible revisions.
157    ///
158    /// A document doing this is malformed under *both* revisions. It is
159    /// reported, never repaired: choosing which half to believe changes which
160    /// requirements exist.
161    #[must_use]
162    pub fn is_internally_inconsistent(&self) -> bool {
163        !self.signals_for_1_0().is_empty() && !self.signals_for_draft().is_empty()
164    }
165
166    /// The revision the shape indicates, if the evidence is unambiguous.
167    ///
168    /// Returns `None` when nothing was observed, and also when the evidence is
169    /// self-contradictory — both are cases where a caller must not receive a
170    /// confident answer.
171    #[must_use]
172    pub fn infer(&self) -> Option<IdsVersion> {
173        if self.is_empty() || self.is_internally_inconsistent() {
174            return None;
175        }
176        Some(if self.signals_for_1_0().is_empty() {
177            IdsVersion::Draft0_9
178        } else {
179            IdsVersion::Ids1_0
180        })
181    }
182}
183
184/// Resolves a document's version from what it declared and what it looks like.
185///
186/// The four outcomes:
187///
188/// - declared, and the shape agrees (or is silent) → [`Detected::Declared`]
189/// - nothing declared, shape unambiguous → [`Detected::Inferred`]
190/// - declared, shape says otherwise → [`Detected::Conflict`]
191/// - nothing declared and no usable shape evidence → `None`
192///
193/// The conflict case is the one that matters: a document declaring 1.0 while
194/// carrying `minOccurs` on a requirement facet is a pre-1.0 draft with a
195/// rewritten header, and reading it as 1.0 loses every occurrence constraint
196/// without erroring.
197///
198/// ```
199/// use openbim_ids::{detect_version, IdsVersion, Signal, VersionSignals};
200/// use openbim_core::Detected;
201///
202/// let mut signals = VersionSignals::new();
203/// signals.observe(Signal::OccursOnRequirementFacet);
204///
205/// // A 1.0 claim over a pre-1.0 shape must not resolve.
206/// let detected = detect_version(Some(IdsVersion::Ids1_0), &signals).expect("evidence");
207/// assert!(detected.is_conflict());
208/// assert_eq!(detected.resolved(), None);
209/// ```
210#[must_use]
211pub fn detect_version(
212    declared: Option<IdsVersion>,
213    signals: &VersionSignals,
214) -> Option<Detected<IdsVersion>> {
215    let observed = signals.infer();
216    match (declared, observed) {
217        // Both present: agreement confirms the claim, disagreement is reported.
218        (Some(declared), Some(observed)) => Some(if versions_agree(declared, observed) {
219            Detected::Declared(declared)
220        } else {
221            Detected::Conflict { declared, observed }
222        }),
223        // A claim with no usable shape evidence is taken at face value; there
224        // is nothing to contradict it. An internally inconsistent document
225        // yields no inference, so it lands here and keeps the declared value.
226        (Some(declared), None) => Some(Detected::Declared(declared)),
227        // No claim, but the shape is unambiguous.
228        (None, Some(observed)) => Some(Detected::Inferred(observed)),
229        (None, None) => None,
230    }
231}
232
233/// Whether a declared revision is consistent with an inferred one.
234///
235/// Shape inference cannot separate the pre-1.0 drafts from each other, so any
236/// declared draft is consistent with an inferred `Draft0_9`. Treating a
237/// declared 0.9.7 as conflicting with an inferred "some draft" would be a false
238/// positive, and false conflicts train callers to ignore real ones.
239fn versions_agree(declared: IdsVersion, observed: IdsVersion) -> bool {
240    match observed {
241        IdsVersion::Ids1_0 => declared == IdsVersion::Ids1_0,
242        _ => declared != IdsVersion::Ids1_0,
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    fn signals(list: &[Signal]) -> VersionSignals {
251        let mut s = VersionSignals::new();
252        for signal in list {
253            s.observe(*signal);
254        }
255        s
256    }
257
258    #[test]
259    fn every_signal_indicates_a_revision() {
260        let all = [
261            Signal::OccursOnRequirementFacet,
262            Signal::CardinalityOnRequirementFacet,
263            Signal::PropertyName,
264            Signal::PropertyBaseName,
265            Signal::MeasureAttribute,
266            Signal::DataTypeAttribute,
267            Signal::Ifc4x3Unsuffixed,
268            Signal::Ifc4x3Add2,
269            Signal::ClassificationWithoutSystem,
270        ];
271        for signal in all {
272            // Exhaustive match: a new variant without an `indicates` arm fails
273            // to compile, so it cannot silently default to a revision.
274            assert!(matches!(
275                signal.indicates(),
276                IdsVersion::Ids1_0 | IdsVersion::Draft0_9
277            ));
278        }
279        assert_eq!(all.iter().filter(|s| s.is_1_0_only()).count(), 4);
280    }
281
282    /// The headline case from the issue.
283    #[test]
284    fn occurs_on_a_requirement_facet_is_a_pre_1_0_tell() {
285        let s = signals(&[Signal::OccursOnRequirementFacet]);
286        assert_eq!(s.infer(), Some(IdsVersion::Draft0_9));
287    }
288
289    #[test]
290    fn cardinality_on_a_requirement_facet_is_1_0() {
291        let s = signals(&[Signal::CardinalityOnRequirementFacet]);
292        assert_eq!(s.infer(), Some(IdsVersion::Ids1_0));
293    }
294
295    /// A 1.0 document carrying `minOccurs` must be reported, not parsed.
296    #[test]
297    fn declared_1_0_over_a_draft_shape_is_a_conflict() {
298        let s = signals(&[Signal::OccursOnRequirementFacet]);
299        let detected = detect_version(Some(IdsVersion::Ids1_0), &s).expect("evidence");
300        assert_eq!(
301            detected,
302            Detected::Conflict {
303                declared: IdsVersion::Ids1_0,
304                observed: IdsVersion::Draft0_9,
305            }
306        );
307        assert!(detected.is_conflict());
308        assert_eq!(detected.resolved(), None, "a conflict must not resolve");
309    }
310
311    #[test]
312    fn declared_draft_over_a_1_0_shape_is_a_conflict() {
313        let s = signals(&[Signal::CardinalityOnRequirementFacet]);
314        let detected = detect_version(Some(IdsVersion::Draft0_9_7), &s).expect("evidence");
315        assert!(detected.is_conflict());
316    }
317
318    #[test]
319    fn agreement_is_declared_not_inferred() {
320        let s = signals(&[
321            Signal::CardinalityOnRequirementFacet,
322            Signal::PropertyBaseName,
323        ]);
324        assert_eq!(
325            detect_version(Some(IdsVersion::Ids1_0), &s),
326            Some(Detected::Declared(IdsVersion::Ids1_0))
327        );
328    }
329
330    #[test]
331    fn shape_alone_infers() {
332        let s = signals(&[Signal::PropertyBaseName, Signal::DataTypeAttribute]);
333        assert_eq!(
334            detect_version(None, &s),
335            Some(Detected::Inferred(IdsVersion::Ids1_0))
336        );
337    }
338
339    #[test]
340    fn no_declaration_and_no_evidence_yields_nothing() {
341        assert_eq!(detect_version(None, &VersionSignals::new()), None);
342    }
343
344    #[test]
345    fn a_declaration_with_no_evidence_stands() {
346        assert_eq!(
347            detect_version(Some(IdsVersion::Ids1_0), &VersionSignals::new()),
348            Some(Detected::Declared(IdsVersion::Ids1_0))
349        );
350    }
351
352    /// Inference cannot separate the drafts, so declaring one is not a conflict.
353    #[test]
354    fn a_declared_draft_does_not_conflict_with_an_inferred_draft() {
355        let s = signals(&[Signal::PropertyName]);
356        for declared in [
357            IdsVersion::Draft0_9,
358            IdsVersion::Draft0_9_6,
359            IdsVersion::Draft0_9_7,
360        ] {
361            let detected = detect_version(Some(declared), &s).expect("evidence");
362            assert_eq!(
363                detected,
364                Detected::Declared(declared),
365                "{declared} must not be reported as conflicting with a draft shape"
366            );
367        }
368    }
369
370    #[test]
371    fn mixed_shapes_are_inconsistent_and_do_not_infer() {
372        let s = signals(&[
373            Signal::OccursOnRequirementFacet,
374            Signal::CardinalityOnRequirementFacet,
375        ]);
376        assert!(s.is_internally_inconsistent());
377        assert_eq!(
378            s.infer(),
379            None,
380            "a self-contradictory document must not infer"
381        );
382    }
383
384    /// Each rename from the issue, in both directions.
385    #[test]
386    fn each_rename_is_detected() {
387        let cases = [
388            (Signal::PropertyName, Signal::PropertyBaseName),
389            (Signal::MeasureAttribute, Signal::DataTypeAttribute),
390            (Signal::Ifc4x3Unsuffixed, Signal::Ifc4x3Add2),
391        ];
392        for (old, new) in cases {
393            assert_eq!(
394                signals(&[old]).infer(),
395                Some(IdsVersion::Draft0_9),
396                "{old:?} must indicate a draft"
397            );
398            assert_eq!(
399                signals(&[new]).infer(),
400                Some(IdsVersion::Ids1_0),
401                "{new:?} must indicate 1.0"
402            );
403        }
404    }
405
406    /// `<system>` became required in 1.0, so only its absence is evidence.
407    #[test]
408    fn missing_classification_system_indicates_a_draft() {
409        assert_eq!(
410            signals(&[Signal::ClassificationWithoutSystem]).infer(),
411            Some(IdsVersion::Draft0_9)
412        );
413    }
414
415    #[test]
416    fn observing_is_idempotent_and_queryable() {
417        let mut s = VersionSignals::new();
418        assert!(s.is_empty());
419        s.observe(Signal::PropertyBaseName)
420            .observe(Signal::PropertyBaseName);
421        assert!(!s.is_empty());
422        assert!(s.saw(Signal::PropertyBaseName));
423        assert!(!s.saw(Signal::PropertyName));
424        assert_eq!(s.iter().count(), 1);
425    }
426}