Skip to main content

hyperi_rustlib/expression/
evaluator.rs

1// Project:   hyperi-rustlib
2// File:      src/expression/evaluator.rs
3// Purpose:   CEL expression compile / evaluate / validate wrappers
4// Language:  Rust
5//
6// License:   BUSL-1.1
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Core CEL expression operations -- compile, evaluate, validate.
10//!
11//! Wraps the [`cel`] crate (renamed from `cel-interpreter`), enforcing the
12//! DFE expression profile on every compilation path. Both Python (via
13//! `common-expression-language` PyO3 bindings) and Rust share the **same**
14//! Rust crate -- zero behavioural drift between services.
15//!
16//! # Profile Configuration
17//!
18//! When the `config` feature is enabled alongside `expression`, the profile
19//! is loaded automatically from the config cascade under the `expression`
20//! key. Applications can set overrides in their `settings.yaml`:
21//!
22//! ```yaml
23//! expression:
24//!   allow_regex: true
25//!   allow_iteration: false
26//!   allow_time: false
27//! ```
28//!
29//! Without the `config` feature (or before `config::setup()` is called),
30//! [`ProfileConfig::default()`] is used -- all restrictions active.
31//!
32//! # Usage
33//!
34//! ```rust
35//! use hyperi_rustlib::expression::{compile, evaluate, evaluate_condition, validate};
36//! use std::collections::HashMap;
37//! use serde_json::json;
38//!
39//! // Validate before storing (UI pre-submit)
40//! assert!(validate(r#"severity == "critical""#).is_empty());
41//!
42//! // One-shot evaluation
43//! let mut data = HashMap::new();
44//! data.insert("severity".into(), json!("critical"));
45//! let result = evaluate(r#"severity == "critical""#, &data).unwrap();
46//! assert_eq!(result, true.into());
47//!
48//! // Boolean condition (missing fields → false)
49//! let empty = HashMap::new();
50//! assert!(!evaluate_condition(r#"severity == "critical""#, &empty));
51//!
52//! // Compile once, evaluate many (hot path)
53//! let program = compile("amount > threshold").unwrap();
54//! // ... call program.execute(&context) per record
55//! ```
56
57use std::collections::HashMap;
58use std::sync::OnceLock;
59
60use cel::{Context, Program, Value};
61use serde_json::Value as JsonValue;
62
63use super::error::{ExpressionError, ExpressionResult};
64use super::profile::{self, ProfileConfig};
65
66/// Cached profile config -- loaded once from the config cascade or default.
67static PROFILE_CONFIG: OnceLock<ProfileConfig> = OnceLock::new();
68
69/// Get the active profile config.
70///
71/// When the `config` feature is enabled and `config::setup()` has been
72/// called, reads `expression` from the cascade. Otherwise returns
73/// `ProfileConfig::default()` (all restrictions active).
74fn get_profile_config() -> &'static ProfileConfig {
75    PROFILE_CONFIG.get_or_init(|| {
76        #[cfg(feature = "config")]
77        {
78            if let Some(cfg) = crate::config::try_get()
79                && let Ok(profile) = cfg.unmarshal_key_registered::<ProfileConfig>("expression")
80            {
81                return profile;
82            }
83        }
84        ProfileConfig::default()
85    })
86}
87
88// ── Validate ──────────────────────────────────────────────────────
89
90/// Validate an expression for syntax and DFE profile compliance.
91///
92/// Uses the profile config from the config cascade (if available) or
93/// [`ProfileConfig::default()`]. Returns a list of error strings
94/// (empty if valid).
95#[must_use]
96pub fn validate(expr: &str) -> Vec<String> {
97    validate_with_config(expr, get_profile_config())
98}
99
100/// Validate an expression with an explicit profile config.
101#[must_use]
102pub fn validate_with_config(expr: &str, config: &ProfileConfig) -> Vec<String> {
103    if expr.trim().is_empty() {
104        return vec!["Expression is empty".to_string()];
105    }
106
107    let profile_errors = profile::check_profile_with_config(expr, config);
108    if !profile_errors.is_empty() {
109        return profile_errors;
110    }
111
112    match Program::compile(expr) {
113        Ok(_) => vec![],
114        Err(e) => vec![format!("{e}")],
115    }
116}
117
118// ── Compile ───────────────────────────────────────────────────────
119
120/// Compile a CEL expression, enforcing the DFE profile.
121///
122/// Uses the profile config from the config cascade (if available).
123///
124/// # Errors
125///
126/// Returns [`ExpressionError::Validation`] if the expression violates the
127/// DFE profile, or [`ExpressionError::Compilation`] if it has a syntax error.
128pub fn compile(expr: &str) -> ExpressionResult<Program> {
129    compile_with_config(expr, get_profile_config())
130}
131
132/// Compile a CEL expression with an explicit profile config.
133///
134/// # Errors
135///
136/// Returns [`ExpressionError::Validation`] if the expression violates the
137/// DFE profile, or [`ExpressionError::Compilation`] if it has a syntax error.
138pub fn compile_with_config(expr: &str, config: &ProfileConfig) -> ExpressionResult<Program> {
139    let errors = validate_with_config(expr, config);
140    if !errors.is_empty() {
141        return Err(ExpressionError::Validation(errors));
142    }
143    Program::compile(expr).map_err(|e| ExpressionError::Compilation(format!("{e}")))
144}
145
146// ── Evaluate ──────────────────────────────────────────────────────
147
148/// Compile and evaluate a CEL expression in one step.
149///
150/// For repeated evaluation of the same expression, use [`compile`] instead.
151///
152/// # Errors
153///
154/// Returns an error if the expression is invalid, violates the DFE profile,
155/// or evaluation fails (missing fields, type mismatch).
156pub fn evaluate<'a>(
157    expr: &str,
158    data: impl IntoIterator<Item = (&'a String, &'a JsonValue)>,
159) -> ExpressionResult<Value> {
160    let program = compile(expr)?;
161    let context = build_context(data)?;
162    program
163        .execute(&context)
164        .map_err(|e| ExpressionError::Evaluation(format!("{e}")))
165}
166
167/// Build a CEL [`Context`] from any iterable of `(key, value)` pairs.
168///
169/// Accepts `&HashMap<String, Value>`, `&serde_json::Map<String, Value>`,
170/// or any other type that iterates over `(&String, &Value)`. This avoids
171/// unnecessary cloning when converting between map types.
172///
173/// Each key-value pair is added as a top-level variable in the CEL
174/// execution context. Supports all JSON types via the `cel`
175/// json feature (serde integration).
176pub fn build_context<'a>(
177    data: impl IntoIterator<Item = (&'a String, &'a JsonValue)>,
178) -> ExpressionResult<Context<'a>> {
179    let mut context = Context::default();
180    for (key, value) in data {
181        context.add_variable_from_value(key, json_to_cel(value));
182    }
183    Ok(context)
184}
185
186// ── Evaluate Condition ────────────────────────────────────────────
187
188/// Evaluate a boolean condition, returning `false` on missing fields.
189///
190/// This is the safe evaluation mode for scoring `when` conditions,
191/// alert triggers, and routing rules. If a field referenced in the
192/// expression is missing from `data`, returns `false` instead of
193/// returning an error.
194///
195/// Non-boolean results are coerced: non-zero integers are truthy,
196/// zero and errors are falsy.
197#[must_use]
198pub fn evaluate_condition<'a>(
199    expr: &str,
200    data: impl IntoIterator<Item = (&'a String, &'a JsonValue)>,
201) -> bool {
202    match evaluate(expr, data) {
203        Ok(Value::Bool(b)) => b,
204        Ok(Value::Int(n)) => n != 0,
205        Ok(Value::UInt(n)) => n != 0,
206        Ok(Value::Float(f)) => f != 0.0,
207        // Everything else (Null, String, List, Map, errors) → false
208        _ => false,
209    }
210}
211
212// ── Helpers ───────────────────────────────────────────────────────
213
214/// Convert a `serde_json::Value` to a CEL `Value`.
215fn json_to_cel(json: &JsonValue) -> Value {
216    match json {
217        JsonValue::Null => Value::Null,
218        JsonValue::Bool(b) => Value::Bool(*b),
219        JsonValue::Number(n) => {
220            if let Some(i) = n.as_i64() {
221                Value::Int(i)
222            } else if let Some(u) = n.as_u64() {
223                Value::UInt(u)
224            } else {
225                Value::Float(n.as_f64().unwrap_or(0.0))
226            }
227        }
228        JsonValue::String(s) => Value::String(s.clone().into()),
229        JsonValue::Array(arr) => {
230            Value::List(arr.iter().map(json_to_cel).collect::<Vec<_>>().into())
231        }
232        JsonValue::Object(obj) => {
233            let hash: HashMap<cel::objects::Key, Value> = obj
234                .iter()
235                .map(|(k, v)| (k.clone().into(), json_to_cel(v)))
236                .collect();
237            Value::Map(hash.into())
238        }
239    }
240}