1use std::collections::BTreeSet;
2
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use url::Url;
6
7use super::{DomainError, SourceScope};
8
9const MAX_SCHEMA_CLASSES: usize = 256;
10const MAX_SCHEMA_PROPERTIES: usize = 256;
11const MAX_SCHEMA_RELATION_SHAPES: usize = 256;
12const MAX_SCHEMA_TEXT_BYTES: usize = 512;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
16#[serde(rename_all = "snake_case")]
17pub enum OntologyClassIdentity {
18 Stable,
19 Occurrence,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
24pub struct OntologyClassDefinition {
25 pub id: &'static str,
26 pub rdf_local_name: &'static str,
27 pub identity: OntologyClassIdentity,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
32#[serde(rename_all = "snake_case", tag = "kind", content = "classes")]
33pub enum OntologyDomainConstraint {
34 Any,
35 OneOf(&'static [&'static str]),
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
40#[serde(rename_all = "snake_case", tag = "kind", content = "classes")]
41pub enum OntologyRangeConstraint {
42 Any,
43 OneOf(&'static [&'static str]),
44 SameAsSubject,
45 DifferentFromSubject,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
50pub struct OntologyRelationShape {
51 pub domain: OntologyDomainConstraint,
52 pub range: OntologyRangeConstraint,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
57pub struct OntologyObjectPropertyDefinition {
58 pub id: &'static str,
59 pub rdf_local_name: &'static str,
60 pub relation_shapes: &'static [OntologyRelationShape],
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
65pub struct OntologySchema {
66 pub id: &'static str,
67 pub version: &'static str,
68 pub namespace_iri: &'static str,
69 pub classes: &'static [OntologyClassDefinition],
70 pub object_properties: &'static [OntologyObjectPropertyDefinition],
71}
72
73impl OntologySchema {
74 pub fn validate(&self) -> Result<(), DomainError> {
76 validate_schema_text("ontology_schema_id", self.id)?;
77 validate_semantic_version(self.version)?;
78 validate_namespace_iri(self.namespace_iri)?;
79 validate_schema_capacity("ontology_classes", self.classes.len(), MAX_SCHEMA_CLASSES)?;
80 validate_schema_capacity(
81 "ontology_object_properties",
82 self.object_properties.len(),
83 MAX_SCHEMA_PROPERTIES,
84 )?;
85 if self.classes.is_empty() {
86 return Err(DomainError::invalid(
87 "ontology_classes",
88 "must contain at least one ontology class",
89 ));
90 }
91
92 let mut class_ids = BTreeSet::new();
93 let mut class_names = BTreeSet::new();
94 for class in self.classes {
95 validate_rdf_local_name("ontology_class_id", class.id)?;
96 validate_rdf_local_name("ontology_class_rdf_name", class.rdf_local_name)?;
97 if !class_ids.insert(class.id) {
98 return Err(DomainError::invalid(
99 "ontology_class_id",
100 format!("duplicate ontology class '{}'", class.id),
101 ));
102 }
103 if !class_names.insert(class.rdf_local_name) {
104 return Err(DomainError::invalid(
105 "ontology_class_rdf_name",
106 format!("duplicate RDF class name '{}'", class.rdf_local_name),
107 ));
108 }
109 }
110
111 let mut property_ids = BTreeSet::new();
112 let mut property_names = BTreeSet::new();
113 for property in self.object_properties {
114 validate_rdf_local_name("ontology_property_id", property.id)?;
115 validate_rdf_local_name("ontology_property_rdf_name", property.rdf_local_name)?;
116 validate_schema_capacity(
117 "ontology_relation_shapes",
118 property.relation_shapes.len(),
119 MAX_SCHEMA_RELATION_SHAPES,
120 )?;
121 if property.relation_shapes.is_empty() {
122 return Err(DomainError::invalid(
123 "ontology_relation_shapes",
124 format!(
125 "ontology property '{}' requires at least one shape",
126 property.id
127 ),
128 ));
129 }
130 if !property_ids.insert(property.id) {
131 return Err(DomainError::invalid(
132 "ontology_property_id",
133 format!("duplicate ontology property '{}'", property.id),
134 ));
135 }
136 if !property_names.insert(property.rdf_local_name) {
137 return Err(DomainError::invalid(
138 "ontology_property_rdf_name",
139 format!("duplicate RDF property name '{}'", property.rdf_local_name),
140 ));
141 }
142 for shape in property.relation_shapes {
143 validate_domain_constraint(shape.domain, &class_ids)?;
144 validate_range_constraint(shape.range, &class_ids)?;
145 }
146 }
147 Ok(())
148 }
149
150 pub fn allows_subject(&self, property_id: &str, subject_class_id: &str) -> bool {
152 if !self
153 .classes
154 .iter()
155 .any(|class| class.id == subject_class_id)
156 {
157 return false;
158 }
159 self.object_properties
160 .iter()
161 .find(|property| property.id == property_id)
162 .is_some_and(|property| {
163 property
164 .relation_shapes
165 .iter()
166 .any(|shape| domain_matches(shape.domain, subject_class_id))
167 })
168 }
169
170 pub fn allows_relation(
172 &self,
173 property_id: &str,
174 subject_class_id: &str,
175 object_class_id: &str,
176 ) -> bool {
177 if !self
178 .classes
179 .iter()
180 .any(|class| class.id == subject_class_id)
181 || !self.classes.iter().any(|class| class.id == object_class_id)
182 {
183 return false;
184 }
185 self.object_properties
186 .iter()
187 .find(|property| property.id == property_id)
188 .is_some_and(|property| {
189 property.relation_shapes.iter().any(|shape| {
190 domain_matches(shape.domain, subject_class_id)
191 && range_matches(shape.range, subject_class_id, object_class_id)
192 })
193 })
194 }
195}
196
197fn validate_schema_capacity(
198 field: &'static str,
199 actual: usize,
200 maximum: usize,
201) -> Result<(), DomainError> {
202 if actual > maximum {
203 return Err(DomainError::invalid(
204 field,
205 format!("must contain {maximum} entries or fewer"),
206 ));
207 }
208 Ok(())
209}
210
211fn validate_schema_text(field: &'static str, value: &str) -> Result<(), DomainError> {
212 if value.is_empty() {
213 return Err(DomainError::invalid(field, "must not be empty"));
214 }
215 if value.len() > MAX_SCHEMA_TEXT_BYTES {
216 return Err(DomainError::invalid(
217 field,
218 format!("must be {MAX_SCHEMA_TEXT_BYTES} bytes or less"),
219 ));
220 }
221 if value.trim() != value || value.contains('\0') {
222 return Err(DomainError::invalid(
223 field,
224 "must be trimmed and contain no NUL bytes",
225 ));
226 }
227 Ok(())
228}
229
230fn validate_semantic_version(version: &str) -> Result<(), DomainError> {
231 validate_schema_text("ontology_schema_version", version)?;
232 if version.split('.').count() != 3
233 || version
234 .split('.')
235 .any(|part| part.is_empty() || !part.bytes().all(|byte| byte.is_ascii_digit()))
236 {
237 return Err(DomainError::invalid(
238 "ontology_schema_version",
239 "must be a numeric major.minor.patch version",
240 ));
241 }
242 Ok(())
243}
244
245fn validate_namespace_iri(namespace_iri: &str) -> Result<(), DomainError> {
246 validate_schema_text("ontology_namespace_iri", namespace_iri)?;
247 let parsed = Url::parse(namespace_iri).map_err(|_| {
248 DomainError::invalid(
249 "ontology_namespace_iri",
250 "must be an absolute HTTP(S) IRI with a host and ending in '#' or '/'",
251 )
252 })?;
253 if !matches!(parsed.scheme(), "http" | "https")
254 || parsed.host().is_none()
255 || !(namespace_iri.ends_with('#') || namespace_iri.ends_with('/'))
256 {
257 return Err(DomainError::invalid(
258 "ontology_namespace_iri",
259 "must be an absolute HTTP(S) IRI with a host and ending in '#' or '/'",
260 ));
261 }
262 Ok(())
263}
264
265fn validate_rdf_local_name(field: &'static str, value: &str) -> Result<(), DomainError> {
266 validate_schema_text(field, value)?;
267 let mut bytes = value.bytes();
268 if !bytes
269 .next()
270 .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
271 || !bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
272 {
273 return Err(DomainError::invalid(
274 field,
275 "must be a bounded RDF local name",
276 ));
277 }
278 Ok(())
279}
280
281fn validate_domain_constraint(
282 constraint: OntologyDomainConstraint,
283 class_ids: &BTreeSet<&str>,
284) -> Result<(), DomainError> {
285 if let OntologyDomainConstraint::OneOf(classes) = constraint {
286 validate_class_list("ontology_property_domain", classes, class_ids)?;
287 }
288 Ok(())
289}
290
291fn validate_range_constraint(
292 constraint: OntologyRangeConstraint,
293 class_ids: &BTreeSet<&str>,
294) -> Result<(), DomainError> {
295 if let OntologyRangeConstraint::OneOf(classes) = constraint {
296 validate_class_list("ontology_property_range", classes, class_ids)?;
297 }
298 Ok(())
299}
300
301fn validate_class_list(
302 field: &'static str,
303 classes: &[&str],
304 class_ids: &BTreeSet<&str>,
305) -> Result<(), DomainError> {
306 if classes.is_empty() {
307 return Err(DomainError::invalid(field, "must not be empty"));
308 }
309 validate_schema_capacity(field, classes.len(), MAX_SCHEMA_CLASSES)?;
310 let mut unique = BTreeSet::new();
311 for class in classes {
312 if !class_ids.contains(class) {
313 return Err(DomainError::invalid(
314 field,
315 format!("references unknown ontology class '{class}'"),
316 ));
317 }
318 if !unique.insert(*class) {
319 return Err(DomainError::invalid(
320 field,
321 format!("contains duplicate ontology class '{class}'"),
322 ));
323 }
324 }
325 Ok(())
326}
327
328fn domain_matches(constraint: OntologyDomainConstraint, subject_class_id: &str) -> bool {
329 match constraint {
330 OntologyDomainConstraint::Any => true,
331 OntologyDomainConstraint::OneOf(classes) => classes.contains(&subject_class_id),
332 }
333}
334
335fn range_matches(
336 constraint: OntologyRangeConstraint,
337 subject_class_id: &str,
338 object_class_id: &str,
339) -> bool {
340 match constraint {
341 OntologyRangeConstraint::Any => true,
342 OntologyRangeConstraint::OneOf(classes) => classes.contains(&object_class_id),
343 OntologyRangeConstraint::SameAsSubject => subject_class_id == object_class_id,
344 OntologyRangeConstraint::DifferentFromSubject => subject_class_id != object_class_id,
345 }
346}
347
348#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
350#[serde(rename_all = "snake_case")]
351pub enum OntologyEntityKind {
352 #[default]
353 Untyped,
354 BusinessDomain,
355 BusinessTerm,
356}
357
358impl OntologyEntityKind {
359 pub const fn as_str(self) -> &'static str {
361 match self {
362 Self::Untyped => "untyped",
363 Self::BusinessDomain => "business_domain",
364 Self::BusinessTerm => "business_term",
365 }
366 }
367}
368
369#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
371pub struct OntologyIdentity {
372 pub source_scope: SourceScope,
373 pub domain_id: String,
374 pub entity_id: String,
375 pub entity_kind: OntologyEntityKind,
376}
377
378impl OntologyIdentity {
379 pub fn new(
381 source_scope: SourceScope,
382 domain_id: impl Into<String>,
383 entity_id: impl Into<String>,
384 entity_kind: OntologyEntityKind,
385 ) -> Result<Self, DomainError> {
386 let domain_id = validate_identity_text("domain_id", domain_id.into())?;
387 let entity_id = validate_identity_text("entity_id", entity_id.into())?;
388 if entity_kind == OntologyEntityKind::Untyped {
389 return Err(DomainError::invalid(
390 "entity_kind",
391 "scoped ontology identities must be typed",
392 ));
393 }
394 Ok(Self {
395 source_scope,
396 domain_id,
397 entity_id,
398 entity_kind,
399 })
400 }
401
402 pub fn stable_entity_id(&self) -> String {
404 let mut digest = Sha256::new();
405 for part in [
406 self.source_scope.as_str(),
407 self.domain_id.as_str(),
408 self.entity_id.as_str(),
409 self.entity_kind.as_str(),
410 ] {
411 digest.update((part.len() as u64).to_be_bytes());
412 digest.update(part.as_bytes());
413 }
414 format!("ontology:{:x}", digest.finalize())
415 }
416}
417
418fn validate_identity_text(field: &'static str, value: String) -> Result<String, DomainError> {
419 let value = value.trim();
420 if value.is_empty() {
421 return Err(DomainError::invalid(field, "must not be empty"));
422 }
423 if value.len() > 128 {
424 return Err(DomainError::invalid(field, "must be 128 bytes or less"));
425 }
426 if value.contains('\0') {
427 return Err(DomainError::invalid(field, "must not contain NUL bytes"));
428 }
429 Ok(value.to_owned())
430}
431
432#[cfg(test)]
433#[path = "ontology_tests.rs"]
434mod tests;