openbim_ids/lib.rs
1//! `openbim-ids` — buildingSMART Information Delivery Specification.
2//!
3//! # What this is
4//!
5//! The standard, machine-readable way to state *"this model must contain these
6//! things, with these properties"* and audit a model against it. It is the
7//! highest-leverage openBIM standard for real projects, because it turns
8//! contractual information requirements into an automated check.
9//!
10//! # 🚨 One namespace, six schema versions
11//!
12//! Every published IDS version from 0.2 to 1.0 declares the **same**
13//! `targetNamespace`. The namespace identifies the format, never the version.
14//! Because the differences are in attribute *names* and cardinality rather
15//! than element names, a reader that guesses wrong does not fail — it silently
16//! produces a *different* specification.
17//!
18//! Version detection must therefore report how it knows, and must surface
19//! disagreement between a file's claim and its shape instead of picking one.
20//! `openbim_core::Detected` exists for exactly this.
21//!
22//! Only 1.0 is an approved buildingSMART standard. Older versions are worth
23//! *reading* because files using them exist; new documents should be 1.0.
24//!
25//! # Reporting discipline
26//!
27//! An audit that quietly treats "property missing" as "check passed" is worse
28//! than no audit. Results distinguish applicable-and-passed,
29//! applicable-and-failed, and not-applicable — see `openbim_core::Outcome`.
30//!
31//! # Repository boundary
32//!
33//! IDS is a *consumer* of the IFC layer, not part of it. The IFC layer must
34//! never depend on this crate. Shared vocabulary belongs in `openbim-core`, and
35//! the `openbimrs/openbim` integration repository pins compatible revisions of
36//! the repositories without reversing that dependency direction.
37//!
38//! # Status
39//!
40//! [`read`] reads IDS 1.0 into the typed [`model`]; every document of the
41//! buildingSMART test corpus reads. [`write`](mod@write) writes the model
42//! back as IDS 1.0 that validates against `ids.xsd`; for every corpus
43//! document, reading what it writes gives back what was read. Producers build a model
44//! with constructors such as [`Ids::new`] and [`Specification::new`], without
45//! detection evidence.
46//!
47//! With the `audit-schema` or `audit` feature, [`audit()`] checks a document against the IFC
48//! schemas of its listed releases (entities, predefined types, attributes,
49//! value types, data types, standard property sets, `partOf` relations): see
50//! [`mod@audit`]. The feature depends on AGPL-3.0-or-later crates.
51//! Checking an IFC *model* against IDS is not implemented.
52//!
53//! The corpus is also the acceptance bar for auditing: every `pass-` case
54//! must pass and every `fail-` case fail, with not-applicable distinguished
55//! from passed.
56
57#![forbid(unsafe_code)]
58
59#[cfg(feature = "audit-schema")]
60pub mod audit;
61pub mod model;
62pub mod occurrence;
63pub mod read;
64pub mod version;
65pub mod write;
66
67#[cfg(feature = "audit-schema")]
68pub use audit::{audit, AuditCode, AuditFinding, Severity};
69pub use model::{
70 Applicability, Attribute, Classification, Entity, Facet, Ids, IfcVersion, Info, Material,
71 PartOf, Property, Relation, Requirement, Requirements, Restriction, Specification, Value,
72};
73pub use occurrence::Occurrence;
74pub use read::{from_slice, from_str, ErrorKind, ReadError};
75pub use version::{detect_version, Signal, VersionSignals};
76pub use write::{to_string, to_writer, WriteError, WriteErrorKind};
77
78/// The XML namespace shared by **all** IDS versions.
79///
80/// Deliberately a single constant: there is no per-version namespace to key
81/// on, which is the whole difficulty of reading IDS.
82pub const NAMESPACE: &str = "http://standards.buildingsmart.org/IDS";
83
84/// A published IDS schema version.
85///
86/// Ordered oldest to newest; `Ids1_0` is the only approved standard.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
88pub enum IdsVersion {
89 /// 0.9 and earlier pre-release drafts.
90 Draft0_9,
91 /// 0.9.6.
92 Draft0_9_6,
93 /// 0.9.7.
94 Draft0_9_7,
95 /// 1.0 — the approved buildingSMART standard.
96 Ids1_0,
97}
98
99impl IdsVersion {
100 /// The version new documents should be written as.
101 ///
102 /// Writing anything older is a deliberate compatibility choice, never a
103 /// default.
104 pub const CURRENT: IdsVersion = IdsVersion::Ids1_0;
105
106 /// Every published version, oldest first.
107 ///
108 /// Exposed so callers — and the tests here — can iterate versions without
109 /// hand-listing variants and silently omitting a newly added one.
110 ///
111 /// ```
112 /// use openbim_ids::IdsVersion;
113 /// assert!(IdsVersion::ALL.contains(&IdsVersion::CURRENT));
114 /// ```
115 pub const ALL: [IdsVersion; 4] = [
116 IdsVersion::Draft0_9,
117 IdsVersion::Draft0_9_6,
118 IdsVersion::Draft0_9_7,
119 IdsVersion::Ids1_0,
120 ];
121
122 /// Whether this version is an approved standard rather than a draft.
123 #[must_use]
124 pub fn is_approved(self) -> bool {
125 matches!(self, IdsVersion::Ids1_0)
126 }
127
128 /// The canonical release string, as it appears in the schema location path.
129 ///
130 /// IDS schemas are published under
131 /// `http://standards.buildingsmart.org/IDS/<version>/ids.xsd`, so these are
132 /// the forms that actually occur in an `xsi:schemaLocation` hint.
133 ///
134 /// ```
135 /// use openbim_ids::IdsVersion;
136 /// assert_eq!(IdsVersion::Ids1_0.as_str(), "1.0");
137 /// ```
138 #[must_use]
139 pub fn as_str(self) -> &'static str {
140 match self {
141 IdsVersion::Draft0_9 => "0.9",
142 IdsVersion::Draft0_9_6 => "0.9.6",
143 IdsVersion::Draft0_9_7 => "0.9.7",
144 IdsVersion::Ids1_0 => "1.0",
145 }
146 }
147}
148
149impl core::fmt::Display for IdsVersion {
150 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
151 f.write_str(self.as_str())
152 }
153}
154
155/// The error returned when a string does not name a published IDS version.
156///
157/// Carries the rejected input so a diagnostic can quote it without the call
158/// site having to keep the original string alive.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct ParseIdsVersionError {
161 input: String,
162}
163
164impl ParseIdsVersionError {
165 /// The string that failed to parse.
166 #[must_use]
167 pub fn input(&self) -> &str {
168 &self.input
169 }
170}
171
172impl core::fmt::Display for ParseIdsVersionError {
173 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
174 write!(
175 f,
176 "`{}` is not a published IDS version (expected one of 0.9, 0.9.6, 0.9.7, 1.0)",
177 self.input
178 )
179 }
180}
181
182impl std::error::Error for ParseIdsVersionError {}
183
184impl core::str::FromStr for IdsVersion {
185 type Err = ParseIdsVersionError;
186
187 /// Parses a published IDS version string.
188 ///
189 /// Accepts the canonical release forms produced by [`IdsVersion::as_str`],
190 /// and additionally the three-component spellings that the schema files
191 /// themselves carry in their own `@version` attribute — `ids.xsd` for the
192 /// approved standard declares `version="1.0.0"`, not `"1.0"`. Rejecting
193 /// that spelling would mean a version read straight out of a schema failed
194 /// to parse.
195 ///
196 /// ```
197 /// use openbim_ids::IdsVersion;
198 /// assert_eq!("1.0".parse(), Ok(IdsVersion::Ids1_0));
199 /// assert_eq!("1.0.0".parse(), Ok(IdsVersion::Ids1_0));
200 /// assert!("0.8".parse::<IdsVersion>().is_err());
201 /// ```
202 fn from_str(s: &str) -> Result<Self, Self::Err> {
203 match s.trim() {
204 "0.9" | "0.9.0" => Ok(IdsVersion::Draft0_9),
205 "0.9.6" => Ok(IdsVersion::Draft0_9_6),
206 "0.9.7" => Ok(IdsVersion::Draft0_9_7),
207 "1.0" | "1.0.0" => Ok(IdsVersion::Ids1_0),
208 other => Err(ParseIdsVersionError {
209 input: other.to_owned(),
210 }),
211 }
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 const ALL: [IdsVersion; 4] = IdsVersion::ALL;
220
221 /// `ALL` must list every variant.
222 ///
223 /// The exhaustive `match` is the enforcement: adding a variant without
224 /// extending `ALL` fails to compile here, so the iterating tests below can
225 /// never silently skip a version.
226 #[test]
227 fn all_lists_every_variant() {
228 for version in ALL {
229 match version {
230 IdsVersion::Draft0_9
231 | IdsVersion::Draft0_9_6
232 | IdsVersion::Draft0_9_7
233 | IdsVersion::Ids1_0 => {}
234 }
235 }
236 assert_eq!(ALL.len(), 4);
237 let unique: std::collections::BTreeSet<_> = ALL.iter().collect();
238 assert_eq!(unique.len(), ALL.len(), "ALL contains a duplicate");
239 }
240
241 #[test]
242 fn all_is_ordered_oldest_first() {
243 assert!(ALL.windows(2).all(|w| w[0] < w[1]));
244 }
245
246 #[test]
247 fn only_1_0_is_approved() {
248 assert!(IdsVersion::Ids1_0.is_approved());
249 assert!(!IdsVersion::Draft0_9.is_approved());
250 assert!(!IdsVersion::Draft0_9_6.is_approved());
251 assert!(!IdsVersion::Draft0_9_7.is_approved());
252 }
253
254 #[test]
255 fn current_is_the_newest_version() {
256 assert_eq!(IdsVersion::CURRENT, IdsVersion::Ids1_0);
257 assert!(IdsVersion::Ids1_0 > IdsVersion::Draft0_9_7);
258 assert!(IdsVersion::Draft0_9_7 > IdsVersion::Draft0_9);
259 }
260
261 /// Every version must survive a text round trip, for all variants.
262 ///
263 /// Enumerated exhaustively rather than spot-checked: the failure this
264 /// guards against is a new variant getting an `as_str` arm that `from_str`
265 /// does not accept, which only shows up on the variant nobody tested.
266 #[test]
267 fn every_version_round_trips_through_text() {
268 for version in ALL {
269 assert_eq!(
270 version.as_str().parse::<IdsVersion>(),
271 Ok(version),
272 "{version} did not round trip"
273 );
274 }
275 }
276
277 #[test]
278 fn display_matches_as_str() {
279 for version in ALL {
280 assert_eq!(version.to_string(), version.as_str());
281 }
282 }
283
284 #[test]
285 fn version_strings_are_distinct() {
286 let mut seen = std::collections::BTreeSet::new();
287 for version in ALL {
288 assert!(seen.insert(version.as_str()), "duplicate {version}");
289 }
290 }
291
292 /// The schema files declare three-component versions; accept those too.
293 #[test]
294 fn schema_file_version_spelling_parses() {
295 assert_eq!("1.0.0".parse(), Ok(IdsVersion::Ids1_0));
296 assert_eq!("0.9.0".parse(), Ok(IdsVersion::Draft0_9));
297 }
298
299 #[test]
300 fn surrounding_whitespace_is_tolerated() {
301 assert_eq!(" 1.0\n".parse(), Ok(IdsVersion::Ids1_0));
302 }
303
304 #[test]
305 fn unknown_versions_are_rejected_and_quote_the_input() {
306 let err = "0.8".parse::<IdsVersion>().expect_err("must reject 0.8");
307 assert_eq!(err.input(), "0.8");
308 assert!(err.to_string().contains("0.8"), "{err}");
309
310 for bad in ["", "1", "1.0.1", "v1.0", "IDS1.0"] {
311 assert!(
312 bad.parse::<IdsVersion>().is_err(),
313 "`{bad}` must not parse as a published version"
314 );
315 }
316 }
317}