Skip to main content

openbim_dt/
model.rs

1//! Typed ISO 23387 views over the lossless XML tree.
2
3use std::{collections::HashMap, error::Error, fmt, str::FromStr};
4
5use crate::{
6    AnyUri, DataTypeName, DateTime, Document, Element, Guid, MultiLanguageText, Reference,
7    ValueError, DRAFT_PLACEHOLDER_NAMESPACE, NAMESPACE,
8};
9
10/// Recognized ISO 23387 element kinds, including local `Library` children.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub enum ElementKind {
13    Library,
14    Subject,
15    DataTemplate,
16    ObjectType,
17    GroupOfProperties,
18    Property,
19    Unit,
20    Dimension,
21    QuantityKind,
22    ReferenceDocument,
23}
24
25impl ElementKind {
26    /// Whether Annex E declares this kind as a concrete global document root.
27    #[must_use]
28    pub const fn is_global_root(self) -> bool {
29        matches!(
30            self,
31            Self::Library
32                | Self::DataTemplate
33                | Self::ObjectType
34                | Self::GroupOfProperties
35                | Self::Property
36        )
37    }
38
39    #[must_use]
40    pub fn from_element(element: &Element) -> Option<Self> {
41        if element.namespace_uri() != Some(NAMESPACE) {
42            return None;
43        }
44        match element.local_name() {
45            "Library" => Some(Self::Library),
46            "Subject" => Some(Self::Subject),
47            "DataTemplate" => Some(Self::DataTemplate),
48            "ObjectType" => Some(Self::ObjectType),
49            "GroupOfProperties" => Some(Self::GroupOfProperties),
50            "Property" => Some(Self::Property),
51            "Unit" => Some(Self::Unit),
52            "Dimension" => Some(Self::Dimension),
53            "QuantityKind" => Some(Self::QuantityKind),
54            "ReferenceDocument" => Some(Self::ReferenceDocument),
55            _ => None,
56        }
57    }
58}
59
60impl Document {
61    #[must_use]
62    pub fn root_kind(&self) -> Option<ElementKind> {
63        ElementKind::from_element(self.root()).filter(|kind| kind.is_global_root())
64    }
65
66    #[must_use]
67    pub fn library(&self) -> Option<Library<'_>> {
68        (self.root_kind() == Some(ElementKind::Library)).then(|| Library {
69            element: self.root(),
70        })
71    }
72
73    /// Separates the root for embedding or conversion into an owned DT type.
74    #[must_use]
75    pub fn into_root_element(self) -> Element {
76        self.take_root()
77    }
78}
79
80/// Borrowed ISO 23387 library view.
81#[derive(Debug, Clone, Copy)]
82pub struct Library<'a> {
83    element: &'a Element,
84}
85
86impl<'a> Library<'a> {
87    #[must_use]
88    pub const fn element(self) -> &'a Element {
89        self.element
90    }
91
92    pub fn guid(self) -> Result<Guid, ValueError> {
93        required_guid(self.element)
94    }
95
96    pub fn items(self) -> impl Iterator<Item = LibraryItem<'a>> {
97        self.element.children().map(classify_library_item)
98    }
99}
100
101/// One child retained by a library, including forward-compatible extensions.
102#[derive(Debug, Clone, Copy)]
103pub enum LibraryItem<'a> {
104    Name(MultilingualTextRef<'a>),
105    DataTemplate(DataTemplateRef<'a>),
106    ObjectType(ConceptRef<'a>),
107    GroupOfProperties(ConceptRef<'a>),
108    Property(PropertyRef<'a>),
109    Unit(ConceptRef<'a>),
110    Dimension(ConceptRef<'a>),
111    QuantityKind(ConceptRef<'a>),
112    ReferenceDocument(ConceptRef<'a>),
113    Extension(&'a Element),
114}
115
116fn classify_library_item(element: &Element) -> LibraryItem<'_> {
117    if is_dt(element, "Name") {
118        return LibraryItem::Name(MultilingualTextRef { element });
119    }
120    match ElementKind::from_element(element) {
121        Some(ElementKind::DataTemplate) => LibraryItem::DataTemplate(DataTemplateRef { element }),
122        Some(ElementKind::ObjectType) => LibraryItem::ObjectType(ConceptRef { element }),
123        Some(ElementKind::GroupOfProperties) => {
124            LibraryItem::GroupOfProperties(ConceptRef { element })
125        }
126        Some(ElementKind::Property) => LibraryItem::Property(PropertyRef { element }),
127        Some(ElementKind::Unit) => LibraryItem::Unit(ConceptRef { element }),
128        Some(ElementKind::Dimension) => LibraryItem::Dimension(ConceptRef { element }),
129        Some(ElementKind::QuantityKind) => LibraryItem::QuantityKind(ConceptRef { element }),
130        Some(ElementKind::ReferenceDocument) => {
131            LibraryItem::ReferenceDocument(ConceptRef { element })
132        }
133        _ => LibraryItem::Extension(element),
134    }
135}
136
137/// Borrowed multilingual text typed by ISO 23387.
138#[derive(Debug, Clone, Copy)]
139pub struct MultilingualTextRef<'a> {
140    element: &'a Element,
141}
142
143impl<'a> MultilingualTextRef<'a> {
144    #[must_use]
145    pub fn language(self) -> Option<&'a str> {
146        self.element.attribute_ns(None, "language")
147    }
148
149    #[must_use]
150    pub fn text(self) -> String {
151        self.element.direct_text()
152    }
153
154    pub fn to_owned(self) -> Result<MultiLanguageText, ValueError> {
155        MultiLanguageText::new(self.language().unwrap_or_default(), self.text())
156    }
157}
158
159/// Borrowed reference typed by ISO 23387.
160#[derive(Debug, Clone, Copy)]
161pub struct ReferenceRef<'a> {
162    element: &'a Element,
163}
164
165impl<'a> ReferenceRef<'a> {
166    pub fn guid(self) -> Option<Result<Guid, ValueError>> {
167        self.element
168            .attribute_ns(Some(NAMESPACE), "GUID")
169            .map(Guid::from_str)
170    }
171
172    #[must_use]
173    pub fn uri(self) -> Option<&'a str> {
174        self.element.attribute_ns(Some(NAMESPACE), "referenceURI")
175    }
176
177    pub fn to_owned(self) -> Result<Reference, ValueError> {
178        let guid = self.guid().transpose()?;
179        let uri = self.uri().map(str::parse).transpose()?;
180        Ok(Reference::new(guid, uri))
181    }
182}
183
184/// Shared borrowed view for every `ConceptType`-derived element.
185#[derive(Debug, Clone, Copy)]
186pub struct ConceptRef<'a> {
187    element: &'a Element,
188}
189
190impl<'a> ConceptRef<'a> {
191    #[must_use]
192    pub const fn element(self) -> &'a Element {
193        self.element
194    }
195
196    pub fn guid(self) -> Result<Guid, ValueError> {
197        required_guid(self.element)
198    }
199
200    #[must_use]
201    pub fn date_of_creation(self) -> Option<&'a str> {
202        self.element.attribute_ns(None, "dateOfCreation")
203    }
204
205    pub fn names(self) -> impl Iterator<Item = MultilingualTextRef<'a>> {
206        self.element
207            .children()
208            .filter(|element| is_dt(element, "Name"))
209            .map(|element| MultilingualTextRef { element })
210    }
211
212    pub fn definitions(self) -> impl Iterator<Item = MultilingualTextRef<'a>> {
213        self.element
214            .children()
215            .filter(|element| is_dt(element, "Definition"))
216            .map(|element| MultilingualTextRef { element })
217    }
218
219    pub fn references(self, local_name: &'a str) -> impl Iterator<Item = ReferenceRef<'a>> {
220        self.element
221            .children()
222            .filter(move |element| is_dt(element, local_name))
223            .map(|element| ReferenceRef { element })
224    }
225}
226
227/// Borrowed data-template view.
228#[derive(Debug, Clone, Copy)]
229pub struct DataTemplateRef<'a> {
230    element: &'a Element,
231}
232
233impl<'a> DataTemplateRef<'a> {
234    #[must_use]
235    pub const fn element(self) -> &'a Element {
236        self.element
237    }
238
239    #[must_use]
240    pub const fn concept(self) -> ConceptRef<'a> {
241        ConceptRef {
242            element: self.element,
243        }
244    }
245
246    pub fn property_references(self) -> impl Iterator<Item = ReferenceRef<'a>> {
247        self.concept().references("HasPropertyRef")
248    }
249
250    pub fn group_references(self) -> impl Iterator<Item = ReferenceRef<'a>> {
251        self.concept().references("HasGroupOfPropertiesRef")
252    }
253
254    pub fn object_type_reference(self) -> Option<ReferenceRef<'a>> {
255        self.concept().references("HasObjectTypeRef").next()
256    }
257}
258
259/// Borrowed property view.
260#[derive(Debug, Clone, Copy)]
261pub struct PropertyRef<'a> {
262    element: &'a Element,
263}
264
265impl<'a> PropertyRef<'a> {
266    #[must_use]
267    pub const fn element(self) -> &'a Element {
268        self.element
269    }
270
271    #[must_use]
272    pub const fn concept(self) -> ConceptRef<'a> {
273        ConceptRef {
274            element: self.element,
275        }
276    }
277
278    pub fn names(self) -> impl Iterator<Item = MultilingualTextRef<'a>> {
279        self.concept().names()
280    }
281
282    #[must_use]
283    pub fn data_type(self) -> Option<DataTypeRef<'a>> {
284        self.element
285            .children()
286            .find(|element| is_dt(element, "DataType"))
287            .map(|element| DataTypeRef { element })
288    }
289}
290
291/// Borrowed data-type constraint view.
292#[derive(Debug, Clone, Copy)]
293pub struct DataTypeRef<'a> {
294    element: &'a Element,
295}
296
297impl<'a> DataTypeRef<'a> {
298    #[must_use]
299    pub fn name(self) -> Option<&'a str> {
300        self.element.attribute_ns(None, "name")
301    }
302
303    #[must_use]
304    pub fn name_kind(self) -> Option<DataTypeName> {
305        self.name().map(DataTypeName::from)
306    }
307}
308
309/// An owned element proven to have one ISO 23387 global kind.
310#[derive(Debug, Clone, PartialEq, Eq)]
311pub struct TypedElement<const KIND: u8> {
312    element: Element,
313}
314
315impl<const KIND: u8> TypedElement<KIND> {
316    pub fn try_from_element(element: Element) -> Result<Self, ModelError> {
317        let expected = kind_from_discriminant(KIND);
318        let actual = ElementKind::from_element(&element);
319        if actual != Some(expected) {
320            return Err(ModelError { expected, actual });
321        }
322        Ok(Self { element })
323    }
324
325    #[must_use]
326    pub const fn element(&self) -> &Element {
327        &self.element
328    }
329
330    #[must_use]
331    pub fn into_element(self) -> Element {
332        self.element
333    }
334}
335
336impl<const KIND: u8> TryFrom<Element> for TypedElement<KIND> {
337    type Error = ModelError;
338
339    fn try_from(element: Element) -> Result<Self, Self::Error> {
340        Self::try_from_element(element)
341    }
342}
343
344pub type LibraryElement = TypedElement<0>;
345pub type SubjectElement = TypedElement<1>;
346pub type DataTemplateElement = TypedElement<2>;
347pub type ObjectTypeElement = TypedElement<3>;
348pub type GroupOfPropertiesElement = TypedElement<4>;
349pub type PropertyElement = TypedElement<5>;
350pub type UnitElement = TypedElement<6>;
351pub type DimensionElement = TypedElement<7>;
352pub type QuantityKindElement = TypedElement<8>;
353pub type ReferenceDocumentElement = TypedElement<9>;
354
355const fn kind_from_discriminant(value: u8) -> ElementKind {
356    match value {
357        0 => ElementKind::Library,
358        1 => ElementKind::Subject,
359        2 => ElementKind::DataTemplate,
360        3 => ElementKind::ObjectType,
361        4 => ElementKind::GroupOfProperties,
362        5 => ElementKind::Property,
363        6 => ElementKind::Unit,
364        7 => ElementKind::Dimension,
365        8 => ElementKind::QuantityKind,
366        9 => ElementKind::ReferenceDocument,
367        _ => panic!("invalid private DT element discriminant"),
368    }
369}
370
371/// Typed-element conversion failure.
372#[derive(Debug, Clone, PartialEq, Eq)]
373pub struct ModelError {
374    pub expected: ElementKind,
375    pub actual: Option<ElementKind>,
376}
377
378impl fmt::Display for ModelError {
379    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
380        write!(
381            formatter,
382            "expected DT element {:?}, found {:?}",
383            self.expected, self.actual
384        )
385    }
386}
387
388impl Error for ModelError {}
389
390/// Validation severity; parsing itself remains non-normalizing and permissive.
391#[derive(Debug, Clone, Copy, PartialEq, Eq)]
392pub enum Severity {
393    Error,
394    Warning,
395}
396
397/// Stable validation categories.
398#[derive(Debug, Clone, Copy, PartialEq, Eq)]
399pub enum DiagnosticCode {
400    DraftNamespace,
401    WrongRootNamespace,
402    UnknownRootElement,
403    MissingGuid,
404    InvalidGuid,
405    InvalidUri,
406    DuplicateGuid,
407    MissingCreationDate,
408    InvalidCreationDate,
409    MissingName,
410    MissingDefinition,
411    DuplicateDefinition,
412    EmptyReference,
413    MissingLanguage,
414    InvalidLanguage,
415    MissingDataTypeName,
416    UnknownDataType,
417}
418
419/// One semantic validation finding.
420#[derive(Debug, Clone, PartialEq, Eq)]
421pub struct Diagnostic {
422    pub severity: Severity,
423    pub code: DiagnosticCode,
424    pub path: String,
425    pub message: String,
426}
427
428impl Document {
429    /// Runs structural checks that do not require redistributing the ISO schema.
430    #[must_use]
431    pub fn validate(&self) -> Vec<Diagnostic> {
432        let mut diagnostics = Vec::new();
433        if self.root().namespace_uri() == Some(DRAFT_PLACEHOLDER_NAMESPACE) {
434            diagnostics.push(diagnostic(
435                Severity::Error,
436                DiagnosticCode::DraftNamespace,
437                "/",
438                "root element uses the pre-release placeholder namespace, not ISO 23387 edition 2",
439            ));
440        } else if self.root().namespace_uri() != Some(NAMESPACE) {
441            diagnostics.push(diagnostic(
442                Severity::Error,
443                DiagnosticCode::WrongRootNamespace,
444                "/",
445                "root element is not in the ISO 23387 edition 2 namespace",
446            ));
447        } else if self.root_kind().is_none() {
448            diagnostics.push(diagnostic(
449                Severity::Error,
450                DiagnosticCode::UnknownRootElement,
451                "/",
452                "root element is not a recognized ISO 23387 global element",
453            ));
454        }
455
456        let mut guids = HashMap::<String, String>::new();
457        validate_element(self.root(), None, None, "", &mut guids, &mut diagnostics);
458        diagnostics
459    }
460}
461
462fn validate_element(
463    element: &Element,
464    parent_kind: Option<ElementKind>,
465    parent_local: Option<&str>,
466    parent_path: &str,
467    guids: &mut HashMap<String, String>,
468    diagnostics: &mut Vec<Diagnostic>,
469) {
470    let path = format!("{parent_path}/{}", element.qname());
471    let kind = ElementKind::from_element(element);
472    let identity = match parent_kind {
473        None => kind.is_some_and(ElementKind::is_global_root),
474        Some(ElementKind::Library) => kind.is_some(),
475        _ => false,
476    };
477    let requires_guid = identity;
478    let concept = identity && kind != Some(ElementKind::Library);
479
480    let guid = element.attribute_ns(Some(NAMESPACE), "GUID");
481    if requires_guid && guid.is_none() {
482        diagnostics.push(diagnostic(
483            Severity::Error,
484            DiagnosticCode::MissingGuid,
485            &path,
486            "required dt:GUID attribute is missing",
487        ));
488    }
489    if let Some(value) = guid {
490        if requires_guid {
491            if let Some(first_path) = guids.insert(value.to_ascii_lowercase(), path.clone()) {
492                diagnostics.push(diagnostic(
493                    Severity::Error,
494                    DiagnosticCode::DuplicateGuid,
495                    &path,
496                    format!("GUID duplicates {first_path}"),
497                ));
498            }
499        }
500        if Guid::from_str(value).is_err() {
501            diagnostics.push(diagnostic(
502                Severity::Error,
503                DiagnosticCode::InvalidGuid,
504                &path,
505                "dt:GUID does not match the ISO 23387 lexical contract",
506            ));
507        }
508    }
509    for attribute_name in ["referenceURI", "about"] {
510        if let Some(uri) = element.attribute_ns(Some(NAMESPACE), attribute_name) {
511            if uri.parse::<AnyUri>().is_err() {
512                diagnostics.push(diagnostic(
513                    Severity::Error,
514                    DiagnosticCode::InvalidUri,
515                    &path,
516                    format!("invalid dt:{attribute_name} value {uri:?}"),
517                ));
518            }
519        }
520    }
521    if concept {
522        match element.attribute_ns(None, "dateOfCreation") {
523            None => diagnostics.push(diagnostic(
524                Severity::Error,
525                DiagnosticCode::MissingCreationDate,
526                &path,
527                "ConceptType-derived element lacks dateOfCreation",
528            )),
529            Some(value) if DateTime::from_str(value).is_err() => diagnostics.push(diagnostic(
530                Severity::Error,
531                DiagnosticCode::InvalidCreationDate,
532                &path,
533                "dateOfCreation does not match the xs:dateTime lexical contract",
534            )),
535            Some(_) => {}
536        }
537        if !element.children().any(|child| is_dt(child, "Name")) {
538            diagnostics.push(diagnostic(
539                Severity::Error,
540                DiagnosticCode::MissingName,
541                &path,
542                "ConceptType-derived element lacks a Name",
543            ));
544        }
545        match element
546            .children()
547            .filter(|child| is_dt(child, "Definition"))
548            .count()
549        {
550            0 => diagnostics.push(diagnostic(
551                Severity::Warning,
552                DiagnosticCode::MissingDefinition,
553                &path,
554                "ConceptType-derived element lacks its required Definition",
555            )),
556            1 => {}
557            _ => diagnostics.push(diagnostic(
558                Severity::Warning,
559                DiagnosticCode::DuplicateDefinition,
560                &path,
561                "ConceptType-derived element has more than one Definition",
562            )),
563        }
564    }
565    if is_reference_element(element, parent_local)
566        && element.attribute_ns(Some(NAMESPACE), "GUID").is_none()
567        && element
568            .attribute_ns(Some(NAMESPACE), "referenceURI")
569            .is_none()
570    {
571        diagnostics.push(diagnostic(
572            Severity::Warning,
573            DiagnosticCode::EmptyReference,
574            &path,
575            "reference has neither dt:GUID nor dt:referenceURI",
576        ));
577    }
578    if is_multilingual_text_element(element, parent_local) {
579        match element.attribute_ns(None, "language") {
580            None => diagnostics.push(diagnostic(
581                Severity::Error,
582                DiagnosticCode::MissingLanguage,
583                &path,
584                "multi-language text lacks its required language attribute",
585            )),
586            Some(language) if MultiLanguageText::new(language, element.direct_text()).is_err() => {
587                diagnostics.push(diagnostic(
588                    Severity::Error,
589                    DiagnosticCode::InvalidLanguage,
590                    &path,
591                    "language does not match the xs:language lexical contract",
592                ));
593            }
594            Some(_) => {}
595        }
596    }
597    if is_dt(element, "DataType") {
598        match element.attribute_ns(None, "name") {
599            None => diagnostics.push(diagnostic(
600                Severity::Warning,
601                DiagnosticCode::MissingDataTypeName,
602                &path,
603                "DataType has no name; Annex E permits it but the value is underspecified",
604            )),
605            Some(name) if matches!(DataTypeName::from(name), DataTypeName::Other(_)) => {
606                diagnostics.push(diagnostic(
607                    Severity::Warning,
608                    DiagnosticCode::UnknownDataType,
609                    &path,
610                    format!("unknown data-type name {name:?} retained"),
611                ));
612            }
613            Some(_) => {}
614        }
615    }
616
617    for child in element.children() {
618        validate_element(
619            child,
620            kind,
621            Some(element.local_name()),
622            &path,
623            guids,
624            diagnostics,
625        );
626    }
627}
628
629fn is_reference_element(element: &Element, parent_local: Option<&str>) -> bool {
630    element.namespace_uri() == Some(NAMESPACE)
631        && is_known_reference_name(element.local_name())
632        && reference_allowed(parent_local, element.local_name())
633}
634
635fn reference_allowed(parent: Option<&str>, child: &str) -> bool {
636    let concept_parent = parent.is_some_and(is_concept_parent);
637    (concept_parent
638        && matches!(
639            child,
640            "ReferenceDocumentRef" | "DictionaryRef" | "SimilarToRef" | "ReplacedObjectsRef"
641        ))
642        || (parent.is_some_and(is_subject_parent)
643            && matches!(child, "HasPartRef" | "IsSubtypeOfRef"))
644        || matches!(
645            (parent, child),
646            (
647                Some("DataTemplate"),
648                "HasObjectTypeRef" | "HasPropertyRef" | "HasGroupOfPropertiesRef"
649            ) | (Some("GroupOfProperties"), "HasPropertyRef")
650                | (
651                    Some("Property"),
652                    "UnitRef"
653                        | "QuantityKindRef"
654                        | "DimensionRef"
655                        | "IsDependentOnRef"
656                        | "IsSpecializationOfRef"
657                )
658                | (Some("QuantityKind"), "DimensionRef")
659                | (Some("Unit"), "DimensionRef")
660        )
661}
662
663fn is_known_reference_name(child: &str) -> bool {
664    matches!(
665        child,
666        "ReferenceDocumentRef"
667            | "DictionaryRef"
668            | "SimilarToRef"
669            | "ReplacedObjectsRef"
670            | "HasPartRef"
671            | "IsSubtypeOfRef"
672            | "HasObjectTypeRef"
673            | "HasPropertyRef"
674            | "HasGroupOfPropertiesRef"
675            | "IsSpecializationOfRef"
676            | "IsDependentOnRef"
677            | "UnitRef"
678            | "QuantityKindRef"
679            | "DimensionRef"
680    )
681}
682
683fn is_multilingual_text_element(element: &Element, parent_local: Option<&str>) -> bool {
684    element.namespace_uri() == Some(NAMESPACE)
685        && is_known_multilingual_name(element.local_name())
686        && multilingual_allowed(parent_local, element.local_name())
687}
688
689fn multilingual_allowed(parent: Option<&str>, child: &str) -> bool {
690    (parent.is_some_and(is_concept_parent)
691        && matches!(child, "Name" | "Definition" | "Description" | "Example"))
692        || matches!(
693            (parent, child),
694            (Some("Unit"), "Symbol") | (Some("PossibleValues"), "ValueList")
695        )
696}
697
698fn is_known_multilingual_name(child: &str) -> bool {
699    matches!(
700        child,
701        "Name" | "Definition" | "Description" | "Example" | "Symbol" | "ValueList"
702    )
703}
704
705fn is_concept_parent(parent: &str) -> bool {
706    matches!(
707        parent,
708        "DataTemplate"
709            | "ObjectType"
710            | "GroupOfProperties"
711            | "Property"
712            | "ReferenceDocument"
713            | "QuantityKind"
714            | "Dimension"
715            | "Unit"
716    )
717}
718
719fn is_subject_parent(parent: &str) -> bool {
720    matches!(parent, "DataTemplate" | "ObjectType" | "GroupOfProperties")
721}
722
723fn diagnostic(
724    severity: Severity,
725    code: DiagnosticCode,
726    path: impl Into<String>,
727    message: impl Into<String>,
728) -> Diagnostic {
729    Diagnostic {
730        severity,
731        code,
732        path: path.into(),
733        message: message.into(),
734    }
735}
736
737fn required_guid(element: &Element) -> Result<Guid, ValueError> {
738    Guid::from_str(
739        element
740            .attribute_ns(Some(NAMESPACE), "GUID")
741            .unwrap_or_default(),
742    )
743}
744
745fn is_dt(element: &Element, local_name: &str) -> bool {
746    element.namespace_uri() == Some(NAMESPACE) && element.local_name() == local_name
747}