1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3
4use crate::entry::Value;
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum ValueType {
10 String,
11 Int,
12 Float,
13 Bool,
14 List,
15 Map,
16 Any,
18}
19
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22pub struct PropertyDef {
23 pub value_type: ValueType,
24 #[serde(default)]
25 pub required: bool,
26 #[serde(default)]
27 pub description: Option<String>,
28 #[serde(default)]
32 pub constraints: Option<BTreeMap<String, serde_json::Value>>,
33}
34
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37pub struct SubtypeDef {
38 #[serde(default)]
39 pub description: Option<String>,
40 #[serde(default)]
41 pub properties: BTreeMap<String, PropertyDef>,
42}
43
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50pub struct NodeTypeDef {
51 #[serde(default)]
52 pub description: Option<String>,
53 #[serde(default)]
54 pub properties: BTreeMap<String, PropertyDef>,
55 #[serde(default)]
58 pub subtypes: Option<BTreeMap<String, SubtypeDef>>,
59 #[serde(default)]
64 pub parent_type: Option<String>,
65}
66
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69pub struct EdgeTypeDef {
70 #[serde(default)]
71 pub description: Option<String>,
72 pub source_types: Vec<String>,
74 pub target_types: Vec<String>,
76 #[serde(default)]
77 pub properties: BTreeMap<String, PropertyDef>,
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
85pub struct Ontology {
86 pub node_types: BTreeMap<String, NodeTypeDef>,
87 pub edge_types: BTreeMap<String, EdgeTypeDef>,
88}
89
90#[derive(Debug, Clone, PartialEq)]
92pub enum ValidationError {
93 UnknownNodeType(String),
94 UnknownEdgeType(String),
95 InvalidSource {
96 edge_type: String,
97 node_type: String,
98 allowed: Vec<String>,
99 },
100 InvalidTarget {
101 edge_type: String,
102 node_type: String,
103 allowed: Vec<String>,
104 },
105 MissingRequiredProperty {
106 type_name: String,
107 property: String,
108 },
109 WrongPropertyType {
110 type_name: String,
111 property: String,
112 expected: ValueType,
113 got: String,
114 },
115 UnknownProperty {
116 type_name: String,
117 property: String,
118 },
119 MissingSubtype {
120 node_type: String,
121 allowed: Vec<String>,
122 },
123 UnknownSubtype {
124 node_type: String,
125 subtype: String,
126 allowed: Vec<String>,
127 },
128 UnexpectedSubtype {
129 node_type: String,
130 subtype: String,
131 },
132 ConstraintViolation {
134 type_name: String,
135 property: String,
136 constraint: String,
137 message: String,
138 },
139}
140
141impl std::fmt::Display for ValidationError {
142 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143 match self {
144 ValidationError::UnknownNodeType(t) => write!(f, "unknown node type: '{t}'"),
145 ValidationError::UnknownEdgeType(t) => write!(f, "unknown edge type: '{t}'"),
146 ValidationError::InvalidSource {
147 edge_type,
148 node_type,
149 allowed,
150 } => write!(
151 f,
152 "edge '{edge_type}' cannot have source type '{node_type}' (allowed: {allowed:?})"
153 ),
154 ValidationError::InvalidTarget {
155 edge_type,
156 node_type,
157 allowed,
158 } => write!(
159 f,
160 "edge '{edge_type}' cannot have target type '{node_type}' (allowed: {allowed:?})"
161 ),
162 ValidationError::MissingRequiredProperty {
163 type_name,
164 property,
165 } => write!(f, "'{type_name}' requires property '{property}'"),
166 ValidationError::WrongPropertyType {
167 type_name,
168 property,
169 expected,
170 got,
171 } => write!(
172 f,
173 "'{type_name}'.'{property}' expects {expected:?}, got {got}"
174 ),
175 ValidationError::UnknownProperty {
176 type_name,
177 property,
178 } => write!(f, "'{type_name}' has no property '{property}' in ontology"),
179 ValidationError::MissingSubtype { node_type, allowed } => {
180 write!(f, "'{node_type}' requires a subtype (allowed: {allowed:?})")
181 }
182 ValidationError::UnknownSubtype {
183 node_type,
184 subtype,
185 allowed,
186 } => write!(
187 f,
188 "'{node_type}' has no subtype '{subtype}' (allowed: {allowed:?})"
189 ),
190 ValidationError::UnexpectedSubtype { node_type, subtype } => write!(
191 f,
192 "'{node_type}' does not define subtypes, but got subtype '{subtype}'"
193 ),
194 ValidationError::ConstraintViolation {
195 type_name,
196 property,
197 constraint,
198 message,
199 } => write!(
200 f,
201 "'{type_name}'.'{property}' violates constraint '{constraint}': {message}"
202 ),
203 }
204 }
205}
206
207#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
209pub struct OntologyExtension {
210 #[serde(default)]
212 pub node_types: BTreeMap<String, NodeTypeDef>,
213 #[serde(default)]
215 pub edge_types: BTreeMap<String, EdgeTypeDef>,
216 #[serde(default)]
218 pub node_type_updates: BTreeMap<String, NodeTypeUpdate>,
219}
220
221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
223pub struct NodeTypeUpdate {
224 #[serde(default)]
226 pub add_properties: BTreeMap<String, PropertyDef>,
227 #[serde(default)]
229 pub relax_properties: Vec<String>,
230 #[serde(default)]
232 pub add_subtypes: BTreeMap<String, SubtypeDef>,
233}
234
235#[derive(Debug, Clone, PartialEq)]
237pub enum MonotonicityError {
238 DuplicateNodeType(String),
239 DuplicateEdgeType(String),
240 UnknownNodeType(String),
241 DuplicateProperty {
242 type_name: String,
243 property: String,
244 },
245 UnknownProperty {
246 type_name: String,
247 property: String,
248 },
249 ValidationFailed(ValidationError),
251}
252
253impl std::fmt::Display for MonotonicityError {
254 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255 match self {
256 MonotonicityError::DuplicateNodeType(t) => {
257 write!(f, "node type '{t}' already exists")
258 }
259 MonotonicityError::DuplicateEdgeType(t) => {
260 write!(f, "edge type '{t}' already exists")
261 }
262 MonotonicityError::UnknownNodeType(t) => {
263 write!(f, "cannot update unknown node type '{t}'")
264 }
265 MonotonicityError::DuplicateProperty {
266 type_name,
267 property,
268 } => {
269 write!(f, "property '{property}' already exists on '{type_name}'")
270 }
271 MonotonicityError::UnknownProperty {
272 type_name,
273 property,
274 } => {
275 write!(
276 f,
277 "property '{property}' does not exist on '{type_name}' (cannot relax)"
278 )
279 }
280 MonotonicityError::ValidationFailed(e) => {
281 write!(f, "ontology validation failed after merge: {e}")
282 }
283 }
284 }
285}
286
287impl Ontology {
288 pub fn ancestors(&self, node_type: &str) -> Vec<&str> {
293 let mut result = Vec::new();
294 let mut current = node_type;
295 for _ in 0..100 {
297 match self
298 .node_types
299 .get(current)
300 .and_then(|d| d.parent_type.as_deref())
301 {
302 Some(parent) => {
303 result.push(parent);
304 current = parent;
305 }
306 None => break,
307 }
308 }
309 result
310 }
311
312 pub fn descendants(&self, node_type: &str) -> Vec<&str> {
315 self.node_types
317 .iter()
318 .filter(|(name, _)| {
319 name.as_str() != node_type && self.ancestors(name).contains(&node_type)
320 })
321 .map(|(name, _)| name.as_str())
322 .collect()
323 }
324
325 pub fn is_subtype_of(&self, child_type: &str, parent_type: &str) -> bool {
327 child_type == parent_type || self.ancestors(child_type).contains(&parent_type)
328 }
329
330 pub fn effective_properties(&self, node_type: &str) -> BTreeMap<String, PropertyDef> {
334 let mut chain: Vec<&str> = self.ancestors(node_type);
335 chain.reverse(); chain.push(node_type);
337
338 let mut props = BTreeMap::new();
339 for t in chain {
340 if let Some(def) = self.node_types.get(t) {
341 for (k, v) in &def.properties {
342 props.insert(k.clone(), v.clone());
343 }
344 }
345 }
346 props
347 }
348
349 pub fn validate_node(
355 &self,
356 node_type: &str,
357 subtype: Option<&str>,
358 properties: &BTreeMap<String, Value>,
359 ) -> Result<(), ValidationError> {
360 let def = self
361 .node_types
362 .get(node_type)
363 .ok_or_else(|| ValidationError::UnknownNodeType(node_type.to_string()))?;
364
365 let base_props = self.effective_properties(node_type);
367
368 match (&def.subtypes, subtype) {
369 (Some(subtypes), Some(st)) => {
371 match subtypes.get(st) {
372 Some(st_def) => {
373 let mut merged = base_props;
375 merged.extend(st_def.properties.clone());
376 validate_properties(node_type, &merged, properties)
377 }
378 None => {
379 validate_properties(node_type, &base_props, properties)
381 }
382 }
383 }
384 (Some(subtypes), None) => Err(ValidationError::MissingSubtype {
386 node_type: node_type.to_string(),
387 allowed: subtypes.keys().cloned().collect(),
388 }),
389 (None, Some(_st)) => validate_properties(node_type, &base_props, properties),
391 (None, None) => validate_properties(node_type, &base_props, properties),
393 }
394 }
395
396 pub fn validate_edge(
399 &self,
400 edge_type: &str,
401 source_node_type: &str,
402 target_node_type: &str,
403 properties: &BTreeMap<String, Value>,
404 ) -> Result<(), ValidationError> {
405 let def = self
406 .edge_types
407 .get(edge_type)
408 .ok_or_else(|| ValidationError::UnknownEdgeType(edge_type.to_string()))?;
409
410 if !def
413 .source_types
414 .iter()
415 .any(|t| self.is_subtype_of(source_node_type, t))
416 {
417 return Err(ValidationError::InvalidSource {
418 edge_type: edge_type.to_string(),
419 node_type: source_node_type.to_string(),
420 allowed: def.source_types.clone(),
421 });
422 }
423
424 if !def
425 .target_types
426 .iter()
427 .any(|t| self.is_subtype_of(target_node_type, t))
428 {
429 return Err(ValidationError::InvalidTarget {
430 edge_type: edge_type.to_string(),
431 node_type: target_node_type.to_string(),
432 allowed: def.target_types.clone(),
433 });
434 }
435
436 validate_properties(edge_type, &def.properties, properties)
437 }
438
439 pub fn validate_property_update(
443 &self,
444 node_type: &str,
445 subtype: Option<&str>,
446 key: &str,
447 value: &Value,
448 ) -> Result<(), ValidationError> {
449 let def = match self.node_types.get(node_type) {
450 Some(d) => d,
451 None => return Ok(()), };
453
454 let mut merged = def.properties.clone();
456 if let (Some(subtypes), Some(st)) = (&def.subtypes, subtype) {
457 if let Some(st_def) = subtypes.get(st) {
458 merged.extend(st_def.properties.clone());
459 }
460 }
461
462 let prop_def = match merged.get(key) {
464 Some(d) => d,
465 None => return Ok(()),
466 };
467
468 if prop_def.value_type != ValueType::Any && !value_matches_type(value, &prop_def.value_type)
470 {
471 return Err(ValidationError::WrongPropertyType {
472 type_name: node_type.to_string(),
473 property: key.to_string(),
474 expected: prop_def.value_type.clone(),
475 got: value_type_name(value).to_string(),
476 });
477 }
478
479 if let Some(constraints) = &prop_def.constraints {
481 validate_constraints(node_type, key, value, constraints)?;
482 }
483
484 Ok(())
485 }
486
487 pub fn validate_self(&self) -> Result<(), ValidationError> {
490 for (edge_name, edge_def) in &self.edge_types {
492 for src in &edge_def.source_types {
493 if !self.node_types.contains_key(src) {
494 return Err(ValidationError::InvalidSource {
495 edge_type: edge_name.clone(),
496 node_type: src.clone(),
497 allowed: self.node_types.keys().cloned().collect(),
498 });
499 }
500 }
501 for tgt in &edge_def.target_types {
502 if !self.node_types.contains_key(tgt) {
503 return Err(ValidationError::InvalidTarget {
504 edge_type: edge_name.clone(),
505 node_type: tgt.clone(),
506 allowed: self.node_types.keys().cloned().collect(),
507 });
508 }
509 }
510 }
511 for (type_name, type_def) in &self.node_types {
513 if let Some(ref parent) = type_def.parent_type {
514 if !self.node_types.contains_key(parent) {
515 return Err(ValidationError::UnknownNodeType(format!(
516 "{}: parent_type '{}' does not exist",
517 type_name, parent
518 )));
519 }
520 }
521 }
522 Ok(())
523 }
524
525 pub fn merge_extension(&mut self, ext: &OntologyExtension) -> Result<(), MonotonicityError> {
531 for name in ext.node_types.keys() {
533 if self.node_types.contains_key(name) {
534 return Err(MonotonicityError::DuplicateNodeType(name.clone()));
535 }
536 }
537
538 for name in ext.edge_types.keys() {
540 if self.edge_types.contains_key(name) {
541 return Err(MonotonicityError::DuplicateEdgeType(name.clone()));
542 }
543 }
544
545 for (type_name, update) in &ext.node_type_updates {
547 let def = self
548 .node_types
549 .get(type_name)
550 .ok_or_else(|| MonotonicityError::UnknownNodeType(type_name.clone()))?;
551
552 for prop_name in update.add_properties.keys() {
554 if def.properties.contains_key(prop_name) {
555 return Err(MonotonicityError::DuplicateProperty {
556 type_name: type_name.clone(),
557 property: prop_name.clone(),
558 });
559 }
560 }
561
562 for prop_name in &update.relax_properties {
564 match def.properties.get(prop_name) {
565 Some(prop_def) if prop_def.required => {} Some(_) => {} None => {
568 return Err(MonotonicityError::UnknownProperty {
569 type_name: type_name.clone(),
570 property: prop_name.clone(),
571 });
572 }
573 }
574 }
575
576 if !update.add_subtypes.is_empty() {
578 if let Some(ref existing) = def.subtypes {
579 for st_name in update.add_subtypes.keys() {
580 if existing.contains_key(st_name) {
581 return Err(MonotonicityError::DuplicateProperty {
582 type_name: type_name.clone(),
583 property: format!("subtype:{st_name}"),
584 });
585 }
586 }
587 }
588 }
589 }
590
591 self.node_types.extend(ext.node_types.clone());
593
594 self.edge_types.extend(ext.edge_types.clone());
596
597 for (type_name, update) in &ext.node_type_updates {
599 let def = self.node_types.get_mut(type_name).unwrap(); def.properties.extend(update.add_properties.clone());
603
604 for prop_name in &update.relax_properties {
606 if let Some(prop_def) = def.properties.get_mut(prop_name) {
607 prop_def.required = false;
608 }
609 }
610
611 if !update.add_subtypes.is_empty() {
613 let subtypes = def.subtypes.get_or_insert_with(BTreeMap::new);
614 subtypes.extend(update.add_subtypes.clone());
615 }
616 }
617
618 self.validate_self()
620 .map_err(MonotonicityError::ValidationFailed)?;
621
622 Ok(())
623 }
624}
625
626fn validate_properties(
628 type_name: &str,
629 defs: &BTreeMap<String, PropertyDef>,
630 values: &BTreeMap<String, Value>,
631) -> Result<(), ValidationError> {
632 for (prop_name, prop_def) in defs {
634 if prop_def.required && !values.contains_key(prop_name) {
635 return Err(ValidationError::MissingRequiredProperty {
636 type_name: type_name.to_string(),
637 property: prop_name.clone(),
638 });
639 }
640 }
641
642 for (prop_name, value) in values {
644 let prop_def = match defs.get(prop_name) {
647 Some(def) => def,
648 None => continue,
649 };
650
651 if prop_def.value_type != ValueType::Any {
652 let actual_type = value_type_name(value);
653 let expected = &prop_def.value_type;
654 if !value_matches_type(value, expected) {
655 return Err(ValidationError::WrongPropertyType {
656 type_name: type_name.to_string(),
657 property: prop_name.clone(),
658 expected: expected.clone(),
659 got: actual_type.to_string(),
660 });
661 }
662 }
663
664 if let Some(constraints) = &prop_def.constraints {
666 validate_constraints(type_name, prop_name, value, constraints)?;
667 }
668 }
669
670 Ok(())
671}
672
673fn validate_constraints(
678 type_name: &str,
679 prop_name: &str,
680 value: &Value,
681 constraints: &BTreeMap<String, serde_json::Value>,
682) -> Result<(), ValidationError> {
683 if let Some(serde_json::Value::Array(allowed)) = constraints.get("enum") {
685 if let Value::String(s) = value {
686 let allowed_strs: Vec<&str> = allowed.iter().filter_map(|v| v.as_str()).collect();
687 if !allowed_strs.contains(&s.as_str()) {
688 return constraint_err(
689 type_name,
690 prop_name,
691 "enum",
692 format!("value '{}' not in allowed set {:?}", s, allowed_strs),
693 );
694 }
695 }
696 }
697
698 check_numeric_bound(
700 type_name,
701 prop_name,
702 value,
703 constraints,
704 "min",
705 |n, b| n < b,
706 |n, b| format!("value {} is less than minimum {}", n, b),
707 )?;
708 check_numeric_bound(
709 type_name,
710 prop_name,
711 value,
712 constraints,
713 "max",
714 |n, b| n > b,
715 |n, b| format!("value {} exceeds maximum {}", n, b),
716 )?;
717 check_numeric_bound(
718 type_name,
719 prop_name,
720 value,
721 constraints,
722 "min_exclusive",
723 |n, b| n <= b,
724 |n, b| format!("value {} must be greater than {}", n, b),
725 )?;
726 check_numeric_bound(
727 type_name,
728 prop_name,
729 value,
730 constraints,
731 "max_exclusive",
732 |n, b| n >= b,
733 |n, b| format!("value {} must be less than {}", n, b),
734 )?;
735
736 check_string_length(
738 type_name,
739 prop_name,
740 value,
741 constraints,
742 "min_length",
743 |len, bound| len < bound,
744 |len, bound| format!("string length {} is less than minimum {}", len, bound),
745 )?;
746 check_string_length(
747 type_name,
748 prop_name,
749 value,
750 constraints,
751 "max_length",
752 |len, bound| len > bound,
753 |len, bound| format!("string length {} exceeds maximum {}", len, bound),
754 )?;
755
756 if let Some(serde_json::Value::String(pattern)) = constraints.get("pattern") {
758 if let Value::String(s) = value {
759 match regex::Regex::new(pattern) {
760 Ok(re) if !re.is_match(s) => {
761 return constraint_err(
762 type_name,
763 prop_name,
764 "pattern",
765 format!("value '{}' does not match pattern '{}'", s, pattern),
766 );
767 }
768 Err(e) => {
769 return constraint_err(
770 type_name,
771 prop_name,
772 "pattern",
773 format!("invalid regex pattern '{}': {}", pattern, e),
774 );
775 }
776 _ => {}
777 }
778 }
779 }
780
781 Ok(())
783}
784
785fn value_as_f64(value: &Value) -> Option<f64> {
787 match value {
788 Value::Int(n) => Some(*n as f64),
789 Value::Float(n) => Some(*n),
790 _ => None,
791 }
792}
793
794fn check_numeric_bound(
796 type_name: &str,
797 prop_name: &str,
798 value: &Value,
799 constraints: &BTreeMap<String, serde_json::Value>,
800 key: &str,
801 violates: impl Fn(f64, f64) -> bool,
802 msg: impl Fn(f64, f64) -> String,
803) -> Result<(), ValidationError> {
804 if let Some(bound_val) = constraints.get(key) {
805 if let Some(bound) = bound_val.as_f64() {
806 if let Some(n) = value_as_f64(value) {
807 if violates(n, bound) {
808 return constraint_err(type_name, prop_name, key, msg(n, bound));
809 }
810 }
811 }
812 }
813 Ok(())
814}
815
816fn check_string_length(
818 type_name: &str,
819 prop_name: &str,
820 value: &Value,
821 constraints: &BTreeMap<String, serde_json::Value>,
822 key: &str,
823 violates: impl Fn(u64, u64) -> bool,
824 msg: impl Fn(u64, u64) -> String,
825) -> Result<(), ValidationError> {
826 if let Some(serde_json::Value::Number(n)) = constraints.get(key) {
827 if let (Some(bound), Value::String(s)) = (n.as_u64(), value) {
828 if violates(s.len() as u64, bound) {
829 return constraint_err(type_name, prop_name, key, msg(s.len() as u64, bound));
830 }
831 }
832 }
833 Ok(())
834}
835
836fn constraint_err(
838 type_name: &str,
839 prop_name: &str,
840 constraint: &str,
841 message: String,
842) -> Result<(), ValidationError> {
843 Err(ValidationError::ConstraintViolation {
844 type_name: type_name.to_string(),
845 property: prop_name.to_string(),
846 constraint: constraint.to_string(),
847 message,
848 })
849}
850
851fn value_matches_type(value: &Value, expected: &ValueType) -> bool {
852 matches!(
853 (value, expected),
854 (Value::Null, _)
855 | (Value::String(_), ValueType::String)
856 | (Value::Int(_), ValueType::Int)
857 | (Value::Float(_), ValueType::Float)
858 | (Value::Bool(_), ValueType::Bool)
859 | (Value::List(_), ValueType::List)
860 | (Value::Map(_), ValueType::Map)
861 | (_, ValueType::Any)
862 )
863}
864
865fn value_type_name(value: &Value) -> &'static str {
866 match value {
867 Value::Null => "null",
868 Value::Bool(_) => "bool",
869 Value::Int(_) => "int",
870 Value::Float(_) => "float",
871 Value::String(_) => "string",
872 Value::List(_) => "list",
873 Value::Map(_) => "map",
874 }
875}
876
877#[cfg(test)]
878mod tests {
879 use super::*;
880
881 fn devops_ontology() -> Ontology {
882 Ontology {
883 node_types: BTreeMap::from([
884 (
885 "signal".into(),
886 NodeTypeDef {
887 description: Some("Something observed".into()),
888 properties: BTreeMap::from([(
889 "severity".into(),
890 PropertyDef {
891 value_type: ValueType::String,
892 required: true,
893 description: None,
894 constraints: None,
895 },
896 )]),
897 subtypes: None,
898 parent_type: None,
899 },
900 ),
901 (
902 "entity".into(),
903 NodeTypeDef {
904 description: Some("Something that exists".into()),
905 properties: BTreeMap::from([
906 (
907 "status".into(),
908 PropertyDef {
909 value_type: ValueType::String,
910 required: false,
911 description: None,
912 constraints: None,
913 },
914 ),
915 (
916 "port".into(),
917 PropertyDef {
918 value_type: ValueType::Int,
919 required: false,
920 description: None,
921 constraints: None,
922 },
923 ),
924 ]),
925 subtypes: None,
926 parent_type: None,
927 },
928 ),
929 (
930 "rule".into(),
931 NodeTypeDef {
932 description: None,
933 properties: BTreeMap::new(),
934 subtypes: None,
935 parent_type: None,
936 },
937 ),
938 (
939 "action".into(),
940 NodeTypeDef {
941 description: None,
942 properties: BTreeMap::new(),
943 subtypes: None,
944 parent_type: None,
945 },
946 ),
947 ]),
948 edge_types: BTreeMap::from([
949 (
950 "OBSERVES".into(),
951 EdgeTypeDef {
952 description: None,
953 source_types: vec!["signal".into()],
954 target_types: vec!["entity".into()],
955 properties: BTreeMap::new(),
956 },
957 ),
958 (
959 "TRIGGERS".into(),
960 EdgeTypeDef {
961 description: None,
962 source_types: vec!["signal".into()],
963 target_types: vec!["rule".into()],
964 properties: BTreeMap::new(),
965 },
966 ),
967 (
968 "RUNS_ON".into(),
969 EdgeTypeDef {
970 description: None,
971 source_types: vec!["entity".into()],
972 target_types: vec!["entity".into()],
973 properties: BTreeMap::new(),
974 },
975 ),
976 ]),
977 }
978 }
979
980 #[test]
983 fn validate_node_valid() {
984 let ont = devops_ontology();
985 let props = BTreeMap::from([("severity".into(), Value::String("critical".into()))]);
986 assert!(ont.validate_node("signal", None, &props).is_ok());
987 }
988
989 #[test]
990 fn validate_node_unknown_type() {
991 let ont = devops_ontology();
992 let err = ont
993 .validate_node("potato", None, &BTreeMap::new())
994 .unwrap_err();
995 assert!(matches!(err, ValidationError::UnknownNodeType(t) if t == "potato"));
996 }
997
998 #[test]
999 fn validate_node_missing_required() {
1000 let ont = devops_ontology();
1001 let err = ont
1002 .validate_node("signal", None, &BTreeMap::new())
1003 .unwrap_err();
1004 assert!(
1005 matches!(err, ValidationError::MissingRequiredProperty { property, .. } if property == "severity")
1006 );
1007 }
1008
1009 #[test]
1010 fn validate_node_wrong_type() {
1011 let ont = devops_ontology();
1012 let props = BTreeMap::from([("severity".into(), Value::Int(5))]);
1013 let err = ont.validate_node("signal", None, &props).unwrap_err();
1014 assert!(
1015 matches!(err, ValidationError::WrongPropertyType { property, .. } if property == "severity")
1016 );
1017 }
1018
1019 #[test]
1020 fn validate_node_unknown_property_accepted() {
1021 let ont = devops_ontology();
1023 let props = BTreeMap::from([
1024 ("severity".into(), Value::String("warn".into())),
1025 ("bogus".into(), Value::Bool(true)),
1026 ]);
1027 assert!(ont.validate_node("signal", None, &props).is_ok());
1028 }
1029
1030 #[test]
1031 fn validate_node_optional_property_absent() {
1032 let ont = devops_ontology();
1033 assert!(ont.validate_node("entity", None, &BTreeMap::new()).is_ok());
1035 }
1036
1037 #[test]
1038 fn validate_node_null_accepted_for_any_type() {
1039 let ont = devops_ontology();
1040 let props = BTreeMap::from([("severity".into(), Value::Null)]);
1042 assert!(ont.validate_node("signal", None, &props).is_ok());
1043 }
1044
1045 #[test]
1048 fn validate_edge_valid() {
1049 let ont = devops_ontology();
1050 assert!(ont
1051 .validate_edge("OBSERVES", "signal", "entity", &BTreeMap::new())
1052 .is_ok());
1053 }
1054
1055 #[test]
1056 fn validate_edge_unknown_type() {
1057 let ont = devops_ontology();
1058 let err = ont
1059 .validate_edge("FLIES_TO", "signal", "entity", &BTreeMap::new())
1060 .unwrap_err();
1061 assert!(matches!(err, ValidationError::UnknownEdgeType(t) if t == "FLIES_TO"));
1062 }
1063
1064 #[test]
1065 fn validate_edge_invalid_source() {
1066 let ont = devops_ontology();
1067 let err = ont
1069 .validate_edge("OBSERVES", "entity", "entity", &BTreeMap::new())
1070 .unwrap_err();
1071 assert!(matches!(err, ValidationError::InvalidSource { .. }));
1072 }
1073
1074 #[test]
1075 fn validate_edge_invalid_target() {
1076 let ont = devops_ontology();
1077 let err = ont
1079 .validate_edge("OBSERVES", "signal", "signal", &BTreeMap::new())
1080 .unwrap_err();
1081 assert!(matches!(err, ValidationError::InvalidTarget { .. }));
1082 }
1083
1084 #[test]
1087 fn validate_self_consistent() {
1088 let ont = devops_ontology();
1089 assert!(ont.validate_self().is_ok());
1090 }
1091
1092 #[test]
1093 fn validate_self_dangling_source() {
1094 let ont = Ontology {
1095 node_types: BTreeMap::from([(
1096 "entity".into(),
1097 NodeTypeDef {
1098 description: None,
1099 properties: BTreeMap::new(),
1100 subtypes: None,
1101 parent_type: None,
1102 },
1103 )]),
1104 edge_types: BTreeMap::from([(
1105 "OBSERVES".into(),
1106 EdgeTypeDef {
1107 description: None,
1108 source_types: vec!["ghost".into()], target_types: vec!["entity".into()],
1110 properties: BTreeMap::new(),
1111 },
1112 )]),
1113 };
1114 let err = ont.validate_self().unwrap_err();
1115 assert!(
1116 matches!(err, ValidationError::InvalidSource { node_type, .. } if node_type == "ghost")
1117 );
1118 }
1119
1120 #[test]
1121 fn validate_self_dangling_target() {
1122 let ont = Ontology {
1123 node_types: BTreeMap::from([(
1124 "signal".into(),
1125 NodeTypeDef {
1126 description: None,
1127 properties: BTreeMap::new(),
1128 subtypes: None,
1129 parent_type: None,
1130 },
1131 )]),
1132 edge_types: BTreeMap::from([(
1133 "OBSERVES".into(),
1134 EdgeTypeDef {
1135 description: None,
1136 source_types: vec!["signal".into()],
1137 target_types: vec!["phantom".into()], properties: BTreeMap::new(),
1139 },
1140 )]),
1141 };
1142 let err = ont.validate_self().unwrap_err();
1143 assert!(
1144 matches!(err, ValidationError::InvalidTarget { node_type, .. } if node_type == "phantom")
1145 );
1146 }
1147
1148 fn constrained_ontology() -> Ontology {
1153 Ontology {
1154 node_types: BTreeMap::from([(
1155 "item".into(),
1156 NodeTypeDef {
1157 description: None,
1158 properties: BTreeMap::from([
1159 (
1160 "slug".into(),
1161 PropertyDef {
1162 value_type: ValueType::String,
1163 required: false,
1164 description: None,
1165 constraints: Some(BTreeMap::from([
1166 (
1167 "pattern".to_string(),
1168 serde_json::Value::String("^[a-z0-9-]+$".to_string()),
1169 ),
1170 (
1171 "min_length".to_string(),
1172 serde_json::Value::Number(1.into()),
1173 ),
1174 (
1175 "max_length".to_string(),
1176 serde_json::Value::Number(63.into()),
1177 ),
1178 ])),
1179 },
1180 ),
1181 (
1182 "score".into(),
1183 PropertyDef {
1184 value_type: ValueType::Float,
1185 required: false,
1186 description: None,
1187 constraints: Some(BTreeMap::from([
1188 ("min_exclusive".to_string(), serde_json::json!(0.0)),
1189 ("max_exclusive".to_string(), serde_json::json!(100.0)),
1190 ])),
1191 },
1192 ),
1193 ]),
1194 subtypes: None,
1195 parent_type: None,
1196 },
1197 )]),
1198 edge_types: BTreeMap::new(),
1199 }
1200 }
1201
1202 #[test]
1203 fn pattern_valid_slug() {
1204 let ont = constrained_ontology();
1205 let props = BTreeMap::from([("slug".into(), Value::String("my-project-1".into()))]);
1206 assert!(ont.validate_node("item", None, &props).is_ok());
1207 }
1208
1209 #[test]
1210 fn pattern_rejects_uppercase() {
1211 let ont = constrained_ontology();
1212 let props = BTreeMap::from([("slug".into(), Value::String("My-Project".into()))]);
1213 assert!(ont.validate_node("item", None, &props).is_err());
1214 }
1215
1216 #[test]
1217 fn pattern_rejects_spaces() {
1218 let ont = constrained_ontology();
1219 let props = BTreeMap::from([("slug".into(), Value::String("has space".into()))]);
1220 assert!(ont.validate_node("item", None, &props).is_err());
1221 }
1222
1223 #[test]
1224 fn min_length_accepts_valid() {
1225 let ont = constrained_ontology();
1226 let props = BTreeMap::from([("slug".into(), Value::String("a".into()))]);
1227 assert!(ont.validate_node("item", None, &props).is_ok());
1228 }
1229
1230 #[test]
1231 fn min_length_rejects_empty() {
1232 let ont = constrained_ontology();
1233 let props = BTreeMap::from([("slug".into(), Value::String("".into()))]);
1234 let err = ont.validate_node("item", None, &props).unwrap_err();
1235 assert!(
1236 matches!(err, ValidationError::ConstraintViolation { constraint, .. } if constraint == "min_length")
1237 );
1238 }
1239
1240 #[test]
1241 fn max_length_rejects_too_long() {
1242 let ont = constrained_ontology();
1243 let long = "a".repeat(64);
1244 let props = BTreeMap::from([("slug".into(), Value::String(long))]);
1245 let err = ont.validate_node("item", None, &props).unwrap_err();
1246 assert!(
1247 matches!(err, ValidationError::ConstraintViolation { constraint, .. } if constraint == "max_length")
1248 );
1249 }
1250
1251 #[test]
1252 fn max_length_accepts_boundary() {
1253 let ont = constrained_ontology();
1254 let exact = "a".repeat(63);
1255 let props = BTreeMap::from([("slug".into(), Value::String(exact))]);
1256 assert!(ont.validate_node("item", None, &props).is_ok());
1257 }
1258
1259 #[test]
1260 fn min_exclusive_rejects_boundary() {
1261 let ont = constrained_ontology();
1262 let props = BTreeMap::from([("score".into(), Value::Float(0.0))]);
1263 let err = ont.validate_node("item", None, &props).unwrap_err();
1264 assert!(
1265 matches!(err, ValidationError::ConstraintViolation { constraint, .. } if constraint == "min_exclusive")
1266 );
1267 }
1268
1269 #[test]
1270 fn min_exclusive_accepts_above() {
1271 let ont = constrained_ontology();
1272 let props = BTreeMap::from([("score".into(), Value::Float(0.001))]);
1273 assert!(ont.validate_node("item", None, &props).is_ok());
1274 }
1275
1276 #[test]
1277 fn max_exclusive_rejects_boundary() {
1278 let ont = constrained_ontology();
1279 let props = BTreeMap::from([("score".into(), Value::Float(100.0))]);
1280 let err = ont.validate_node("item", None, &props).unwrap_err();
1281 assert!(
1282 matches!(err, ValidationError::ConstraintViolation { constraint, .. } if constraint == "max_exclusive")
1283 );
1284 }
1285
1286 #[test]
1287 fn max_exclusive_accepts_below() {
1288 let ont = constrained_ontology();
1289 let props = BTreeMap::from([("score".into(), Value::Float(99.999))]);
1290 assert!(ont.validate_node("item", None, &props).is_ok());
1291 }
1292
1293 #[test]
1296 fn ontology_roundtrip_msgpack() {
1297 let ont = devops_ontology();
1298 let bytes = rmp_serde::to_vec(&ont).unwrap();
1299 let decoded: Ontology = rmp_serde::from_slice(&bytes).unwrap();
1300 assert_eq!(ont, decoded);
1301 }
1302
1303 #[test]
1304 fn ontology_roundtrip_json() {
1305 let ont = devops_ontology();
1306 let json = serde_json::to_string(&ont).unwrap();
1307 let decoded: Ontology = serde_json::from_str(&json).unwrap();
1308 assert_eq!(ont, decoded);
1309 }
1310
1311 fn hierarchy_ontology() -> Ontology {
1314 Ontology {
1317 node_types: BTreeMap::from([
1318 (
1319 "thing".into(),
1320 NodeTypeDef {
1321 description: None,
1322 properties: BTreeMap::from([(
1323 "name".into(),
1324 PropertyDef {
1325 value_type: ValueType::String,
1326 required: true,
1327 description: None,
1328 constraints: None,
1329 },
1330 )]),
1331 subtypes: None,
1332 parent_type: None, },
1334 ),
1335 (
1336 "entity".into(),
1337 NodeTypeDef {
1338 description: None,
1339 properties: BTreeMap::from([(
1340 "status".into(),
1341 PropertyDef {
1342 value_type: ValueType::String,
1343 required: false,
1344 description: None,
1345 constraints: None,
1346 },
1347 )]),
1348 subtypes: None,
1349 parent_type: Some("thing".into()), },
1351 ),
1352 (
1353 "server".into(),
1354 NodeTypeDef {
1355 description: None,
1356 properties: BTreeMap::from([(
1357 "ip".into(),
1358 PropertyDef {
1359 value_type: ValueType::String,
1360 required: false,
1361 description: None,
1362 constraints: None,
1363 },
1364 )]),
1365 subtypes: None,
1366 parent_type: Some("entity".into()), },
1368 ),
1369 (
1370 "event".into(),
1371 NodeTypeDef {
1372 description: None,
1373 properties: BTreeMap::new(),
1374 subtypes: None,
1375 parent_type: Some("thing".into()), },
1377 ),
1378 ]),
1379 edge_types: BTreeMap::from([(
1380 "RELATES_TO".into(),
1381 EdgeTypeDef {
1382 description: None,
1383 source_types: vec!["thing".into()], target_types: vec!["entity".into()], properties: BTreeMap::new(),
1386 },
1387 )]),
1388 }
1389 }
1390
1391 #[test]
1392 fn ancestors_empty_for_root() {
1393 let ont = hierarchy_ontology();
1394 assert!(ont.ancestors("thing").is_empty());
1395 }
1396
1397 #[test]
1398 fn ancestors_single_parent() {
1399 let ont = hierarchy_ontology();
1400 assert_eq!(ont.ancestors("entity"), vec!["thing"]);
1401 }
1402
1403 #[test]
1404 fn ancestors_transitive() {
1405 let ont = hierarchy_ontology();
1406 assert_eq!(ont.ancestors("server"), vec!["entity", "thing"]);
1408 }
1409
1410 #[test]
1411 fn descendants_of_root() {
1412 let ont = hierarchy_ontology();
1413 let mut desc = ont.descendants("thing");
1414 desc.sort();
1415 assert_eq!(desc, vec!["entity", "event", "server"]);
1416 }
1417
1418 #[test]
1419 fn descendants_of_entity() {
1420 let ont = hierarchy_ontology();
1421 assert_eq!(ont.descendants("entity"), vec!["server"]);
1422 }
1423
1424 #[test]
1425 fn descendants_of_leaf() {
1426 let ont = hierarchy_ontology();
1427 assert!(ont.descendants("server").is_empty());
1428 }
1429
1430 #[test]
1431 fn is_subtype_of_self() {
1432 let ont = hierarchy_ontology();
1433 assert!(ont.is_subtype_of("server", "server"));
1434 }
1435
1436 #[test]
1437 fn is_subtype_of_parent() {
1438 let ont = hierarchy_ontology();
1439 assert!(ont.is_subtype_of("server", "entity"));
1440 assert!(ont.is_subtype_of("server", "thing"));
1441 }
1442
1443 #[test]
1444 fn is_not_subtype_of_sibling() {
1445 let ont = hierarchy_ontology();
1446 assert!(!ont.is_subtype_of("server", "event"));
1447 }
1448
1449 #[test]
1450 fn effective_properties_inherits() {
1451 let ont = hierarchy_ontology();
1452 let props = ont.effective_properties("server");
1453 assert!(props.contains_key("name"));
1455 assert!(props.contains_key("status"));
1456 assert!(props.contains_key("ip"));
1457 }
1458
1459 #[test]
1460 fn effective_properties_root_has_own_only() {
1461 let ont = hierarchy_ontology();
1462 let props = ont.effective_properties("thing");
1463 assert!(props.contains_key("name"));
1464 assert!(!props.contains_key("status"));
1465 }
1466
1467 #[test]
1468 fn validate_node_inherits_required_from_ancestor() {
1469 let ont = hierarchy_ontology();
1470 let err = ont.validate_node("server", None, &BTreeMap::new());
1472 assert!(err.is_err());
1473
1474 let props = BTreeMap::from([("name".into(), Value::String("web-01".into()))]);
1475 assert!(ont.validate_node("server", None, &props).is_ok());
1476 }
1477
1478 #[test]
1479 fn validate_edge_hierarchy_aware() {
1480 let ont = hierarchy_ontology();
1481 let empty = BTreeMap::new();
1484 assert!(ont
1485 .validate_edge("RELATES_TO", "server", "server", &empty)
1486 .is_ok());
1487 assert!(ont
1488 .validate_edge("RELATES_TO", "event", "entity", &empty)
1489 .is_ok());
1490 assert!(ont
1491 .validate_edge("RELATES_TO", "thing", "entity", &empty)
1492 .is_ok());
1493 }
1494
1495 #[test]
1496 fn validate_edge_hierarchy_rejects_wrong_branch() {
1497 let ont = hierarchy_ontology();
1498 let empty = BTreeMap::new();
1500 assert!(ont
1501 .validate_edge("RELATES_TO", "thing", "event", &empty)
1502 .is_err());
1503 }
1504
1505 #[test]
1506 fn validate_self_rejects_dangling_parent() {
1507 let ont = Ontology {
1508 node_types: BTreeMap::from([(
1509 "orphan".into(),
1510 NodeTypeDef {
1511 description: None,
1512 properties: BTreeMap::new(),
1513 subtypes: None,
1514 parent_type: Some("ghost".into()), },
1516 )]),
1517 edge_types: BTreeMap::new(),
1518 };
1519 assert!(ont.validate_self().is_err());
1520 }
1521}