Skip to main content

openbim_ids/
occurrence.rs

1//! Occurrence, modelled where each IDS revision puts it.
2//!
3//! # Why this type exists
4//!
5//! IDS moved occurrence between revisions rather than renaming it:
6//!
7//! - **0.9** carries `xs:occurs` (`minOccurs` / `maxOccurs`) on
8//!   `specificationType` and on every requirement facet.
9//! - **1.0** moved occurrence onto `<applicability>`, and requirement facets
10//!   instead carry `@cardinality` of `required`, `prohibited` or `optional`.
11//!
12//! Both spell the same three intents. Keeping the revision's *spelling* in the
13//! typed model would mean a 0.9 document and a 1.0 document stating identical
14//! requirements produce unequal output, and every consumer would have to learn
15//! both encodings. So both lower into this one type, and the revision-specific
16//! spelling survives only as the function that produced it.
17
18/// What a requirement demands of the elements a specification applies to.
19///
20/// This is the normalised form: [`from_xs_occurs`](Occurrence::from_xs_occurs)
21/// lowers the 0.9 spelling and [`from_cardinality`](Occurrence::from_cardinality)
22/// the 1.0 spelling, so equal intent compares equal across revisions.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum Occurrence {
25    /// The facet must be present. The 1.0 `required` cardinality, and the 0.9
26    /// spelling `minOccurs >= 1`.
27    Required,
28    /// The facet must be absent. The 1.0 `prohibited` cardinality, and the 0.9
29    /// spelling `maxOccurs = 0`.
30    Prohibited,
31    /// The facet may be present or absent. The 1.0 `optional` cardinality, and
32    /// the 0.9 spelling `minOccurs = 0` with a non-zero `maxOccurs`.
33    Optional,
34}
35
36impl Occurrence {
37    /// The schema default.
38    ///
39    /// Load-bearing, and the reason it is named rather than left implicit: real
40    /// IDS files lean on it. A facet writing no `@cardinality` at all means
41    /// *required*, so a reader that treats "absent" as "optional" silently
42    /// drops requirements.
43    pub const DEFAULT: Occurrence = Occurrence::Required;
44
45    /// Lowers the IDS 1.0 `@cardinality` attribute.
46    ///
47    /// ```
48    /// use openbim_ids::Occurrence;
49    /// assert_eq!(Occurrence::from_cardinality("prohibited"), Some(Occurrence::Prohibited));
50    /// assert_eq!(Occurrence::from_cardinality("Required"), None);
51    /// ```
52    ///
53    /// Matching is case-sensitive because the schema enumerates lower-case
54    /// tokens; accepting other spellings would mean accepting documents the
55    /// schema rejects.
56    #[must_use]
57    pub fn from_cardinality(value: &str) -> Option<Occurrence> {
58        match value {
59            "required" => Some(Occurrence::Required),
60            "prohibited" => Some(Occurrence::Prohibited),
61            "optional" => Some(Occurrence::Optional),
62            _ => None,
63        }
64    }
65
66    /// The IDS 1.0 `@cardinality` spelling of this occurrence.
67    #[must_use]
68    pub fn as_cardinality(self) -> &'static str {
69        match self {
70            Occurrence::Required => "required",
71            Occurrence::Prohibited => "prohibited",
72            Occurrence::Optional => "optional",
73        }
74    }
75
76    /// Lowers the IDS 0.9 `xs:occurs` spelling.
77    ///
78    /// `max` is `None` for `maxOccurs="unbounded"`. Absent attributes must be
79    /// passed as their schema defaults of `1`, not as `0` — see
80    /// [`Occurrence::DEFAULT`].
81    ///
82    /// ```
83    /// use openbim_ids::Occurrence;
84    /// // The 0.9 and 1.0 spellings of the same intent agree.
85    /// assert_eq!(
86    ///     Occurrence::from_xs_occurs(0, Some(0)),
87    ///     Occurrence::from_cardinality("prohibited").unwrap()
88    /// );
89    /// assert_eq!(Occurrence::from_xs_occurs(1, None), Occurrence::Required);
90    /// ```
91    #[must_use]
92    pub fn from_xs_occurs(min: u32, max: Option<u32>) -> Occurrence {
93        // Order matters: `minOccurs="0" maxOccurs="0"` is prohibited, not
94        // optional. Testing the zero maximum first keeps that unambiguous.
95        if max == Some(0) {
96            Occurrence::Prohibited
97        } else if min == 0 {
98            Occurrence::Optional
99        } else {
100            Occurrence::Required
101        }
102    }
103
104    /// Whether the facet is allowed to appear at all.
105    #[must_use]
106    pub fn permits_presence(self) -> bool {
107        !matches!(self, Occurrence::Prohibited)
108    }
109}
110
111impl Default for Occurrence {
112    fn default() -> Self {
113        Occurrence::DEFAULT
114    }
115}
116
117impl core::fmt::Display for Occurrence {
118    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
119        f.write_str(self.as_cardinality())
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    const ALL: [Occurrence; 3] = [
128        Occurrence::Required,
129        Occurrence::Prohibited,
130        Occurrence::Optional,
131    ];
132
133    #[test]
134    fn cardinality_round_trips() {
135        for occurrence in ALL {
136            assert_eq!(
137                Occurrence::from_cardinality(occurrence.as_cardinality()),
138                Some(occurrence)
139            );
140        }
141    }
142
143    #[test]
144    fn unknown_cardinality_is_rejected() {
145        for bad in ["", "Required", "REQUIRED", "mandatory", "forbidden"] {
146            assert!(
147                Occurrence::from_cardinality(bad).is_none(),
148                "`{bad}` must not parse"
149            );
150        }
151    }
152
153    /// The defining property: revisions disagree on spelling, not on meaning.
154    #[test]
155    fn revisions_agree_on_equal_intent() {
156        // required: 0.9 `minOccurs=1`, 1.0 `cardinality="required"`
157        assert_eq!(
158            Occurrence::from_xs_occurs(1, Some(1)),
159            Occurrence::from_cardinality("required").expect("required")
160        );
161        // optional: 0.9 `minOccurs=0 maxOccurs=1`, 1.0 `cardinality="optional"`
162        assert_eq!(
163            Occurrence::from_xs_occurs(0, Some(1)),
164            Occurrence::from_cardinality("optional").expect("optional")
165        );
166        // prohibited: 0.9 `maxOccurs=0`, 1.0 `cardinality="prohibited"`
167        assert_eq!(
168            Occurrence::from_xs_occurs(0, Some(0)),
169            Occurrence::from_cardinality("prohibited").expect("prohibited")
170        );
171    }
172
173    #[test]
174    fn unbounded_maximum_is_required_not_optional() {
175        assert_eq!(Occurrence::from_xs_occurs(1, None), Occurrence::Required);
176        assert_eq!(Occurrence::from_xs_occurs(0, None), Occurrence::Optional);
177    }
178
179    /// `maxOccurs="0"` wins over `minOccurs="0"`.
180    #[test]
181    fn zero_maximum_is_prohibited_even_with_zero_minimum() {
182        assert_eq!(
183            Occurrence::from_xs_occurs(0, Some(0)),
184            Occurrence::Prohibited
185        );
186    }
187
188    /// The schema default is required; reading absent as optional drops
189    /// requirements silently.
190    #[test]
191    fn default_is_required() {
192        assert_eq!(Occurrence::default(), Occurrence::Required);
193        assert_eq!(Occurrence::DEFAULT, Occurrence::Required);
194    }
195
196    #[test]
197    fn only_prohibited_forbids_presence() {
198        assert!(Occurrence::Required.permits_presence());
199        assert!(Occurrence::Optional.permits_presence());
200        assert!(!Occurrence::Prohibited.permits_presence());
201    }
202
203    #[test]
204    fn display_matches_cardinality() {
205        for occurrence in ALL {
206            assert_eq!(occurrence.to_string(), occurrence.as_cardinality());
207        }
208    }
209}