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}
29
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct SubtypeDef {
33 #[serde(default)]
34 pub description: Option<String>,
35 #[serde(default)]
36 pub properties: BTreeMap<String, PropertyDef>,
37}
38
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45pub struct NodeTypeDef {
46 #[serde(default)]
47 pub description: Option<String>,
48 #[serde(default)]
49 pub properties: BTreeMap<String, PropertyDef>,
50 #[serde(default)]
53 pub subtypes: Option<BTreeMap<String, SubtypeDef>>,
54}
55
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub struct EdgeTypeDef {
59 #[serde(default)]
60 pub description: Option<String>,
61 pub source_types: Vec<String>,
63 pub target_types: Vec<String>,
65 #[serde(default)]
66 pub properties: BTreeMap<String, PropertyDef>,
67}
68
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74pub struct Ontology {
75 pub node_types: BTreeMap<String, NodeTypeDef>,
76 pub edge_types: BTreeMap<String, EdgeTypeDef>,
77}
78
79#[derive(Debug, Clone, PartialEq)]
81pub enum ValidationError {
82 UnknownNodeType(String),
83 UnknownEdgeType(String),
84 InvalidSource {
85 edge_type: String,
86 node_type: String,
87 allowed: Vec<String>,
88 },
89 InvalidTarget {
90 edge_type: String,
91 node_type: String,
92 allowed: Vec<String>,
93 },
94 MissingRequiredProperty {
95 type_name: String,
96 property: String,
97 },
98 WrongPropertyType {
99 type_name: String,
100 property: String,
101 expected: ValueType,
102 got: String,
103 },
104 UnknownProperty {
105 type_name: String,
106 property: String,
107 },
108 MissingSubtype {
109 node_type: String,
110 allowed: Vec<String>,
111 },
112 UnknownSubtype {
113 node_type: String,
114 subtype: String,
115 allowed: Vec<String>,
116 },
117 UnexpectedSubtype {
118 node_type: String,
119 subtype: String,
120 },
121}
122
123impl std::fmt::Display for ValidationError {
124 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125 match self {
126 ValidationError::UnknownNodeType(t) => write!(f, "unknown node type: '{t}'"),
127 ValidationError::UnknownEdgeType(t) => write!(f, "unknown edge type: '{t}'"),
128 ValidationError::InvalidSource {
129 edge_type,
130 node_type,
131 allowed,
132 } => write!(
133 f,
134 "edge '{edge_type}' cannot have source type '{node_type}' (allowed: {allowed:?})"
135 ),
136 ValidationError::InvalidTarget {
137 edge_type,
138 node_type,
139 allowed,
140 } => write!(
141 f,
142 "edge '{edge_type}' cannot have target type '{node_type}' (allowed: {allowed:?})"
143 ),
144 ValidationError::MissingRequiredProperty {
145 type_name,
146 property,
147 } => write!(f, "'{type_name}' requires property '{property}'"),
148 ValidationError::WrongPropertyType {
149 type_name,
150 property,
151 expected,
152 got,
153 } => write!(
154 f,
155 "'{type_name}'.'{property}' expects {expected:?}, got {got}"
156 ),
157 ValidationError::UnknownProperty {
158 type_name,
159 property,
160 } => write!(f, "'{type_name}' has no property '{property}' in ontology"),
161 ValidationError::MissingSubtype { node_type, allowed } => {
162 write!(f, "'{node_type}' requires a subtype (allowed: {allowed:?})")
163 }
164 ValidationError::UnknownSubtype {
165 node_type,
166 subtype,
167 allowed,
168 } => write!(
169 f,
170 "'{node_type}' has no subtype '{subtype}' (allowed: {allowed:?})"
171 ),
172 ValidationError::UnexpectedSubtype { node_type, subtype } => write!(
173 f,
174 "'{node_type}' does not define subtypes, but got subtype '{subtype}'"
175 ),
176 }
177 }
178}
179
180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
182pub struct OntologyExtension {
183 #[serde(default)]
185 pub node_types: BTreeMap<String, NodeTypeDef>,
186 #[serde(default)]
188 pub edge_types: BTreeMap<String, EdgeTypeDef>,
189 #[serde(default)]
191 pub node_type_updates: BTreeMap<String, NodeTypeUpdate>,
192}
193
194#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
196pub struct NodeTypeUpdate {
197 #[serde(default)]
199 pub add_properties: BTreeMap<String, PropertyDef>,
200 #[serde(default)]
202 pub relax_properties: Vec<String>,
203 #[serde(default)]
205 pub add_subtypes: BTreeMap<String, SubtypeDef>,
206}
207
208#[derive(Debug, Clone, PartialEq)]
210pub enum MonotonicityError {
211 DuplicateNodeType(String),
212 DuplicateEdgeType(String),
213 UnknownNodeType(String),
214 DuplicateProperty {
215 type_name: String,
216 property: String,
217 },
218 UnknownProperty {
219 type_name: String,
220 property: String,
221 },
222 ValidationFailed(ValidationError),
224}
225
226impl std::fmt::Display for MonotonicityError {
227 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228 match self {
229 MonotonicityError::DuplicateNodeType(t) => {
230 write!(f, "node type '{t}' already exists")
231 }
232 MonotonicityError::DuplicateEdgeType(t) => {
233 write!(f, "edge type '{t}' already exists")
234 }
235 MonotonicityError::UnknownNodeType(t) => {
236 write!(f, "cannot update unknown node type '{t}'")
237 }
238 MonotonicityError::DuplicateProperty {
239 type_name,
240 property,
241 } => {
242 write!(f, "property '{property}' already exists on '{type_name}'")
243 }
244 MonotonicityError::UnknownProperty {
245 type_name,
246 property,
247 } => {
248 write!(
249 f,
250 "property '{property}' does not exist on '{type_name}' (cannot relax)"
251 )
252 }
253 MonotonicityError::ValidationFailed(e) => {
254 write!(f, "ontology validation failed after merge: {e}")
255 }
256 }
257 }
258}
259
260impl Ontology {
261 pub fn validate_node(
267 &self,
268 node_type: &str,
269 subtype: Option<&str>,
270 properties: &BTreeMap<String, Value>,
271 ) -> Result<(), ValidationError> {
272 let def = self
273 .node_types
274 .get(node_type)
275 .ok_or_else(|| ValidationError::UnknownNodeType(node_type.to_string()))?;
276
277 match (&def.subtypes, subtype) {
278 (Some(subtypes), Some(st)) => {
280 match subtypes.get(st) {
281 Some(st_def) => {
282 let mut merged = def.properties.clone();
284 merged.extend(st_def.properties.clone());
285 validate_properties(node_type, &merged, properties)
286 }
287 None => {
288 validate_properties(node_type, &def.properties, properties)
290 }
291 }
292 }
293 (Some(subtypes), None) => Err(ValidationError::MissingSubtype {
295 node_type: node_type.to_string(),
296 allowed: subtypes.keys().cloned().collect(),
297 }),
298 (None, Some(_st)) => validate_properties(node_type, &def.properties, properties),
300 (None, None) => validate_properties(node_type, &def.properties, properties),
302 }
303 }
304
305 pub fn validate_edge(
308 &self,
309 edge_type: &str,
310 source_node_type: &str,
311 target_node_type: &str,
312 properties: &BTreeMap<String, Value>,
313 ) -> Result<(), ValidationError> {
314 let def = self
315 .edge_types
316 .get(edge_type)
317 .ok_or_else(|| ValidationError::UnknownEdgeType(edge_type.to_string()))?;
318
319 if !def.source_types.iter().any(|t| t == source_node_type) {
320 return Err(ValidationError::InvalidSource {
321 edge_type: edge_type.to_string(),
322 node_type: source_node_type.to_string(),
323 allowed: def.source_types.clone(),
324 });
325 }
326
327 if !def.target_types.iter().any(|t| t == target_node_type) {
328 return Err(ValidationError::InvalidTarget {
329 edge_type: edge_type.to_string(),
330 node_type: target_node_type.to_string(),
331 allowed: def.target_types.clone(),
332 });
333 }
334
335 validate_properties(edge_type, &def.properties, properties)
336 }
337
338 pub fn validate_self(&self) -> Result<(), ValidationError> {
341 for (edge_name, edge_def) in &self.edge_types {
342 for src in &edge_def.source_types {
343 if !self.node_types.contains_key(src) {
344 return Err(ValidationError::InvalidSource {
345 edge_type: edge_name.clone(),
346 node_type: src.clone(),
347 allowed: self.node_types.keys().cloned().collect(),
348 });
349 }
350 }
351 for tgt in &edge_def.target_types {
352 if !self.node_types.contains_key(tgt) {
353 return Err(ValidationError::InvalidTarget {
354 edge_type: edge_name.clone(),
355 node_type: tgt.clone(),
356 allowed: self.node_types.keys().cloned().collect(),
357 });
358 }
359 }
360 }
361 Ok(())
362 }
363
364 pub fn merge_extension(&mut self, ext: &OntologyExtension) -> Result<(), MonotonicityError> {
370 for name in ext.node_types.keys() {
372 if self.node_types.contains_key(name) {
373 return Err(MonotonicityError::DuplicateNodeType(name.clone()));
374 }
375 }
376
377 for name in ext.edge_types.keys() {
379 if self.edge_types.contains_key(name) {
380 return Err(MonotonicityError::DuplicateEdgeType(name.clone()));
381 }
382 }
383
384 for (type_name, update) in &ext.node_type_updates {
386 let def = self
387 .node_types
388 .get(type_name)
389 .ok_or_else(|| MonotonicityError::UnknownNodeType(type_name.clone()))?;
390
391 for prop_name in update.add_properties.keys() {
393 if def.properties.contains_key(prop_name) {
394 return Err(MonotonicityError::DuplicateProperty {
395 type_name: type_name.clone(),
396 property: prop_name.clone(),
397 });
398 }
399 }
400
401 for prop_name in &update.relax_properties {
403 match def.properties.get(prop_name) {
404 Some(prop_def) if prop_def.required => {} Some(_) => {} None => {
407 return Err(MonotonicityError::UnknownProperty {
408 type_name: type_name.clone(),
409 property: prop_name.clone(),
410 });
411 }
412 }
413 }
414
415 if !update.add_subtypes.is_empty() {
417 if let Some(ref existing) = def.subtypes {
418 for st_name in update.add_subtypes.keys() {
419 if existing.contains_key(st_name) {
420 return Err(MonotonicityError::DuplicateProperty {
421 type_name: type_name.clone(),
422 property: format!("subtype:{st_name}"),
423 });
424 }
425 }
426 }
427 }
428 }
429
430 self.node_types.extend(ext.node_types.clone());
432
433 self.edge_types.extend(ext.edge_types.clone());
435
436 for (type_name, update) in &ext.node_type_updates {
438 let def = self.node_types.get_mut(type_name).unwrap(); def.properties.extend(update.add_properties.clone());
442
443 for prop_name in &update.relax_properties {
445 if let Some(prop_def) = def.properties.get_mut(prop_name) {
446 prop_def.required = false;
447 }
448 }
449
450 if !update.add_subtypes.is_empty() {
452 let subtypes = def.subtypes.get_or_insert_with(BTreeMap::new);
453 subtypes.extend(update.add_subtypes.clone());
454 }
455 }
456
457 self.validate_self()
459 .map_err(MonotonicityError::ValidationFailed)?;
460
461 Ok(())
462 }
463}
464
465fn validate_properties(
467 type_name: &str,
468 defs: &BTreeMap<String, PropertyDef>,
469 values: &BTreeMap<String, Value>,
470) -> Result<(), ValidationError> {
471 for (prop_name, prop_def) in defs {
473 if prop_def.required && !values.contains_key(prop_name) {
474 return Err(ValidationError::MissingRequiredProperty {
475 type_name: type_name.to_string(),
476 property: prop_name.clone(),
477 });
478 }
479 }
480
481 for (prop_name, value) in values {
483 let prop_def = match defs.get(prop_name) {
486 Some(def) => def,
487 None => continue,
488 };
489
490 if prop_def.value_type != ValueType::Any {
491 let actual_type = value_type_name(value);
492 let expected = &prop_def.value_type;
493 if !value_matches_type(value, expected) {
494 return Err(ValidationError::WrongPropertyType {
495 type_name: type_name.to_string(),
496 property: prop_name.clone(),
497 expected: expected.clone(),
498 got: actual_type.to_string(),
499 });
500 }
501 }
502 }
503
504 Ok(())
505}
506
507fn value_matches_type(value: &Value, expected: &ValueType) -> bool {
508 matches!(
509 (value, expected),
510 (Value::Null, _)
511 | (Value::String(_), ValueType::String)
512 | (Value::Int(_), ValueType::Int)
513 | (Value::Float(_), ValueType::Float)
514 | (Value::Bool(_), ValueType::Bool)
515 | (Value::List(_), ValueType::List)
516 | (Value::Map(_), ValueType::Map)
517 | (_, ValueType::Any)
518 )
519}
520
521fn value_type_name(value: &Value) -> &'static str {
522 match value {
523 Value::Null => "null",
524 Value::Bool(_) => "bool",
525 Value::Int(_) => "int",
526 Value::Float(_) => "float",
527 Value::String(_) => "string",
528 Value::List(_) => "list",
529 Value::Map(_) => "map",
530 }
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536
537 fn devops_ontology() -> Ontology {
538 Ontology {
539 node_types: BTreeMap::from([
540 (
541 "signal".into(),
542 NodeTypeDef {
543 description: Some("Something observed".into()),
544 properties: BTreeMap::from([(
545 "severity".into(),
546 PropertyDef {
547 value_type: ValueType::String,
548 required: true,
549 description: None,
550 },
551 )]),
552 subtypes: None,
553 },
554 ),
555 (
556 "entity".into(),
557 NodeTypeDef {
558 description: Some("Something that exists".into()),
559 properties: BTreeMap::from([
560 (
561 "status".into(),
562 PropertyDef {
563 value_type: ValueType::String,
564 required: false,
565 description: None,
566 },
567 ),
568 (
569 "port".into(),
570 PropertyDef {
571 value_type: ValueType::Int,
572 required: false,
573 description: None,
574 },
575 ),
576 ]),
577 subtypes: None,
578 },
579 ),
580 (
581 "rule".into(),
582 NodeTypeDef {
583 description: None,
584 properties: BTreeMap::new(),
585 subtypes: None,
586 },
587 ),
588 (
589 "action".into(),
590 NodeTypeDef {
591 description: None,
592 properties: BTreeMap::new(),
593 subtypes: None,
594 },
595 ),
596 ]),
597 edge_types: BTreeMap::from([
598 (
599 "OBSERVES".into(),
600 EdgeTypeDef {
601 description: None,
602 source_types: vec!["signal".into()],
603 target_types: vec!["entity".into()],
604 properties: BTreeMap::new(),
605 },
606 ),
607 (
608 "TRIGGERS".into(),
609 EdgeTypeDef {
610 description: None,
611 source_types: vec!["signal".into()],
612 target_types: vec!["rule".into()],
613 properties: BTreeMap::new(),
614 },
615 ),
616 (
617 "RUNS_ON".into(),
618 EdgeTypeDef {
619 description: None,
620 source_types: vec!["entity".into()],
621 target_types: vec!["entity".into()],
622 properties: BTreeMap::new(),
623 },
624 ),
625 ]),
626 }
627 }
628
629 #[test]
632 fn validate_node_valid() {
633 let ont = devops_ontology();
634 let props = BTreeMap::from([("severity".into(), Value::String("critical".into()))]);
635 assert!(ont.validate_node("signal", None, &props).is_ok());
636 }
637
638 #[test]
639 fn validate_node_unknown_type() {
640 let ont = devops_ontology();
641 let err = ont
642 .validate_node("potato", None, &BTreeMap::new())
643 .unwrap_err();
644 assert!(matches!(err, ValidationError::UnknownNodeType(t) if t == "potato"));
645 }
646
647 #[test]
648 fn validate_node_missing_required() {
649 let ont = devops_ontology();
650 let err = ont
651 .validate_node("signal", None, &BTreeMap::new())
652 .unwrap_err();
653 assert!(
654 matches!(err, ValidationError::MissingRequiredProperty { property, .. } if property == "severity")
655 );
656 }
657
658 #[test]
659 fn validate_node_wrong_type() {
660 let ont = devops_ontology();
661 let props = BTreeMap::from([("severity".into(), Value::Int(5))]);
662 let err = ont.validate_node("signal", None, &props).unwrap_err();
663 assert!(
664 matches!(err, ValidationError::WrongPropertyType { property, .. } if property == "severity")
665 );
666 }
667
668 #[test]
669 fn validate_node_unknown_property_accepted() {
670 let ont = devops_ontology();
672 let props = BTreeMap::from([
673 ("severity".into(), Value::String("warn".into())),
674 ("bogus".into(), Value::Bool(true)),
675 ]);
676 assert!(ont.validate_node("signal", None, &props).is_ok());
677 }
678
679 #[test]
680 fn validate_node_optional_property_absent() {
681 let ont = devops_ontology();
682 assert!(ont.validate_node("entity", None, &BTreeMap::new()).is_ok());
684 }
685
686 #[test]
687 fn validate_node_null_accepted_for_any_type() {
688 let ont = devops_ontology();
689 let props = BTreeMap::from([("severity".into(), Value::Null)]);
691 assert!(ont.validate_node("signal", None, &props).is_ok());
692 }
693
694 #[test]
697 fn validate_edge_valid() {
698 let ont = devops_ontology();
699 assert!(ont
700 .validate_edge("OBSERVES", "signal", "entity", &BTreeMap::new())
701 .is_ok());
702 }
703
704 #[test]
705 fn validate_edge_unknown_type() {
706 let ont = devops_ontology();
707 let err = ont
708 .validate_edge("FLIES_TO", "signal", "entity", &BTreeMap::new())
709 .unwrap_err();
710 assert!(matches!(err, ValidationError::UnknownEdgeType(t) if t == "FLIES_TO"));
711 }
712
713 #[test]
714 fn validate_edge_invalid_source() {
715 let ont = devops_ontology();
716 let err = ont
718 .validate_edge("OBSERVES", "entity", "entity", &BTreeMap::new())
719 .unwrap_err();
720 assert!(matches!(err, ValidationError::InvalidSource { .. }));
721 }
722
723 #[test]
724 fn validate_edge_invalid_target() {
725 let ont = devops_ontology();
726 let err = ont
728 .validate_edge("OBSERVES", "signal", "signal", &BTreeMap::new())
729 .unwrap_err();
730 assert!(matches!(err, ValidationError::InvalidTarget { .. }));
731 }
732
733 #[test]
736 fn validate_self_consistent() {
737 let ont = devops_ontology();
738 assert!(ont.validate_self().is_ok());
739 }
740
741 #[test]
742 fn validate_self_dangling_source() {
743 let ont = Ontology {
744 node_types: BTreeMap::from([(
745 "entity".into(),
746 NodeTypeDef {
747 description: None,
748 properties: BTreeMap::new(),
749 subtypes: None,
750 },
751 )]),
752 edge_types: BTreeMap::from([(
753 "OBSERVES".into(),
754 EdgeTypeDef {
755 description: None,
756 source_types: vec!["ghost".into()], target_types: vec!["entity".into()],
758 properties: BTreeMap::new(),
759 },
760 )]),
761 };
762 let err = ont.validate_self().unwrap_err();
763 assert!(
764 matches!(err, ValidationError::InvalidSource { node_type, .. } if node_type == "ghost")
765 );
766 }
767
768 #[test]
769 fn validate_self_dangling_target() {
770 let ont = Ontology {
771 node_types: BTreeMap::from([(
772 "signal".into(),
773 NodeTypeDef {
774 description: None,
775 properties: BTreeMap::new(),
776 subtypes: None,
777 },
778 )]),
779 edge_types: BTreeMap::from([(
780 "OBSERVES".into(),
781 EdgeTypeDef {
782 description: None,
783 source_types: vec!["signal".into()],
784 target_types: vec!["phantom".into()], properties: BTreeMap::new(),
786 },
787 )]),
788 };
789 let err = ont.validate_self().unwrap_err();
790 assert!(
791 matches!(err, ValidationError::InvalidTarget { node_type, .. } if node_type == "phantom")
792 );
793 }
794
795 #[test]
798 fn ontology_roundtrip_msgpack() {
799 let ont = devops_ontology();
800 let bytes = rmp_serde::to_vec(&ont).unwrap();
801 let decoded: Ontology = rmp_serde::from_slice(&bytes).unwrap();
802 assert_eq!(ont, decoded);
803 }
804
805 #[test]
806 fn ontology_roundtrip_json() {
807 let ont = devops_ontology();
808 let json = serde_json::to_string(&ont).unwrap();
809 let decoded: Ontology = serde_json::from_str(&json).unwrap();
810 assert_eq!(ont, decoded);
811 }
812}