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}