Skip to main content

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}