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}
60
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63pub struct EdgeTypeDef {
64 #[serde(default)]
65 pub description: Option<String>,
66 pub source_types: Vec<String>,
68 pub target_types: Vec<String>,
70 #[serde(default)]
71 pub properties: BTreeMap<String, PropertyDef>,
72}
73
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct Ontology {
80 pub node_types: BTreeMap<String, NodeTypeDef>,
81 pub edge_types: BTreeMap<String, EdgeTypeDef>,
82}
83
84#[derive(Debug, Clone, PartialEq)]
86pub enum ValidationError {
87 UnknownNodeType(String),
88 UnknownEdgeType(String),
89 InvalidSource {
90 edge_type: String,
91 node_type: String,
92 allowed: Vec<String>,
93 },
94 InvalidTarget {
95 edge_type: String,
96 node_type: String,
97 allowed: Vec<String>,
98 },
99 MissingRequiredProperty {
100 type_name: String,
101 property: String,
102 },
103 WrongPropertyType {
104 type_name: String,
105 property: String,
106 expected: ValueType,
107 got: String,
108 },
109 UnknownProperty {
110 type_name: String,
111 property: String,
112 },
113 MissingSubtype {
114 node_type: String,
115 allowed: Vec<String>,
116 },
117 UnknownSubtype {
118 node_type: String,
119 subtype: String,
120 allowed: Vec<String>,
121 },
122 UnexpectedSubtype {
123 node_type: String,
124 subtype: String,
125 },
126 ConstraintViolation {
128 type_name: String,
129 property: String,
130 constraint: String,
131 message: String,
132 },
133}
134
135impl std::fmt::Display for ValidationError {
136 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137 match self {
138 ValidationError::UnknownNodeType(t) => write!(f, "unknown node type: '{t}'"),
139 ValidationError::UnknownEdgeType(t) => write!(f, "unknown edge type: '{t}'"),
140 ValidationError::InvalidSource {
141 edge_type,
142 node_type,
143 allowed,
144 } => write!(
145 f,
146 "edge '{edge_type}' cannot have source type '{node_type}' (allowed: {allowed:?})"
147 ),
148 ValidationError::InvalidTarget {
149 edge_type,
150 node_type,
151 allowed,
152 } => write!(
153 f,
154 "edge '{edge_type}' cannot have target type '{node_type}' (allowed: {allowed:?})"
155 ),
156 ValidationError::MissingRequiredProperty {
157 type_name,
158 property,
159 } => write!(f, "'{type_name}' requires property '{property}'"),
160 ValidationError::WrongPropertyType {
161 type_name,
162 property,
163 expected,
164 got,
165 } => write!(
166 f,
167 "'{type_name}'.'{property}' expects {expected:?}, got {got}"
168 ),
169 ValidationError::UnknownProperty {
170 type_name,
171 property,
172 } => write!(f, "'{type_name}' has no property '{property}' in ontology"),
173 ValidationError::MissingSubtype { node_type, allowed } => {
174 write!(f, "'{node_type}' requires a subtype (allowed: {allowed:?})")
175 }
176 ValidationError::UnknownSubtype {
177 node_type,
178 subtype,
179 allowed,
180 } => write!(
181 f,
182 "'{node_type}' has no subtype '{subtype}' (allowed: {allowed:?})"
183 ),
184 ValidationError::UnexpectedSubtype { node_type, subtype } => write!(
185 f,
186 "'{node_type}' does not define subtypes, but got subtype '{subtype}'"
187 ),
188 ValidationError::ConstraintViolation {
189 type_name,
190 property,
191 constraint,
192 message,
193 } => write!(
194 f,
195 "'{type_name}'.'{property}' violates constraint '{constraint}': {message}"
196 ),
197 }
198 }
199}
200
201#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
203pub struct OntologyExtension {
204 #[serde(default)]
206 pub node_types: BTreeMap<String, NodeTypeDef>,
207 #[serde(default)]
209 pub edge_types: BTreeMap<String, EdgeTypeDef>,
210 #[serde(default)]
212 pub node_type_updates: BTreeMap<String, NodeTypeUpdate>,
213}
214
215#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
217pub struct NodeTypeUpdate {
218 #[serde(default)]
220 pub add_properties: BTreeMap<String, PropertyDef>,
221 #[serde(default)]
223 pub relax_properties: Vec<String>,
224 #[serde(default)]
226 pub add_subtypes: BTreeMap<String, SubtypeDef>,
227}
228
229#[derive(Debug, Clone, PartialEq)]
231pub enum MonotonicityError {
232 DuplicateNodeType(String),
233 DuplicateEdgeType(String),
234 UnknownNodeType(String),
235 DuplicateProperty {
236 type_name: String,
237 property: String,
238 },
239 UnknownProperty {
240 type_name: String,
241 property: String,
242 },
243 ValidationFailed(ValidationError),
245}
246
247impl std::fmt::Display for MonotonicityError {
248 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249 match self {
250 MonotonicityError::DuplicateNodeType(t) => {
251 write!(f, "node type '{t}' already exists")
252 }
253 MonotonicityError::DuplicateEdgeType(t) => {
254 write!(f, "edge type '{t}' already exists")
255 }
256 MonotonicityError::UnknownNodeType(t) => {
257 write!(f, "cannot update unknown node type '{t}'")
258 }
259 MonotonicityError::DuplicateProperty {
260 type_name,
261 property,
262 } => {
263 write!(f, "property '{property}' already exists on '{type_name}'")
264 }
265 MonotonicityError::UnknownProperty {
266 type_name,
267 property,
268 } => {
269 write!(
270 f,
271 "property '{property}' does not exist on '{type_name}' (cannot relax)"
272 )
273 }
274 MonotonicityError::ValidationFailed(e) => {
275 write!(f, "ontology validation failed after merge: {e}")
276 }
277 }
278 }
279}
280
281impl Ontology {
282 pub fn validate_node(
288 &self,
289 node_type: &str,
290 subtype: Option<&str>,
291 properties: &BTreeMap<String, Value>,
292 ) -> Result<(), ValidationError> {
293 let def = self
294 .node_types
295 .get(node_type)
296 .ok_or_else(|| ValidationError::UnknownNodeType(node_type.to_string()))?;
297
298 match (&def.subtypes, subtype) {
299 (Some(subtypes), Some(st)) => {
301 match subtypes.get(st) {
302 Some(st_def) => {
303 let mut merged = def.properties.clone();
305 merged.extend(st_def.properties.clone());
306 validate_properties(node_type, &merged, properties)
307 }
308 None => {
309 validate_properties(node_type, &def.properties, properties)
311 }
312 }
313 }
314 (Some(subtypes), None) => Err(ValidationError::MissingSubtype {
316 node_type: node_type.to_string(),
317 allowed: subtypes.keys().cloned().collect(),
318 }),
319 (None, Some(_st)) => validate_properties(node_type, &def.properties, properties),
321 (None, None) => validate_properties(node_type, &def.properties, properties),
323 }
324 }
325
326 pub fn validate_edge(
329 &self,
330 edge_type: &str,
331 source_node_type: &str,
332 target_node_type: &str,
333 properties: &BTreeMap<String, Value>,
334 ) -> Result<(), ValidationError> {
335 let def = self
336 .edge_types
337 .get(edge_type)
338 .ok_or_else(|| ValidationError::UnknownEdgeType(edge_type.to_string()))?;
339
340 if !def.source_types.iter().any(|t| t == source_node_type) {
341 return Err(ValidationError::InvalidSource {
342 edge_type: edge_type.to_string(),
343 node_type: source_node_type.to_string(),
344 allowed: def.source_types.clone(),
345 });
346 }
347
348 if !def.target_types.iter().any(|t| t == target_node_type) {
349 return Err(ValidationError::InvalidTarget {
350 edge_type: edge_type.to_string(),
351 node_type: target_node_type.to_string(),
352 allowed: def.target_types.clone(),
353 });
354 }
355
356 validate_properties(edge_type, &def.properties, properties)
357 }
358
359 pub fn validate_self(&self) -> Result<(), ValidationError> {
362 for (edge_name, edge_def) in &self.edge_types {
363 for src in &edge_def.source_types {
364 if !self.node_types.contains_key(src) {
365 return Err(ValidationError::InvalidSource {
366 edge_type: edge_name.clone(),
367 node_type: src.clone(),
368 allowed: self.node_types.keys().cloned().collect(),
369 });
370 }
371 }
372 for tgt in &edge_def.target_types {
373 if !self.node_types.contains_key(tgt) {
374 return Err(ValidationError::InvalidTarget {
375 edge_type: edge_name.clone(),
376 node_type: tgt.clone(),
377 allowed: self.node_types.keys().cloned().collect(),
378 });
379 }
380 }
381 }
382 Ok(())
383 }
384
385 pub fn merge_extension(&mut self, ext: &OntologyExtension) -> Result<(), MonotonicityError> {
391 for name in ext.node_types.keys() {
393 if self.node_types.contains_key(name) {
394 return Err(MonotonicityError::DuplicateNodeType(name.clone()));
395 }
396 }
397
398 for name in ext.edge_types.keys() {
400 if self.edge_types.contains_key(name) {
401 return Err(MonotonicityError::DuplicateEdgeType(name.clone()));
402 }
403 }
404
405 for (type_name, update) in &ext.node_type_updates {
407 let def = self
408 .node_types
409 .get(type_name)
410 .ok_or_else(|| MonotonicityError::UnknownNodeType(type_name.clone()))?;
411
412 for prop_name in update.add_properties.keys() {
414 if def.properties.contains_key(prop_name) {
415 return Err(MonotonicityError::DuplicateProperty {
416 type_name: type_name.clone(),
417 property: prop_name.clone(),
418 });
419 }
420 }
421
422 for prop_name in &update.relax_properties {
424 match def.properties.get(prop_name) {
425 Some(prop_def) if prop_def.required => {} Some(_) => {} None => {
428 return Err(MonotonicityError::UnknownProperty {
429 type_name: type_name.clone(),
430 property: prop_name.clone(),
431 });
432 }
433 }
434 }
435
436 if !update.add_subtypes.is_empty() {
438 if let Some(ref existing) = def.subtypes {
439 for st_name in update.add_subtypes.keys() {
440 if existing.contains_key(st_name) {
441 return Err(MonotonicityError::DuplicateProperty {
442 type_name: type_name.clone(),
443 property: format!("subtype:{st_name}"),
444 });
445 }
446 }
447 }
448 }
449 }
450
451 self.node_types.extend(ext.node_types.clone());
453
454 self.edge_types.extend(ext.edge_types.clone());
456
457 for (type_name, update) in &ext.node_type_updates {
459 let def = self.node_types.get_mut(type_name).unwrap(); def.properties.extend(update.add_properties.clone());
463
464 for prop_name in &update.relax_properties {
466 if let Some(prop_def) = def.properties.get_mut(prop_name) {
467 prop_def.required = false;
468 }
469 }
470
471 if !update.add_subtypes.is_empty() {
473 let subtypes = def.subtypes.get_or_insert_with(BTreeMap::new);
474 subtypes.extend(update.add_subtypes.clone());
475 }
476 }
477
478 self.validate_self()
480 .map_err(MonotonicityError::ValidationFailed)?;
481
482 Ok(())
483 }
484}
485
486fn validate_properties(
488 type_name: &str,
489 defs: &BTreeMap<String, PropertyDef>,
490 values: &BTreeMap<String, Value>,
491) -> Result<(), ValidationError> {
492 for (prop_name, prop_def) in defs {
494 if prop_def.required && !values.contains_key(prop_name) {
495 return Err(ValidationError::MissingRequiredProperty {
496 type_name: type_name.to_string(),
497 property: prop_name.clone(),
498 });
499 }
500 }
501
502 for (prop_name, value) in values {
504 let prop_def = match defs.get(prop_name) {
507 Some(def) => def,
508 None => continue,
509 };
510
511 if prop_def.value_type != ValueType::Any {
512 let actual_type = value_type_name(value);
513 let expected = &prop_def.value_type;
514 if !value_matches_type(value, expected) {
515 return Err(ValidationError::WrongPropertyType {
516 type_name: type_name.to_string(),
517 property: prop_name.clone(),
518 expected: expected.clone(),
519 got: actual_type.to_string(),
520 });
521 }
522 }
523
524 if let Some(constraints) = &prop_def.constraints {
526 validate_constraints(type_name, prop_name, value, constraints)?;
527 }
528 }
529
530 Ok(())
531}
532
533fn validate_constraints(
538 type_name: &str,
539 prop_name: &str,
540 value: &Value,
541 constraints: &BTreeMap<String, serde_json::Value>,
542) -> Result<(), ValidationError> {
543 if let Some(serde_json::Value::Array(allowed)) = constraints.get("enum") {
545 if let Value::String(s) = value {
546 let allowed_strs: Vec<&str> = allowed.iter().filter_map(|v| v.as_str()).collect();
547 if !allowed_strs.contains(&s.as_str()) {
548 return Err(ValidationError::ConstraintViolation {
549 type_name: type_name.to_string(),
550 property: prop_name.to_string(),
551 constraint: "enum".to_string(),
552 message: format!("value '{}' not in allowed set {:?}", s, allowed_strs),
553 });
554 }
555 }
556 }
557
558 if let Some(min_val) = constraints.get("min") {
560 if let Some(min) = min_val.as_f64() {
561 let num = match value {
562 Value::Int(n) => Some(*n as f64),
563 Value::Float(n) => Some(*n),
564 _ => None,
565 };
566 if let Some(n) = num {
567 if n < min {
568 return Err(ValidationError::ConstraintViolation {
569 type_name: type_name.to_string(),
570 property: prop_name.to_string(),
571 constraint: "min".to_string(),
572 message: format!("value {} is less than minimum {}", n, min),
573 });
574 }
575 }
576 }
577 }
578
579 if let Some(max_val) = constraints.get("max") {
581 if let Some(max) = max_val.as_f64() {
582 let num = match value {
583 Value::Int(n) => Some(*n as f64),
584 Value::Float(n) => Some(*n),
585 _ => None,
586 };
587 if let Some(n) = num {
588 if n > max {
589 return Err(ValidationError::ConstraintViolation {
590 type_name: type_name.to_string(),
591 property: prop_name.to_string(),
592 constraint: "max".to_string(),
593 message: format!("value {} exceeds maximum {}", n, max),
594 });
595 }
596 }
597 }
598 }
599
600 Ok(())
604}
605
606fn value_matches_type(value: &Value, expected: &ValueType) -> bool {
607 matches!(
608 (value, expected),
609 (Value::Null, _)
610 | (Value::String(_), ValueType::String)
611 | (Value::Int(_), ValueType::Int)
612 | (Value::Float(_), ValueType::Float)
613 | (Value::Bool(_), ValueType::Bool)
614 | (Value::List(_), ValueType::List)
615 | (Value::Map(_), ValueType::Map)
616 | (_, ValueType::Any)
617 )
618}
619
620fn value_type_name(value: &Value) -> &'static str {
621 match value {
622 Value::Null => "null",
623 Value::Bool(_) => "bool",
624 Value::Int(_) => "int",
625 Value::Float(_) => "float",
626 Value::String(_) => "string",
627 Value::List(_) => "list",
628 Value::Map(_) => "map",
629 }
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635
636 fn devops_ontology() -> Ontology {
637 Ontology {
638 node_types: BTreeMap::from([
639 (
640 "signal".into(),
641 NodeTypeDef {
642 description: Some("Something observed".into()),
643 properties: BTreeMap::from([(
644 "severity".into(),
645 PropertyDef {
646 value_type: ValueType::String,
647 required: true,
648 description: None,
649 constraints: None,
650 },
651 )]),
652 subtypes: None,
653 },
654 ),
655 (
656 "entity".into(),
657 NodeTypeDef {
658 description: Some("Something that exists".into()),
659 properties: BTreeMap::from([
660 (
661 "status".into(),
662 PropertyDef {
663 value_type: ValueType::String,
664 required: false,
665 description: None,
666 constraints: None,
667 },
668 ),
669 (
670 "port".into(),
671 PropertyDef {
672 value_type: ValueType::Int,
673 required: false,
674 description: None,
675 constraints: None,
676 },
677 ),
678 ]),
679 subtypes: None,
680 },
681 ),
682 (
683 "rule".into(),
684 NodeTypeDef {
685 description: None,
686 properties: BTreeMap::new(),
687 subtypes: None,
688 },
689 ),
690 (
691 "action".into(),
692 NodeTypeDef {
693 description: None,
694 properties: BTreeMap::new(),
695 subtypes: None,
696 },
697 ),
698 ]),
699 edge_types: BTreeMap::from([
700 (
701 "OBSERVES".into(),
702 EdgeTypeDef {
703 description: None,
704 source_types: vec!["signal".into()],
705 target_types: vec!["entity".into()],
706 properties: BTreeMap::new(),
707 },
708 ),
709 (
710 "TRIGGERS".into(),
711 EdgeTypeDef {
712 description: None,
713 source_types: vec!["signal".into()],
714 target_types: vec!["rule".into()],
715 properties: BTreeMap::new(),
716 },
717 ),
718 (
719 "RUNS_ON".into(),
720 EdgeTypeDef {
721 description: None,
722 source_types: vec!["entity".into()],
723 target_types: vec!["entity".into()],
724 properties: BTreeMap::new(),
725 },
726 ),
727 ]),
728 }
729 }
730
731 #[test]
734 fn validate_node_valid() {
735 let ont = devops_ontology();
736 let props = BTreeMap::from([("severity".into(), Value::String("critical".into()))]);
737 assert!(ont.validate_node("signal", None, &props).is_ok());
738 }
739
740 #[test]
741 fn validate_node_unknown_type() {
742 let ont = devops_ontology();
743 let err = ont
744 .validate_node("potato", None, &BTreeMap::new())
745 .unwrap_err();
746 assert!(matches!(err, ValidationError::UnknownNodeType(t) if t == "potato"));
747 }
748
749 #[test]
750 fn validate_node_missing_required() {
751 let ont = devops_ontology();
752 let err = ont
753 .validate_node("signal", None, &BTreeMap::new())
754 .unwrap_err();
755 assert!(
756 matches!(err, ValidationError::MissingRequiredProperty { property, .. } if property == "severity")
757 );
758 }
759
760 #[test]
761 fn validate_node_wrong_type() {
762 let ont = devops_ontology();
763 let props = BTreeMap::from([("severity".into(), Value::Int(5))]);
764 let err = ont.validate_node("signal", None, &props).unwrap_err();
765 assert!(
766 matches!(err, ValidationError::WrongPropertyType { property, .. } if property == "severity")
767 );
768 }
769
770 #[test]
771 fn validate_node_unknown_property_accepted() {
772 let ont = devops_ontology();
774 let props = BTreeMap::from([
775 ("severity".into(), Value::String("warn".into())),
776 ("bogus".into(), Value::Bool(true)),
777 ]);
778 assert!(ont.validate_node("signal", None, &props).is_ok());
779 }
780
781 #[test]
782 fn validate_node_optional_property_absent() {
783 let ont = devops_ontology();
784 assert!(ont.validate_node("entity", None, &BTreeMap::new()).is_ok());
786 }
787
788 #[test]
789 fn validate_node_null_accepted_for_any_type() {
790 let ont = devops_ontology();
791 let props = BTreeMap::from([("severity".into(), Value::Null)]);
793 assert!(ont.validate_node("signal", None, &props).is_ok());
794 }
795
796 #[test]
799 fn validate_edge_valid() {
800 let ont = devops_ontology();
801 assert!(ont
802 .validate_edge("OBSERVES", "signal", "entity", &BTreeMap::new())
803 .is_ok());
804 }
805
806 #[test]
807 fn validate_edge_unknown_type() {
808 let ont = devops_ontology();
809 let err = ont
810 .validate_edge("FLIES_TO", "signal", "entity", &BTreeMap::new())
811 .unwrap_err();
812 assert!(matches!(err, ValidationError::UnknownEdgeType(t) if t == "FLIES_TO"));
813 }
814
815 #[test]
816 fn validate_edge_invalid_source() {
817 let ont = devops_ontology();
818 let err = ont
820 .validate_edge("OBSERVES", "entity", "entity", &BTreeMap::new())
821 .unwrap_err();
822 assert!(matches!(err, ValidationError::InvalidSource { .. }));
823 }
824
825 #[test]
826 fn validate_edge_invalid_target() {
827 let ont = devops_ontology();
828 let err = ont
830 .validate_edge("OBSERVES", "signal", "signal", &BTreeMap::new())
831 .unwrap_err();
832 assert!(matches!(err, ValidationError::InvalidTarget { .. }));
833 }
834
835 #[test]
838 fn validate_self_consistent() {
839 let ont = devops_ontology();
840 assert!(ont.validate_self().is_ok());
841 }
842
843 #[test]
844 fn validate_self_dangling_source() {
845 let ont = Ontology {
846 node_types: BTreeMap::from([(
847 "entity".into(),
848 NodeTypeDef {
849 description: None,
850 properties: BTreeMap::new(),
851 subtypes: None,
852 },
853 )]),
854 edge_types: BTreeMap::from([(
855 "OBSERVES".into(),
856 EdgeTypeDef {
857 description: None,
858 source_types: vec!["ghost".into()], target_types: vec!["entity".into()],
860 properties: BTreeMap::new(),
861 },
862 )]),
863 };
864 let err = ont.validate_self().unwrap_err();
865 assert!(
866 matches!(err, ValidationError::InvalidSource { node_type, .. } if node_type == "ghost")
867 );
868 }
869
870 #[test]
871 fn validate_self_dangling_target() {
872 let ont = Ontology {
873 node_types: BTreeMap::from([(
874 "signal".into(),
875 NodeTypeDef {
876 description: None,
877 properties: BTreeMap::new(),
878 subtypes: None,
879 },
880 )]),
881 edge_types: BTreeMap::from([(
882 "OBSERVES".into(),
883 EdgeTypeDef {
884 description: None,
885 source_types: vec!["signal".into()],
886 target_types: vec!["phantom".into()], properties: BTreeMap::new(),
888 },
889 )]),
890 };
891 let err = ont.validate_self().unwrap_err();
892 assert!(
893 matches!(err, ValidationError::InvalidTarget { node_type, .. } if node_type == "phantom")
894 );
895 }
896
897 #[test]
900 fn ontology_roundtrip_msgpack() {
901 let ont = devops_ontology();
902 let bytes = rmp_serde::to_vec(&ont).unwrap();
903 let decoded: Ontology = rmp_serde::from_slice(&bytes).unwrap();
904 assert_eq!(ont, decoded);
905 }
906
907 #[test]
908 fn ontology_roundtrip_json() {
909 let ont = devops_ontology();
910 let json = serde_json::to_string(&ont).unwrap();
911 let decoded: Ontology = serde_json::from_str(&json).unwrap();
912 assert_eq!(ont, decoded);
913 }
914}