From 5077e9f1177baf66cbbd72ff7ca108911e846cbe Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:06:58 +0300 Subject: [PATCH] pinakes-ui: extract expression evaluation into dedicated module Signed-off-by: NotAShelf Change-Id: I4d4901c4701e8ae446dbc76b457c058d6a6a6964 --- crates/pinakes-ui/src/plugin_ui/expr.rs | 1015 +++++++++++++++++++++++ crates/pinakes-ui/src/plugin_ui/mod.rs | 1 + 2 files changed, 1016 insertions(+) create mode 100644 crates/pinakes-ui/src/plugin_ui/expr.rs diff --git a/crates/pinakes-ui/src/plugin_ui/expr.rs b/crates/pinakes-ui/src/plugin_ui/expr.rs new file mode 100644 index 0000000..055c145 --- /dev/null +++ b/crates/pinakes-ui/src/plugin_ui/expr.rs @@ -0,0 +1,1015 @@ +//! Expression evaluation for plugin UI schemas +//! +//! Provides utilities for evaluating [`Expression`] values against a JSON data +//! context. + +use pinakes_plugin_api::{Expression, Operator}; + +/// Evaluate an expression against a JSON context, returning a JSON value. +/// +/// - `Literal(v)`: returns `v` unchanged +/// - `Path("a.b.1")`: walks the context by dotted key/index segments +/// - `Operation { left, op, right }`: evaluates both sides and applies `op` +/// - `Call { function, args }`: dispatches to a built-in function +pub fn evaluate_expression( + expr: &Expression, + ctx: &serde_json::Value, +) -> serde_json::Value { + match expr { + Expression::Literal(v) => v.clone(), + Expression::Path(path) => { + get_json_path(ctx, path) + .cloned() + .unwrap_or(serde_json::Value::Null) + }, + Expression::Operation { left, op, right } => { + let l = evaluate_expression(left, ctx); + let r = evaluate_expression(right, ctx); + match op { + Operator::Eq => serde_json::Value::Bool(l == r), + Operator::Ne => serde_json::Value::Bool(l != r), + Operator::And => { + serde_json::Value::Bool( + l.as_bool().unwrap_or(false) && r.as_bool().unwrap_or(false), + ) + }, + Operator::Or => { + serde_json::Value::Bool( + l.as_bool().unwrap_or(false) || r.as_bool().unwrap_or(false), + ) + }, + Operator::Gt => { + serde_json::Value::Bool( + l.as_f64().unwrap_or(0.0) > r.as_f64().unwrap_or(0.0), + ) + }, + Operator::Gte => { + serde_json::Value::Bool( + l.as_f64().unwrap_or(0.0) >= r.as_f64().unwrap_or(0.0), + ) + }, + Operator::Lt => { + serde_json::Value::Bool( + l.as_f64().unwrap_or(0.0) < r.as_f64().unwrap_or(0.0), + ) + }, + Operator::Lte => { + serde_json::Value::Bool( + l.as_f64().unwrap_or(0.0) <= r.as_f64().unwrap_or(0.0), + ) + }, + Operator::Add => { + let result = l.as_f64().unwrap_or(0.0) + r.as_f64().unwrap_or(0.0); + serde_json::json!(result) + }, + Operator::Sub => { + let result = l.as_f64().unwrap_or(0.0) - r.as_f64().unwrap_or(0.0); + serde_json::json!(result) + }, + Operator::Mul => { + let result = l.as_f64().unwrap_or(0.0) * r.as_f64().unwrap_or(0.0); + serde_json::json!(result) + }, + Operator::Div => { + let divisor = r.as_f64().unwrap_or(0.0); + if divisor == 0.0 { + serde_json::json!(0.0) + } else { + serde_json::json!(l.as_f64().unwrap_or(0.0) / divisor) + } + }, + Operator::Concat => { + let result = format!( + "{}{}", + value_to_display_string(&l), + value_to_display_string(&r) + ); + serde_json::Value::String(result) + }, + } + }, + Expression::Call { function, args } => { + let evaluated: Vec = + args.iter().map(|a| evaluate_expression(a, ctx)).collect(); + call_builtin(function, &evaluated) + }, + } +} + +/// Dispatch a built-in function call. +/// +/// Built-in functions: +/// - `len(value)`: length of array/string/object +/// - `upper(str)`: uppercase string +/// - `lower(str)`: lowercase string +/// - `trim(str)`: trim leading/trailing whitespace +/// - `format(template, ...args)`: replace `{}` placeholders left-to-right +/// - `join(array, sep)`: join array elements with separator +/// - `contains(haystack, needle)`: true if string contains substring or array +/// contains value +/// - `keys(object)`: array of object keys +/// - `values(object)`: array of object values +/// - `abs(number)`: absolute value +/// - `round(number)`: round to nearest integer +/// - `floor(number)`: round down +/// - `ceil(number)`: round up +/// - `not(value)`: boolean negation +/// - `coalesce(a, b, ...)`: first non-null argument +/// - `to_string(value)`: convert to string +/// - `to_number(value)`: parse string to number, or pass through number +/// - `slice(array_or_string, start[, end])`: sub-array or substring +/// - `reverse(array_or_string)`: reversed array or string +/// - `if(cond, then, else)`: conditional +/// +/// Unknown function names return `null`. +fn call_builtin(name: &str, args: &[serde_json::Value]) -> serde_json::Value { + match name { + "len" => { + let v = args.first().unwrap_or(&serde_json::Value::Null); + match v { + serde_json::Value::String(s) => serde_json::json!(s.chars().count()), + serde_json::Value::Array(a) => serde_json::json!(a.len()), + serde_json::Value::Object(o) => serde_json::json!(o.len()), + _ => serde_json::json!(0), + } + }, + "upper" => { + let s = value_to_display_string( + args.first().unwrap_or(&serde_json::Value::Null), + ); + serde_json::Value::String(s.to_uppercase()) + }, + "lower" => { + let s = value_to_display_string( + args.first().unwrap_or(&serde_json::Value::Null), + ); + serde_json::Value::String(s.to_lowercase()) + }, + "trim" => { + let s = value_to_display_string( + args.first().unwrap_or(&serde_json::Value::Null), + ); + serde_json::Value::String(s.trim().to_string()) + }, + "format" => { + // Replace `{}` placeholders left-to-right with subsequent arguments + let template = value_to_display_string( + args.first().unwrap_or(&serde_json::Value::Null), + ); + let mut result = String::new(); + let mut arg_idx = 1usize; + let mut chars = template.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '{' && chars.peek() == Some(&'}') { + chars.next(); // consume '}' + let replacement = args + .get(arg_idx) + .map(value_to_display_string) + .unwrap_or_default(); + result.push_str(&replacement); + arg_idx += 1; + } else { + result.push(ch); + } + } + serde_json::Value::String(result) + }, + "join" => { + let arr = args.first().unwrap_or(&serde_json::Value::Null); + let default_sep = serde_json::Value::String(",".to_string()); + let sep = value_to_display_string(args.get(1).unwrap_or(&default_sep)); + match arr { + serde_json::Value::Array(items) => { + let joined = items + .iter() + .map(value_to_display_string) + .collect::>() + .join(&sep); + serde_json::Value::String(joined) + }, + other => serde_json::Value::String(value_to_display_string(other)), + } + }, + "contains" => { + let haystack = args.first().unwrap_or(&serde_json::Value::Null); + let needle = args.get(1).unwrap_or(&serde_json::Value::Null); + match haystack { + serde_json::Value::String(s) => { + serde_json::Value::Bool(s.contains(&value_to_display_string(needle))) + }, + serde_json::Value::Array(arr) => { + serde_json::Value::Bool(arr.contains(needle)) + }, + _ => serde_json::Value::Bool(false), + } + }, + "keys" => { + let obj = args.first().unwrap_or(&serde_json::Value::Null); + match obj { + serde_json::Value::Object(map) => { + serde_json::Value::Array( + map + .keys() + .map(|k| serde_json::Value::String(k.clone())) + .collect(), + ) + }, + _ => serde_json::Value::Array(vec![]), + } + }, + "values" => { + let obj = args.first().unwrap_or(&serde_json::Value::Null); + match obj { + serde_json::Value::Object(map) => { + serde_json::Value::Array(map.values().cloned().collect()) + }, + _ => serde_json::Value::Array(vec![]), + } + }, + "abs" => { + let n = args + .first() + .unwrap_or(&serde_json::Value::Null) + .as_f64() + .unwrap_or(0.0); + serde_json::json!(n.abs()) + }, + "round" => { + let n = args + .first() + .unwrap_or(&serde_json::Value::Null) + .as_f64() + .unwrap_or(0.0); + serde_json::json!(n.round()) + }, + "floor" => { + let n = args + .first() + .unwrap_or(&serde_json::Value::Null) + .as_f64() + .unwrap_or(0.0); + serde_json::json!(n.floor()) + }, + "ceil" => { + let n = args + .first() + .unwrap_or(&serde_json::Value::Null) + .as_f64() + .unwrap_or(0.0); + serde_json::json!(n.ceil()) + }, + "not" => { + let b = args + .first() + .unwrap_or(&serde_json::Value::Null) + .as_bool() + .unwrap_or(false); + serde_json::Value::Bool(!b) + }, + "coalesce" => { + args + .iter() + .find(|v| !v.is_null()) + .cloned() + .unwrap_or(serde_json::Value::Null) + }, + "to_string" => { + let s = value_to_display_string( + args.first().unwrap_or(&serde_json::Value::Null), + ); + serde_json::Value::String(s) + }, + "to_number" => { + let v = args.first().unwrap_or(&serde_json::Value::Null); + match v { + serde_json::Value::Number(_) => v.clone(), + serde_json::Value::String(s) => { + s.parse::() + .map_or(serde_json::Value::Null, |n| serde_json::json!(n)) + }, + serde_json::Value::Bool(b) => { + serde_json::json!(if *b { 1.0 } else { 0.0 }) + }, + _ => serde_json::Value::Null, + } + }, + "slice" => { + let target = args.first().unwrap_or(&serde_json::Value::Null); + let start = args.get(1).and_then(serde_json::Value::as_i64).unwrap_or(0); + let end_opt = args.get(2).and_then(serde_json::Value::as_i64); + match target { + serde_json::Value::Array(arr) => { + let len = i64::try_from(arr.len()).unwrap_or(i64::MAX); + let s = usize::try_from(if start < 0 { + (len + start).max(0) + } else { + start.min(len) + }) + .unwrap_or(0); + let e = end_opt.map_or(arr.len(), |e| { + usize::try_from(if e < 0 { (len + e).max(0) } else { e.min(len) }) + .unwrap_or(0) + }); + if s >= e { + serde_json::Value::Array(vec![]) + } else { + serde_json::Value::Array(arr[s..e].to_vec()) + } + }, + serde_json::Value::String(st) => { + let chars: Vec = st.chars().collect(); + let len = i64::try_from(chars.len()).unwrap_or(i64::MAX); + let s = usize::try_from(if start < 0 { + (len + start).max(0) + } else { + start.min(len) + }) + .unwrap_or(0); + let e = end_opt.map_or(chars.len(), |e| { + usize::try_from(if e < 0 { (len + e).max(0) } else { e.min(len) }) + .unwrap_or(0) + }); + if s >= e { + serde_json::Value::String(String::new()) + } else { + serde_json::Value::String(chars[s..e].iter().collect()) + } + }, + _ => serde_json::Value::Null, + } + }, + "reverse" => { + let v = args.first().unwrap_or(&serde_json::Value::Null); + match v { + serde_json::Value::Array(arr) => { + serde_json::Value::Array(arr.iter().rev().cloned().collect()) + }, + serde_json::Value::String(s) => { + serde_json::Value::String(s.chars().rev().collect()) + }, + _ => v.clone(), + } + }, + "if" => { + let cond = args + .first() + .unwrap_or(&serde_json::Value::Null) + .as_bool() + .unwrap_or(false); + if cond { + args.get(1).cloned().unwrap_or(serde_json::Value::Null) + } else { + args.get(2).cloned().unwrap_or(serde_json::Value::Null) + } + }, + _ => { + tracing::warn!(function = %name, "Unknown built-in function in Call expression"); + serde_json::Value::Null + }, + } +} + +/// Evaluate an expression and coerce the result to `bool`. +/// +/// Returns `false` if the value is not a boolean. +pub fn evaluate_expression_as_bool( + expr: &Expression, + ctx: &serde_json::Value, +) -> bool { + evaluate_expression(expr, ctx).as_bool().unwrap_or(false) +} + +/// Evaluate an expression and coerce the result to `f64`. +/// +/// Returns `0.0` if the value is not numeric. +pub fn evaluate_expression_as_f64( + expr: &Expression, + ctx: &serde_json::Value, +) -> f64 { + evaluate_expression(expr, ctx).as_f64().unwrap_or(0.0) +} + +/// Convert a JSON value to a human-readable display string. +/// +/// - `String(s)`: returns the raw string without JSON quoting +/// - `Null`: returns an empty string +/// - everything else: uses [`serde_json::Value::to_string`] (JSON encoding) +pub fn value_to_display_string(v: &serde_json::Value) -> String { + match v { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Null => String::new(), + other => other.to_string(), + } +} + +/// Get a value from JSON by dot-notation path. +/// +/// Supports nested object keys and array indices: +/// - `"user.name"` resolves to `json["user"]["name"]` +/// - `"items.0.title"` resolves to `json["items"][0]["title"]` +#[must_use] +pub fn get_json_path<'a>( + value: &'a serde_json::Value, + path: &str, +) -> Option<&'a serde_json::Value> { + let mut current = value; + for key in path.split('.') { + match current { + serde_json::Value::Object(map) => { + current = map.get(key)?; + }, + serde_json::Value::Array(arr) => { + let idx = key.parse::().ok()?; + current = arr.get(idx)?; + }, + _ => return None, + } + } + Some(current) +} + +#[cfg(test)] +mod tests { + use pinakes_plugin_api::Operator; + + use super::*; + + fn lit(v: serde_json::Value) -> Box { + Box::new(Expression::Literal(v)) + } + + fn op_expr( + left: serde_json::Value, + op: Operator, + right: serde_json::Value, + ) -> Expression { + Expression::Operation { + left: lit(left), + op, + right: lit(right), + } + } + + // value_to_display_string + + #[test] + fn test_value_to_display_string_string() { + assert_eq!( + value_to_display_string(&serde_json::json!("hello")), + "hello" + ); + } + + #[test] + fn test_value_to_display_string_null() { + assert_eq!(value_to_display_string(&serde_json::Value::Null), ""); + } + + #[test] + fn test_value_to_display_string_number() { + assert_eq!(value_to_display_string(&serde_json::json!(42)), "42"); + } + + #[test] + fn test_value_to_display_string_bool() { + assert_eq!(value_to_display_string(&serde_json::json!(true)), "true"); + } + + // arithmetic operators + + #[test] + fn test_evaluate_expression_add() { + let expr = op_expr( + serde_json::json!(3.0), + Operator::Add, + serde_json::json!(4.0), + ); + let result = evaluate_expression(&expr, &serde_json::json!({})); + assert_eq!(result.as_f64().unwrap(), 7.0); + } + + #[test] + fn test_evaluate_expression_sub() { + let expr = op_expr( + serde_json::json!(10.0), + Operator::Sub, + serde_json::json!(3.0), + ); + let result = evaluate_expression(&expr, &serde_json::json!({})); + assert_eq!(result.as_f64().unwrap(), 7.0); + } + + #[test] + fn test_evaluate_expression_mul() { + let expr = op_expr( + serde_json::json!(3.0), + Operator::Mul, + serde_json::json!(4.0), + ); + let result = evaluate_expression(&expr, &serde_json::json!({})); + assert_eq!(result.as_f64().unwrap(), 12.0); + } + + #[test] + fn test_evaluate_expression_div() { + let expr = op_expr( + serde_json::json!(10.0), + Operator::Div, + serde_json::json!(2.0), + ); + let result = evaluate_expression(&expr, &serde_json::json!({})); + assert_eq!(result.as_f64().unwrap(), 5.0); + } + + #[test] + fn test_evaluate_expression_div_by_zero() { + let expr = op_expr( + serde_json::json!(10.0), + Operator::Div, + serde_json::json!(0.0), + ); + let result = evaluate_expression(&expr, &serde_json::json!({})); + assert_eq!(result.as_f64().unwrap(), 0.0); + } + + // Concat + + #[test] + fn test_evaluate_expression_concat_strings() { + let expr = op_expr( + serde_json::json!("Hello, "), + Operator::Concat, + serde_json::json!("World!"), + ); + let result = evaluate_expression(&expr, &serde_json::json!({})); + assert_eq!(result, serde_json::json!("Hello, World!")); + } + + #[test] + fn test_evaluate_expression_concat_with_number() { + let expr = op_expr( + serde_json::json!("Count: "), + Operator::Concat, + serde_json::json!(42), + ); + let result = evaluate_expression(&expr, &serde_json::json!({})); + assert_eq!(result, serde_json::json!("Count: 42")); + } + + #[test] + fn test_evaluate_expression_concat_with_null() { + let expr = op_expr( + serde_json::json!("Value: "), + Operator::Concat, + serde_json::Value::Null, + ); + let result = evaluate_expression(&expr, &serde_json::json!({})); + assert_eq!(result, serde_json::json!("Value: ")); + } + + // call built-ins + + fn call(function: &str, args: Vec) -> serde_json::Value { + let expr = Expression::Call { + function: function.to_string(), + args: args.into_iter().map(Expression::Literal).collect(), + }; + evaluate_expression(&expr, &serde_json::json!({})) + } + + #[test] + fn test_call_len_array() { + assert_eq!( + call("len", vec![serde_json::json!([1, 2, 3])]), + serde_json::json!(3) + ); + } + + #[test] + fn test_call_len_string() { + assert_eq!( + call("len", vec![serde_json::json!("hello")]), + serde_json::json!(5) + ); + } + + #[test] + fn test_call_len_object() { + assert_eq!( + call("len", vec![serde_json::json!({"a": 1, "b": 2})]), + serde_json::json!(2) + ); + } + + #[test] + fn test_call_upper() { + assert_eq!( + call("upper", vec![serde_json::json!("hello")]), + serde_json::json!("HELLO") + ); + } + + #[test] + fn test_call_lower() { + assert_eq!( + call("lower", vec![serde_json::json!("WORLD")]), + serde_json::json!("world") + ); + } + + #[test] + fn test_call_trim() { + assert_eq!( + call("trim", vec![serde_json::json!(" hi ")]), + serde_json::json!("hi") + ); + } + + #[test] + fn test_call_format_single_placeholder() { + assert_eq!( + call("format", vec![ + serde_json::json!("Hello, {}!"), + serde_json::json!("Bob") + ]), + serde_json::json!("Hello, Bob!") + ); + } + + #[test] + fn test_call_format_multiple_placeholders() { + assert_eq!( + call("format", vec![ + serde_json::json!("{} + {} = {}"), + serde_json::json!(1), + serde_json::json!(2), + serde_json::json!(3), + ]), + serde_json::json!("1 + 2 = 3") + ); + } + + #[test] + fn test_call_format_no_placeholders() { + assert_eq!( + call("format", vec![serde_json::json!("no placeholders")]), + serde_json::json!("no placeholders") + ); + } + + #[test] + fn test_call_join() { + assert_eq!( + call("join", vec![ + serde_json::json!(["a", "b", "c"]), + serde_json::json!(", ") + ]), + serde_json::json!("a, b, c") + ); + } + + #[test] + fn test_call_join_non_array() { + assert_eq!( + call("join", vec![ + serde_json::json!("hello"), + serde_json::json!(",") + ]), + serde_json::json!("hello") + ); + } + + #[test] + fn test_call_contains_string() { + assert_eq!( + call("contains", vec![ + serde_json::json!("hello world"), + serde_json::json!("world") + ]), + serde_json::json!(true) + ); + assert_eq!( + call("contains", vec![ + serde_json::json!("hello world"), + serde_json::json!("xyz") + ]), + serde_json::json!(false) + ); + } + + #[test] + fn test_call_contains_array() { + assert_eq!( + call("contains", vec![ + serde_json::json!([1, 2, 3]), + serde_json::json!(2) + ]), + serde_json::json!(true) + ); + assert_eq!( + call("contains", vec![ + serde_json::json!([1, 2, 3]), + serde_json::json!(9) + ]), + serde_json::json!(false) + ); + } + + #[test] + fn test_call_keys() { + let result = call("keys", vec![serde_json::json!({"b": 2, "a": 1})]); + let mut keys: Vec = result + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect(); + keys.sort(); + assert_eq!(keys, vec!["a", "b"]); + } + + #[test] + fn test_call_keys_non_object() { + assert_eq!( + call("keys", vec![serde_json::json!(42)]), + serde_json::json!([]) + ); + } + + #[test] + fn test_call_values_non_object() { + assert_eq!( + call("values", vec![serde_json::json!("x")]), + serde_json::json!([]) + ); + } + + #[test] + fn test_call_abs() { + assert_eq!( + call("abs", vec![serde_json::json!(-5.0)]), + serde_json::json!(5.0) + ); + assert_eq!( + call("abs", vec![serde_json::json!(3.0)]), + serde_json::json!(3.0) + ); + } + + #[test] + fn test_call_round() { + assert_eq!( + call("round", vec![serde_json::json!(3.6)]), + serde_json::json!(4.0) + ); + assert_eq!( + call("round", vec![serde_json::json!(3.4)]), + serde_json::json!(3.0) + ); + } + + #[test] + fn test_call_floor() { + assert_eq!( + call("floor", vec![serde_json::json!(3.9)]), + serde_json::json!(3.0) + ); + } + + #[test] + fn test_call_ceil() { + assert_eq!( + call("ceil", vec![serde_json::json!(3.1)]), + serde_json::json!(4.0) + ); + } + + #[test] + fn test_call_not() { + assert_eq!( + call("not", vec![serde_json::json!(true)]), + serde_json::json!(false) + ); + assert_eq!( + call("not", vec![serde_json::json!(false)]), + serde_json::json!(true) + ); + } + + #[test] + fn test_call_coalesce() { + assert_eq!( + call("coalesce", vec![ + serde_json::Value::Null, + serde_json::json!("first"), + serde_json::json!("second"), + ]), + serde_json::json!("first") + ); + } + + #[test] + fn test_call_coalesce_all_null() { + assert_eq!( + call("coalesce", vec![ + serde_json::Value::Null, + serde_json::Value::Null + ]), + serde_json::Value::Null + ); + } + + #[test] + fn test_call_to_string() { + assert_eq!( + call("to_string", vec![serde_json::json!(42)]), + serde_json::json!("42") + ); + assert_eq!( + call("to_string", vec![serde_json::json!("hi")]), + serde_json::json!("hi") + ); + } + + #[test] + fn test_call_to_number_string() { + assert_eq!( + call("to_number", vec![serde_json::json!("3.14")]), + serde_json::json!(3.14) + ); + } + + #[test] + fn test_call_to_number_already_number() { + assert_eq!( + call("to_number", vec![serde_json::json!(42)]), + serde_json::json!(42) + ); + } + + #[test] + fn test_call_to_number_bool() { + assert_eq!( + call("to_number", vec![serde_json::json!(true)]), + serde_json::json!(1.0) + ); + assert_eq!( + call("to_number", vec![serde_json::json!(false)]), + serde_json::json!(0.0) + ); + } + + #[test] + fn test_call_to_number_invalid_string() { + assert_eq!( + call("to_number", vec![serde_json::json!("abc")]), + serde_json::Value::Null + ); + } + + #[test] + fn test_call_slice_array() { + assert_eq!( + call("slice", vec![ + serde_json::json!([1, 2, 3, 4, 5]), + serde_json::json!(1), + serde_json::json!(3) + ]), + serde_json::json!([2, 3]) + ); + } + + #[test] + fn test_call_slice_array_no_end() { + assert_eq!( + call("slice", vec![ + serde_json::json!([1, 2, 3]), + serde_json::json!(1) + ]), + serde_json::json!([2, 3]) + ); + } + + #[test] + fn test_call_slice_array_negative() { + assert_eq!( + call("slice", vec![ + serde_json::json!([1, 2, 3, 4]), + serde_json::json!(-2) + ]), + serde_json::json!([3, 4]) + ); + } + + #[test] + fn test_call_slice_string() { + assert_eq!( + call("slice", vec![ + serde_json::json!("hello"), + serde_json::json!(1), + serde_json::json!(4) + ]), + serde_json::json!("ell") + ); + } + + #[test] + fn test_call_reverse_array() { + assert_eq!( + call("reverse", vec![serde_json::json!([1, 2, 3])]), + serde_json::json!([3, 2, 1]) + ); + } + + #[test] + fn test_call_reverse_string() { + assert_eq!( + call("reverse", vec![serde_json::json!("abc")]), + serde_json::json!("cba") + ); + } + + #[test] + fn test_call_if_true() { + assert_eq!( + call("if", vec![ + serde_json::json!(true), + serde_json::json!("yes"), + serde_json::json!("no"), + ]), + serde_json::json!("yes") + ); + } + + #[test] + fn test_call_if_false() { + assert_eq!( + call("if", vec![ + serde_json::json!(false), + serde_json::json!("yes"), + serde_json::json!("no"), + ]), + serde_json::json!("no") + ); + } + + #[test] + fn test_call_unknown_returns_null() { + assert_eq!( + call("nonexistent_function", vec![]), + serde_json::Value::Null + ); + } + + #[test] + fn test_get_json_path_object() { + let data = serde_json::json!({ + "user": { "name": "John", "age": 30 } + }); + assert_eq!( + get_json_path(&data, "user.name"), + Some(&serde_json::Value::String("John".to_string())) + ); + } + + #[test] + fn test_get_json_path_array() { + let data = serde_json::json!({ "items": ["a", "b", "c"] }); + assert_eq!( + get_json_path(&data, "items.1"), + Some(&serde_json::Value::String("b".to_string())) + ); + } + + #[test] + fn test_get_json_path_invalid() { + let data = serde_json::json!({"foo": "bar"}); + assert!(get_json_path(&data, "nonexistent").is_none()); + } + + #[test] + fn test_get_json_path_array_out_of_bounds() { + let data = serde_json::json!({"items": ["a"]}); + assert!(get_json_path(&data, "items.5").is_none()); + } + + #[test] + fn test_get_json_path_non_array_index() { + let data = serde_json::json!({"foo": "bar"}); + assert!(get_json_path(&data, "foo.0").is_none()); + } + + #[test] + fn test_path_expression_uses_get_json_path() { + let ctx = serde_json::json!({"a": {"b": [1, 2, 3]}}); + let expr = Expression::Path("a.b.1".to_string()); + assert_eq!(evaluate_expression(&expr, &ctx), serde_json::json!(2)); + } + + #[test] + fn test_path_expression_missing_returns_null() { + let ctx = serde_json::json!({"a": 1}); + let expr = Expression::Path("b.c".to_string()); + assert_eq!(evaluate_expression(&expr, &ctx), serde_json::Value::Null); + } +} diff --git a/crates/pinakes-ui/src/plugin_ui/mod.rs b/crates/pinakes-ui/src/plugin_ui/mod.rs index 1684cb9..5e3016d 100644 --- a/crates/pinakes-ui/src/plugin_ui/mod.rs +++ b/crates/pinakes-ui/src/plugin_ui/mod.rs @@ -31,6 +31,7 @@ pub mod actions; pub mod data; +pub mod expr; pub mod registry; pub mod renderer; pub mod widget;