Skip to main content

cruxx_script/
expr.rs

1/// Minimal expression evaluator for `{{ path }}` references in YAML values.
2///
3/// Supports: `{{ input }}`, `{{ steps.<name>.output }}`, `{{ steps.<name>.confidence }}`.
4use serde_json::Value;
5use std::collections::HashMap;
6
7/// Result of a completed step, used for expression resolution.
8///
9/// `confidence` is `None` for steps that produced no score (e.g. `handler_value` handlers).
10/// Routing steps (`route_on_confidence`) that reference such a step will receive an
11/// [`ExprError::NoConfidence`] rather than a spurious `1.0`.
12#[derive(Debug, Clone)]
13pub struct StepResult {
14    pub output: Value,
15    pub confidence: Option<f32>,
16}
17
18/// Evaluation context holding pipeline state.
19pub struct ExprContext {
20    pub input: Value,
21    pub steps: HashMap<String, StepResult>,
22}
23
24impl ExprContext {
25    pub fn new(input: Value) -> Self {
26        Self {
27            input,
28            steps: HashMap::new(),
29        }
30    }
31
32    /// Resolve an expression string to a JSON value.
33    ///
34    /// - `{{ path }}` (entire string) — resolves to the typed JSON value at path.
35    /// - Strings containing one or more `{{ path }}` snippets — interpolated: each
36    ///   snippet is replaced with its string representation; result is a JSON string.
37    /// - Plain strings with no `{{ }}` — returned unchanged as a JSON string.
38    pub fn eval(&self, expr: &str) -> Result<Value, ExprError> {
39        let trimmed = expr.trim();
40
41        // Fast path: entire string is a single template — return typed value.
42        if let Some(inner) = trimmed
43            .strip_prefix("{{")
44            .and_then(|s| s.strip_suffix("}}"))
45        {
46            return self.resolve_path(inner.trim());
47        }
48
49        // No template markers at all — return as-is.
50        if !expr.contains("{{") {
51            return Ok(Value::String(expr.to_string()));
52        }
53
54        // Interpolation: replace every `{{ path }}` occurrence within the string.
55        let mut result = String::with_capacity(expr.len());
56        let mut remaining = expr;
57        while let Some(start) = remaining.find("{{") {
58            result.push_str(&remaining[..start]);
59            let after_open = &remaining[start + 2..];
60            let end = after_open
61                .find("}}")
62                .ok_or_else(|| ExprError::Syntax(expr.to_string()))?;
63            let path = after_open[..end].trim();
64            let value = self.resolve_path(path)?;
65            match value {
66                Value::String(s) => result.push_str(&s),
67                other => result.push_str(&other.to_string()),
68            }
69            remaining = &after_open[end + 2..];
70        }
71        result.push_str(remaining);
72        Ok(Value::String(result))
73    }
74
75    /// Resolve an expression to f32 (for confidence routing).
76    pub fn eval_f32(&self, expr: &str) -> Result<f32, ExprError> {
77        let value = self.eval(expr)?;
78        match value {
79            Value::Number(n) => n.as_f64().map(|f| f as f32).ok_or(ExprError::NotNumeric),
80            _ => Err(ExprError::NotNumeric),
81        }
82    }
83
84    fn resolve_path(&self, path: &str) -> Result<Value, ExprError> {
85        if path == "input" {
86            return Ok(self.input.clone());
87        }
88
89        // `input.<field>.<subfield>...` — dot-path into the pipeline input
90        if let Some(rest) = path.strip_prefix("input.") {
91            return json_get(&self.input, rest)
92                .ok_or_else(|| ExprError::UnknownPath(path.to_string()));
93        }
94
95        let parts: Vec<&str> = path.splitn(3, '.').collect();
96        match parts.as_slice() {
97            // `steps.<name>.output` — full output value
98            ["steps", name, "output"] => self
99                .steps
100                .get(*name)
101                .map(|r| r.output.clone())
102                .ok_or_else(|| ExprError::UnknownStep((*name).to_string())),
103            ["steps", name, "confidence"] => {
104                let result = self
105                    .steps
106                    .get(*name)
107                    .ok_or_else(|| ExprError::UnknownStep((*name).to_string()))?;
108                let score = result
109                    .confidence
110                    .ok_or_else(|| ExprError::NoConfidence((*name).to_string()))?;
111                Ok(Value::Number(
112                    // safe: confidence is always finite (NaN rejected in HandlerOutput::with_confidence)
113                    serde_json::Number::from_f64(score as f64)
114                        .expect("confidence is always finite"),
115                ))
116            }
117            // `steps.<name>.output.<field>...` — dot-path into a step's output
118            ["steps", name, rest] if rest.starts_with("output.") => {
119                let step = self
120                    .steps
121                    .get(*name)
122                    .ok_or_else(|| ExprError::UnknownStep((*name).to_string()))?;
123                let field_path = rest.strip_prefix("output.").unwrap();
124                json_get(&step.output, field_path)
125                    .ok_or_else(|| ExprError::UnknownPath(path.to_string()))
126            }
127            _ => Err(ExprError::UnknownPath(path.to_string())),
128        }
129    }
130}
131
132/// Walk a dot-separated key path into a JSON value, returning the nested value if found.
133fn json_get(value: &Value, path: &str) -> Option<Value> {
134    let mut current = value;
135    let mut owned;
136    for key in path.split('.') {
137        match current.get(key) {
138            Some(v) => {
139                owned = v.clone();
140                current = &owned;
141            }
142            None => return None,
143        }
144    }
145    Some(current.clone())
146}
147
148#[derive(Debug, thiserror::Error)]
149pub enum ExprError {
150    #[error("syntax error in expression: {0}")]
151    Syntax(String),
152    #[error("unknown step: {0}")]
153    UnknownStep(String),
154    #[error("unknown path: {0}")]
155    UnknownPath(String),
156    #[error("value is not numeric")]
157    NotNumeric,
158    /// Step exists but produced no confidence score (e.g. a `handler_value` handler).
159    /// Using such a step as input to `route_on_confidence` is a pipeline authoring error.
160    #[error("step '{0}' produced no confidence score — use a handler that emits confidence")]
161    NoConfidence(String),
162}