pub mod error;
pub mod explain;
pub mod metadata;
pub mod store;
pub use jsonlogic_fast::error::{RuleEngineError, RuleEngineResult};
pub use jsonlogic_fast::extract::{
extract_array, extract_bool, extract_f64, extract_object, extract_string,
};
pub use jsonlogic_fast::{
evaluate, evaluate_batch, evaluate_batch_detailed, evaluate_batch_numeric,
evaluate_batch_numeric_detailed, evaluate_numeric, evaluate_rule, serialize, serialize_value,
validate_rule, EvaluationResult, NumericEvaluationResult,
};
pub use error::ValidationError;
pub use explain::ExplainResult;
pub use store::RuleStore;
use metadata::RuleDefinition;
use serde_json::Value;
pub fn execute(rule_def: &RuleDefinition, context_json: &str) -> RuleEngineResult<Value> {
let rule_json = serde_json::to_string(&rule_def.logic)
.map_err(|e| RuleEngineError::InvalidRule(e.to_string()))?;
evaluate(&rule_json, context_json)
}
pub fn execute_numeric(rule_def: &RuleDefinition, context_json: &str) -> RuleEngineResult<f64> {
let rule_json = serde_json::to_string(&rule_def.logic)
.map_err(|e| RuleEngineError::InvalidRule(e.to_string()))?;
evaluate_numeric(&rule_json, context_json)
}
pub fn execute_batch(
rule_def: &RuleDefinition,
contexts_json: &[String],
) -> RuleEngineResult<Vec<Value>> {
let rule_json = serde_json::to_string(&rule_def.logic)
.map_err(|e| RuleEngineError::InvalidRule(e.to_string()))?;
evaluate_batch(&rule_json, contexts_json)
}
pub fn execute_batch_detailed(
rule_def: &RuleDefinition,
contexts_json: &[String],
) -> RuleEngineResult<Vec<EvaluationResult>> {
let rule_json = serde_json::to_string(&rule_def.logic)
.map_err(|e| RuleEngineError::InvalidRule(e.to_string()))?;
evaluate_batch_detailed(&rule_json, contexts_json)
}
pub fn execute_chain(
rules: &[RuleDefinition],
initial_context_json: &str,
) -> RuleEngineResult<(Value, Value)> {
if rules.is_empty() {
return Err(RuleEngineError::InvalidRule(
"rule chain must contain at least one rule".to_owned(),
));
}
let mut context: Value = serde_json::from_str(initial_context_json)
.map_err(|e| RuleEngineError::InvalidRule(format!("invalid context JSON: {e}")))?;
let mut last_result = Value::Null;
for rule in rules {
let ctx_str = serde_json::to_string(&context)
.map_err(|e| RuleEngineError::InvalidRule(e.to_string()))?;
last_result = execute(rule, &ctx_str)?;
if let Some(obj) = context.as_object_mut() {
obj.insert("decision".to_owned(), last_result.clone());
}
}
Ok((last_result, context))
}
pub fn execute_explain(
rule_def: &RuleDefinition,
context_json: &str,
) -> RuleEngineResult<ExplainResult> {
let context_snapshot: Value = serde_json::from_str(context_json)
.map_err(|e| RuleEngineError::InvalidRule(format!("invalid context JSON: {e}")))?;
let result = execute(rule_def, context_json)?;
Ok(ExplainResult::new(
rule_def.name.clone(),
rule_def.version.clone(),
rule_def.tags.clone(),
rule_def.logic.clone(),
context_snapshot,
result,
))
}
pub fn get_engine_info() -> Value {
let core_info = jsonlogic_fast::get_core_info();
serde_json::json!({
"engine": "tempus-engine",
"version": env!("CARGO_PKG_VERSION"),
"evaluator": core_info,
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn sample_rule() -> RuleDefinition {
RuleDefinition::new(
"score-check",
json!({"if": [{">": [{"var": "score"}, 700]}, "approve", "review"]}),
)
}
#[test]
fn execute_returns_correct_result() {
let rule = sample_rule();
let result = execute(&rule, r#"{"score": 800}"#).unwrap();
assert_eq!(result, json!("approve"));
}
#[test]
fn execute_returns_review_for_low_score() {
let rule = sample_rule();
let result = execute(&rule, r#"{"score": 500}"#).unwrap();
assert_eq!(result, json!("review"));
}
#[test]
fn execute_numeric_works() {
let rule = RuleDefinition::new("fee-calc", json!({"*": [{"var": "amount"}, 0.029]}));
let result = execute_numeric(&rule, r#"{"amount": 1000}"#).unwrap();
assert!((result - 29.0).abs() < 1e-6);
}
#[test]
fn execute_batch_processes_multiple_contexts() {
let rule = sample_rule();
let contexts = vec![
r#"{"score": 800}"#.to_string(),
r#"{"score": 500}"#.to_string(),
r#"{"score": 742}"#.to_string(),
];
let results = execute_batch(&rule, &contexts).unwrap();
assert_eq!(
results,
vec![json!("approve"), json!("review"), json!("approve")]
);
}
#[test]
fn execute_batch_detailed_reports_errors() {
let rule = sample_rule();
let contexts = vec![r#"{"score": 800}"#.to_string(), "{bad json}".to_string()];
let results = execute_batch_detailed(&rule, &contexts).unwrap();
assert_eq!(results[0].result, Some(json!("approve")));
assert!(results[1].error.is_some());
}
#[test]
fn get_engine_info_returns_version() {
let info = get_engine_info();
assert_eq!(info["engine"], "tempus-engine");
assert!(info["version"].as_str().is_some());
assert!(info["evaluator"]["engine"].as_str().is_some());
}
#[test]
fn rule_metadata_is_preserved() {
let mut rule = sample_rule();
rule.version = Some("1.2.0".to_string());
rule.tags = vec!["credit".to_string(), "prod".to_string()];
assert_eq!(rule.name, "score-check");
assert_eq!(rule.version, Some("1.2.0".to_string()));
assert_eq!(rule.tags.len(), 2);
let result = execute(&rule, r#"{"score": 800}"#).unwrap();
assert_eq!(result, json!("approve"));
}
#[test]
fn re_exported_evaluate_works_directly() {
let result = evaluate(r#"{">":[{"var":"x"},1]}"#, r#"{"x":5}"#).unwrap();
assert_eq!(result, json!(true));
}
#[test]
fn re_exported_validate_rule_works() {
assert!(validate_rule(r#"{">":[{"var":"x"},1]}"#).unwrap());
}
#[test]
fn execute_chain_single_rule() {
let rules = vec![sample_rule()];
let (result, _ctx) = execute_chain(&rules, r#"{"score": 800}"#).unwrap();
assert_eq!(result, json!("approve"));
}
#[test]
fn execute_chain_pipes_decision_to_next_rule() {
let rule1 = RuleDefinition::new(
"score-gate",
json!({"if": [{">": [{"var": "score"}, 700]}, "approve", "review"]}),
);
let rule2 = RuleDefinition::new(
"decision-logger",
json!({"var": "decision"}),
);
let (result, ctx) = execute_chain(&[rule1, rule2], r#"{"score": 800}"#).unwrap();
assert_eq!(result, json!("approve"));
assert_eq!(ctx["decision"], json!("approve"));
}
#[test]
fn execute_chain_empty_returns_error() {
assert!(execute_chain(&[], r#"{}"#).is_err());
}
#[test]
fn execute_explain_captures_metadata() {
let rule = sample_rule()
.with_version("1.0.0")
.with_tags(vec!["credit".into()]);
let trace = execute_explain(&rule, r#"{"score": 800}"#).unwrap();
assert_eq!(trace.rule_name, "score-check");
assert_eq!(trace.rule_version.as_deref(), Some("1.0.0"));
assert_eq!(trace.result, json!("approve"));
assert_eq!(trace.context_snapshot["score"], json!(800));
assert!(!trace.evaluated_at.is_empty());
}
}