Skip to main content

tempus_engine/
metadata.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4use crate::error::ValidationError;
5
6/// A rule definition with metadata for decision systems.
7///
8/// This wraps a JSON-Logic rule blob with identifying information:
9/// name, version, tags, and optional description. The metadata is
10/// carried alongside the rule for governance, audit, and traceability.
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12pub struct RuleDefinition {
13    /// Unique name identifying this rule.
14    pub name: String,
15
16    /// The JSON-Logic rule blob.
17    pub logic: Value,
18
19    /// Optional semantic version for this rule.
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub version: Option<String>,
22
23    /// Optional human-readable description.
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub description: Option<String>,
26
27    /// Tags for categorization and filtering.
28    #[serde(default, skip_serializing_if = "Vec::is_empty")]
29    pub tags: Vec<String>,
30
31    /// Context keys that must be present for this rule to execute.
32    /// Used by `validate_context` to enforce schema contracts.
33    #[serde(default, skip_serializing_if = "Vec::is_empty")]
34    pub required_context_keys: Vec<String>,
35}
36
37impl RuleDefinition {
38    /// Create a new rule definition with a name and logic.
39    pub fn new(name: impl Into<String>, logic: Value) -> Self {
40        Self {
41            name: name.into(),
42            logic,
43            version: None,
44            description: None,
45            tags: Vec::new(),
46            required_context_keys: Vec::new(),
47        }
48    }
49
50    /// Builder: set the version.
51    pub fn with_version(mut self, version: impl Into<String>) -> Self {
52        self.version = Some(version.into());
53        self
54    }
55
56    /// Builder: set the description.
57    pub fn with_description(mut self, description: impl Into<String>) -> Self {
58        self.description = Some(description.into());
59        self
60    }
61
62    /// Builder: set tags.
63    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
64        self.tags = tags;
65        self
66    }
67
68    /// Builder: declare which context keys are required.
69    pub fn with_required_keys(mut self, keys: Vec<String>) -> Self {
70        self.required_context_keys = keys;
71        self
72    }
73
74    /// Validate that a parsed JSON context object contains all required keys.
75    ///
76    /// Returns `Ok(())` when all keys are present, or a `ValidationError`
77    /// listing every missing key.
78    pub fn validate_context(&self, context: &Value) -> Result<(), ValidationError> {
79        if self.required_context_keys.is_empty() {
80            return Ok(());
81        }
82        let obj = match context.as_object() {
83            Some(o) => o,
84            None => {
85                return Err(ValidationError::new(
86                    &self.name,
87                    self.required_context_keys.clone(),
88                ))
89            }
90        };
91        let missing: Vec<String> = self
92            .required_context_keys
93            .iter()
94            .filter(|k| !obj.contains_key(k.as_str()))
95            .cloned()
96            .collect();
97        if missing.is_empty() {
98            Ok(())
99        } else {
100            Err(ValidationError::new(&self.name, missing))
101        }
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use serde_json::json;
109
110    #[test]
111    fn new_creates_minimal_definition() {
112        let rule = RuleDefinition::new("test", json!({"==": [1, 1]}));
113        assert_eq!(rule.name, "test");
114        assert_eq!(rule.version, None);
115        assert_eq!(rule.description, None);
116        assert!(rule.tags.is_empty());
117        assert!(rule.required_context_keys.is_empty());
118    }
119
120    #[test]
121    fn builder_pattern_works() {
122        let rule = RuleDefinition::new("fraud-block", json!({">":[{"var":"risk"},0.8]}))
123            .with_version("2.1.0")
124            .with_description("Block high-risk transactions")
125            .with_tags(vec!["fraud".into(), "prod".into()]);
126
127        assert_eq!(rule.name, "fraud-block");
128        assert_eq!(rule.version.as_deref(), Some("2.1.0"));
129        assert_eq!(
130            rule.description.as_deref(),
131            Some("Block high-risk transactions")
132        );
133        assert_eq!(rule.tags, vec!["fraud", "prod"]);
134    }
135
136    #[test]
137    fn serialization_roundtrip() {
138        let rule = RuleDefinition::new("test", json!({">":[{"var":"x"},1]})).with_version("1.0.0");
139
140        let json_str = serde_json::to_string(&rule).unwrap();
141        let deserialized: RuleDefinition = serde_json::from_str(&json_str).unwrap();
142        assert_eq!(rule, deserialized);
143    }
144
145    #[test]
146    fn minimal_serialization_omits_empty_fields() {
147        let rule = RuleDefinition::new("test", json!({"==": [1, 1]}));
148        let json_str = serde_json::to_string(&rule).unwrap();
149        assert!(!json_str.contains("version"));
150        assert!(!json_str.contains("description"));
151        assert!(!json_str.contains("tags"));
152    }
153
154    #[test]
155    fn validate_context_passes_when_all_keys_present() {
156        let rule = RuleDefinition::new("r", json!({}))
157            .with_required_keys(vec!["score".into(), "income".into()]);
158        let ctx = json!({"score": 700, "income": 50000});
159        assert!(rule.validate_context(&ctx).is_ok());
160    }
161
162    #[test]
163    fn validate_context_fails_on_missing_key() {
164        let rule = RuleDefinition::new("r", json!({}))
165            .with_required_keys(vec!["score".into(), "income".into()]);
166        let ctx = json!({"score": 700});
167        let err = rule.validate_context(&ctx).unwrap_err();
168        assert!(err.missing_keys.contains(&"income".to_string()));
169    }
170
171    #[test]
172    fn validate_context_passes_with_no_required_keys() {
173        let rule = RuleDefinition::new("r", json!({}));
174        assert!(rule.validate_context(&json!({})).is_ok());
175    }
176}
177