Skip to main content

axioval_engine/
classifications.rs

1//! Source-declared classification assignments, for `classification` selectors.
2//!
3//! A classification selector names a system and a code. Whether an object
4//! carries that code is a fact of its source: an IFC file states it through
5//! `IfcRelAssociatesClassification` and a chain of classification references,
6//! and nothing on the project's objects says whether an adapter looked. So the
7//! answer comes from a trusted service, and an object whose source has no such
8//! service is not evaluated rather than read as unclassified.
9
10use std::sync::Arc;
11
12use axioval_ir::{ObjectId, SourceId};
13use thiserror::Error;
14
15use crate::{SnapshotBoundService, SourceSnapshot};
16
17/// One classification an object carries, directly or inherited from its type.
18#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct ClassificationAssignment {
20    /// Name of the classification system, or `None` when the source does not
21    /// connect this assignment to a system. A selector can then neither
22    /// confirm nor rule out a match, and the object is not evaluated.
23    pub system: Option<String>,
24    /// Codes from the assigned item up to the system root, leaf first. An
25    /// entry is `None` when that level states no code.
26    pub codes: Vec<Option<String>>,
27}
28
29impl ClassificationAssignment {
30    /// Whether this assignment is `code` in `system`, or a descendant of it
31    /// when `include_descendants` is set. `None` when the system is unknown.
32    #[must_use]
33    pub fn matches(&self, system: &str, code: &str, include_descendants: bool) -> Option<bool> {
34        self.matches_code(system, |level| level == code, include_descendants)
35    }
36
37    /// Whether this assignment is in `system` with a code `code` accepts,
38    /// or, when `include_descendants` is set, an ancestor's code it accepts.
39    /// A level stating no code is never accepted. `None` when the system is
40    /// unknown.
41    #[must_use]
42    pub fn matches_code(
43        &self,
44        system: &str,
45        code: impl Fn(&str) -> bool,
46        include_descendants: bool,
47    ) -> Option<bool> {
48        if !self.in_system(system)? {
49            return Some(false);
50        }
51        let levels = if include_descendants {
52            &self.codes[..]
53        } else {
54            &self.codes[..self.codes.len().min(1)]
55        };
56        Some(levels.iter().flatten().any(|level| code(level)))
57    }
58
59    /// Whether this assignment is in `system`, whatever its code. `None`
60    /// when the system is unknown.
61    #[must_use]
62    pub fn in_system(&self, system: &str) -> Option<bool> {
63        Some(self.system.as_deref()? == system)
64    }
65}
66
67/// Failure to list an object's classifications conclusively.
68#[derive(Clone, Debug, Error, Eq, PartialEq)]
69pub enum ClassificationError {
70    /// The service does not cover this source.
71    #[error("classification service does not cover source `{0}`")]
72    UncoveredSource(SourceId),
73    /// The object is not part of the source.
74    #[error("object `{0}` is not in the classified source")]
75    UnknownObject(ObjectId),
76    /// The source's classification data is malformed or cannot be read exactly.
77    #[error("classifications cannot be read exactly: {0}")]
78    Unreadable(String),
79}
80
81/// Trusted adapter seam listing the classifications an object carries.
82pub trait ClassificationService: Send + Sync {
83    /// Exact source snapshots this service answers for.
84    fn source_snapshots(&self) -> &[SourceSnapshot];
85    /// Every classification `object` carries, complete or refused.
86    fn classifications(
87        &self,
88        object: &ObjectId,
89    ) -> Result<Vec<ClassificationAssignment>, ClassificationError>;
90}
91
92/// Cloneable, type-erased classification service registered by an adapter.
93#[derive(Clone)]
94pub struct ClassificationServiceHandle(Arc<dyn ClassificationService>);
95
96impl ClassificationServiceHandle {
97    /// Wraps a trusted classification service.
98    #[must_use]
99    pub fn new(service: Arc<dyn ClassificationService>) -> Self {
100        Self(service)
101    }
102
103    /// Answers for one object, refusing sources the service does not cover.
104    pub fn classifications(
105        &self,
106        object: &ObjectId,
107    ) -> Result<Vec<ClassificationAssignment>, ClassificationError> {
108        if !self
109            .0
110            .source_snapshots()
111            .iter()
112            .any(|snapshot| *snapshot.source() == object.source)
113        {
114            return Err(ClassificationError::UncoveredSource(object.source.clone()));
115        }
116        self.0.classifications(object)
117    }
118}
119
120impl SnapshotBoundService for ClassificationServiceHandle {
121    fn source_snapshots(&self) -> &[SourceSnapshot] {
122        self.0.source_snapshots()
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::ClassificationAssignment;
129
130    fn chain(system: Option<&str>, codes: &[Option<&str>]) -> ClassificationAssignment {
131        ClassificationAssignment {
132            system: system.map(str::to_owned),
133            codes: codes.iter().map(|code| code.map(str::to_owned)).collect(),
134        }
135    }
136
137    #[test]
138    fn the_leaf_matches_and_ancestors_only_with_descendants() {
139        let item = chain(Some("DIN 276"), &[Some("331"), Some("330"), Some("300")]);
140        assert_eq!(item.matches("DIN 276", "331", false), Some(true));
141        assert_eq!(item.matches("DIN 276", "330", false), Some(false));
142        assert_eq!(item.matches("DIN 276", "330", true), Some(true));
143        assert_eq!(item.matches("Uniclass", "331", true), Some(false));
144    }
145
146    #[test]
147    fn an_unknown_system_is_undecided_not_a_mismatch() {
148        let item = chain(None, &[Some("331")]);
149        assert_eq!(item.matches("DIN 276", "331", false), None);
150    }
151
152    #[test]
153    fn an_unstated_code_never_matches() {
154        let item = chain(Some("DIN 276"), &[None, Some("330")]);
155        assert_eq!(item.matches("DIN 276", "331", true), Some(false));
156        assert_eq!(item.matches("DIN 276", "330", false), Some(false));
157    }
158}