Skip to main content

tempus_engine/
lib.rs

1pub mod error;
2pub mod explain;
3pub mod metadata;
4pub mod store;
5
6// Re-export the full jsonlogic-fast public API for convenience.
7// Users of tempus-engine get evaluation "for free" without adding
8// jsonlogic-fast as a separate dependency.
9pub use jsonlogic_fast::error::{RuleEngineError, RuleEngineResult};
10pub use jsonlogic_fast::extract::{
11    extract_array, extract_bool, extract_f64, extract_object, extract_string,
12};
13pub use jsonlogic_fast::{
14    evaluate, evaluate_batch, evaluate_batch_detailed, evaluate_batch_numeric,
15    evaluate_batch_numeric_detailed, evaluate_numeric, evaluate_rule, serialize, serialize_value,
16    validate_rule, EvaluationResult, NumericEvaluationResult,
17};
18
19pub use error::ValidationError;
20pub use explain::ExplainResult;
21pub use store::RuleStore;
22
23use metadata::RuleDefinition;
24use serde_json::Value;
25
26/// Execute a rule definition (with metadata) against a JSON context.
27///
28/// This is the primary entry point for Tempus Engine. It wraps the raw
29/// jsonlogic-fast evaluation with the rule's metadata context.
30pub fn execute(rule_def: &RuleDefinition, context_json: &str) -> RuleEngineResult<Value> {
31    let rule_json = serde_json::to_string(&rule_def.logic)
32        .map_err(|e| RuleEngineError::InvalidRule(e.to_string()))?;
33    evaluate(&rule_json, context_json)
34}
35
36/// Execute a rule definition and coerce the result to f64.
37pub fn execute_numeric(rule_def: &RuleDefinition, context_json: &str) -> RuleEngineResult<f64> {
38    let rule_json = serde_json::to_string(&rule_def.logic)
39        .map_err(|e| RuleEngineError::InvalidRule(e.to_string()))?;
40    evaluate_numeric(&rule_json, context_json)
41}
42
43/// Execute a rule definition against a batch of contexts.
44pub fn execute_batch(
45    rule_def: &RuleDefinition,
46    contexts_json: &[String],
47) -> RuleEngineResult<Vec<Value>> {
48    let rule_json = serde_json::to_string(&rule_def.logic)
49        .map_err(|e| RuleEngineError::InvalidRule(e.to_string()))?;
50    evaluate_batch(&rule_json, contexts_json)
51}
52
53/// Execute a rule definition against a batch of contexts with detailed results.
54pub fn execute_batch_detailed(
55    rule_def: &RuleDefinition,
56    contexts_json: &[String],
57) -> RuleEngineResult<Vec<EvaluationResult>> {
58    let rule_json = serde_json::to_string(&rule_def.logic)
59        .map_err(|e| RuleEngineError::InvalidRule(e.to_string()))?;
60    evaluate_batch_detailed(&rule_json, contexts_json)
61}
62
63/// Execute a sequence of rules where the output of each rule is merged into
64/// the context for the next rule (`output → "decision"` key injection).
65///
66/// The final context — with `"decision"` set to the last rule's output — is
67/// returned together with the last result value. This enables decision
68/// pipelines such as:
69///
70///   1. `score-rule`  → output `"approve"` →  injected as `{"decision": "approve"}`
71///   2. `limit-rule`  reads `{"var": "decision"}` and `{"var": "amount"}` → final answer
72///
73/// Returns an error if any rule in the chain fails.
74pub fn execute_chain(
75    rules: &[RuleDefinition],
76    initial_context_json: &str,
77) -> RuleEngineResult<(Value, Value)> {
78    if rules.is_empty() {
79        return Err(RuleEngineError::InvalidRule(
80            "rule chain must contain at least one rule".to_owned(),
81        ));
82    }
83    let mut context: Value = serde_json::from_str(initial_context_json)
84        .map_err(|e| RuleEngineError::InvalidRule(format!("invalid context JSON: {e}")))?;
85
86    let mut last_result = Value::Null;
87    for rule in rules {
88        let ctx_str = serde_json::to_string(&context)
89            .map_err(|e| RuleEngineError::InvalidRule(e.to_string()))?;
90        last_result = execute(rule, &ctx_str)?;
91        // Inject the result as "decision" into the context for the next rule.
92        if let Some(obj) = context.as_object_mut() {
93            obj.insert("decision".to_owned(), last_result.clone());
94        }
95    }
96    Ok((last_result, context))
97}
98
99/// Execute a rule with full tracing metadata attached to the result.
100///
101/// Returns an `ExplainResult` that records the rule snapshot, context
102/// snapshot, final decision, and timestamp — suitable for audit logs and
103/// right-to-explanation compliance.
104pub fn execute_explain(
105    rule_def: &RuleDefinition,
106    context_json: &str,
107) -> RuleEngineResult<ExplainResult> {
108    let context_snapshot: Value = serde_json::from_str(context_json)
109        .map_err(|e| RuleEngineError::InvalidRule(format!("invalid context JSON: {e}")))?;
110    let result = execute(rule_def, context_json)?;
111    Ok(ExplainResult::new(
112        rule_def.name.clone(),
113        rule_def.version.clone(),
114        rule_def.tags.clone(),
115        rule_def.logic.clone(),
116        context_snapshot,
117        result,
118    ))
119}
120
121/// Return engine information including the underlying jsonlogic-fast version.
122pub fn get_engine_info() -> Value {
123    let core_info = jsonlogic_fast::get_core_info();
124    serde_json::json!({
125        "engine": "tempus-engine",
126        "version": env!("CARGO_PKG_VERSION"),
127        "evaluator": core_info,
128    })
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use serde_json::json;
135
136    fn sample_rule() -> RuleDefinition {
137        RuleDefinition::new(
138            "score-check",
139            json!({"if": [{">": [{"var": "score"}, 700]}, "approve", "review"]}),
140        )
141    }
142
143    #[test]
144    fn execute_returns_correct_result() {
145        let rule = sample_rule();
146        let result = execute(&rule, r#"{"score": 800}"#).unwrap();
147        assert_eq!(result, json!("approve"));
148    }
149
150    #[test]
151    fn execute_returns_review_for_low_score() {
152        let rule = sample_rule();
153        let result = execute(&rule, r#"{"score": 500}"#).unwrap();
154        assert_eq!(result, json!("review"));
155    }
156
157    #[test]
158    fn execute_numeric_works() {
159        let rule = RuleDefinition::new("fee-calc", json!({"*": [{"var": "amount"}, 0.029]}));
160        let result = execute_numeric(&rule, r#"{"amount": 1000}"#).unwrap();
161        assert!((result - 29.0).abs() < 1e-6);
162    }
163
164    #[test]
165    fn execute_batch_processes_multiple_contexts() {
166        let rule = sample_rule();
167        let contexts = vec![
168            r#"{"score": 800}"#.to_string(),
169            r#"{"score": 500}"#.to_string(),
170            r#"{"score": 742}"#.to_string(),
171        ];
172        let results = execute_batch(&rule, &contexts).unwrap();
173        assert_eq!(
174            results,
175            vec![json!("approve"), json!("review"), json!("approve")]
176        );
177    }
178
179    #[test]
180    fn execute_batch_detailed_reports_errors() {
181        let rule = sample_rule();
182        let contexts = vec![r#"{"score": 800}"#.to_string(), "{bad json}".to_string()];
183        let results = execute_batch_detailed(&rule, &contexts).unwrap();
184        assert_eq!(results[0].result, Some(json!("approve")));
185        assert!(results[1].error.is_some());
186    }
187
188    #[test]
189    fn get_engine_info_returns_version() {
190        let info = get_engine_info();
191        assert_eq!(info["engine"], "tempus-engine");
192        assert!(info["version"].as_str().is_some());
193        assert!(info["evaluator"]["engine"].as_str().is_some());
194    }
195
196    #[test]
197    fn rule_metadata_is_preserved() {
198        let mut rule = sample_rule();
199        rule.version = Some("1.2.0".to_string());
200        rule.tags = vec!["credit".to_string(), "prod".to_string()];
201
202        assert_eq!(rule.name, "score-check");
203        assert_eq!(rule.version, Some("1.2.0".to_string()));
204        assert_eq!(rule.tags.len(), 2);
205
206        // Metadata doesn't affect evaluation
207        let result = execute(&rule, r#"{"score": 800}"#).unwrap();
208        assert_eq!(result, json!("approve"));
209    }
210
211    #[test]
212    fn re_exported_evaluate_works_directly() {
213        let result = evaluate(r#"{">":[{"var":"x"},1]}"#, r#"{"x":5}"#).unwrap();
214        assert_eq!(result, json!(true));
215    }
216
217    #[test]
218    fn re_exported_validate_rule_works() {
219        assert!(validate_rule(r#"{">":[{"var":"x"},1]}"#).unwrap());
220    }
221
222    // --- execute_chain tests ---
223
224    #[test]
225    fn execute_chain_single_rule() {
226        let rules = vec![sample_rule()];
227        let (result, _ctx) = execute_chain(&rules, r#"{"score": 800}"#).unwrap();
228        assert_eq!(result, json!("approve"));
229    }
230
231    #[test]
232    fn execute_chain_pipes_decision_to_next_rule() {
233        // Rule 1: score → "approve" or "review"
234        let rule1 = RuleDefinition::new(
235            "score-gate",
236            json!({"if": [{">": [{"var": "score"}, 700]}, "approve", "review"]}),
237        );
238        // Rule 2: reads "decision" from context
239        let rule2 = RuleDefinition::new(
240            "decision-logger",
241            json!({"var": "decision"}),
242        );
243        let (result, ctx) = execute_chain(&[rule1, rule2], r#"{"score": 800}"#).unwrap();
244        assert_eq!(result, json!("approve"));
245        assert_eq!(ctx["decision"], json!("approve"));
246    }
247
248    #[test]
249    fn execute_chain_empty_returns_error() {
250        assert!(execute_chain(&[], r#"{}"#).is_err());
251    }
252
253    // --- execute_explain tests ---
254
255    #[test]
256    fn execute_explain_captures_metadata() {
257        let rule = sample_rule()
258            .with_version("1.0.0")
259            .with_tags(vec!["credit".into()]);
260        let trace = execute_explain(&rule, r#"{"score": 800}"#).unwrap();
261        assert_eq!(trace.rule_name, "score-check");
262        assert_eq!(trace.rule_version.as_deref(), Some("1.0.0"));
263        assert_eq!(trace.result, json!("approve"));
264        assert_eq!(trace.context_snapshot["score"], json!(800));
265        assert!(!trace.evaluated_at.is_empty());
266    }
267}
268