axioval_engine/
classifications.rs1use std::sync::Arc;
11
12use axioval_ir::{ObjectId, SourceId};
13use thiserror::Error;
14
15use crate::{SnapshotBoundService, SourceSnapshot};
16
17#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct ClassificationAssignment {
20 pub system: Option<String>,
24 pub codes: Vec<Option<String>>,
27}
28
29impl ClassificationAssignment {
30 #[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 #[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 #[must_use]
62 pub fn in_system(&self, system: &str) -> Option<bool> {
63 Some(self.system.as_deref()? == system)
64 }
65}
66
67#[derive(Clone, Debug, Error, Eq, PartialEq)]
69pub enum ClassificationError {
70 #[error("classification service does not cover source `{0}`")]
72 UncoveredSource(SourceId),
73 #[error("object `{0}` is not in the classified source")]
75 UnknownObject(ObjectId),
76 #[error("classifications cannot be read exactly: {0}")]
78 Unreadable(String),
79}
80
81pub trait ClassificationService: Send + Sync {
83 fn source_snapshots(&self) -> &[SourceSnapshot];
85 fn classifications(
87 &self,
88 object: &ObjectId,
89 ) -> Result<Vec<ClassificationAssignment>, ClassificationError>;
90}
91
92#[derive(Clone)]
94pub struct ClassificationServiceHandle(Arc<dyn ClassificationService>);
95
96impl ClassificationServiceHandle {
97 #[must_use]
99 pub fn new(service: Arc<dyn ClassificationService>) -> Self {
100 Self(service)
101 }
102
103 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}