//! Workflow input reference resolution (v1.2 Workflows) — pure, DB-free. //! //! A step's declared `input` is a JSON value whose string leaves may carry //! `{{ … }}` references, resolved at run time against the run's accumulated //! context: the run `input` and each prior step's `output`. //! //! Reference grammar: //! `{{ input }}` · `{{ input.a.b }}` //! `{{ steps..output }}` · `{{ steps..output.a.b }}` //! //! A string that is *exactly* a single `{{ … }}` reference is replaced by the //! referenced JSON value (type-preserving). A reference embedded in a larger //! string interpolates as text. A missing reference is a hard error (a workflow //! referencing a non-existent output is a definition bug we surface, not hide). //! //! The same reference walker backs the `when` condition evaluator //! (`workflow_expr`). use std::collections::{BTreeMap, BTreeSet}; use serde_json::{Map, Value}; use thiserror::Error; /// The run context a template/condition resolves against. #[derive(Debug, Default, Clone)] pub struct RunContext { /// The run's top-level input. pub input: Value, /// Prior step outputs, keyed by step name. pub steps: BTreeMap, } #[derive(Debug, Error, PartialEq, Eq)] pub enum TemplateError { #[error("unresolved reference `{0}` (no such input/step output)")] MissingRef(String), #[error("malformed reference `{0}`")] MalformedRef(String), #[error("reference `{0}` targets undeclared step `{1}`")] UnknownStep(String, String), } impl RunContext { /// Resolve a dotted reference path (`input.a.b` / `steps.name.output.a.b`) /// to the JSON value it points at, or `None` if absent. #[must_use] pub fn resolve_ref(&self, path: &str) -> Option<&Value> { let parts: Vec<&str> = path.split('.').collect(); let (root_val, rest): (&Value, &[&str]) = match parts.first()?.trim() { "input" => (&self.input, &parts[1..]), "steps" => { // steps..output[.deep...] let name = parts.get(1)?; if parts.get(2).map(|s| s.trim()) != Some("output") { return None; } (self.steps.get(*name)?, &parts[3..]) } _ => return None, }; let mut cur = root_val; for seg in rest { let seg = seg.trim(); cur = match cur { Value::Object(m) => m.get(seg)?, Value::Array(a) => a.get(seg.parse::().ok()?)?, _ => return None, }; } Some(cur) } } /// Resolve every `{{ … }}` reference in `value` against `ctx`. /// /// # Errors /// [`TemplateError::MissingRef`] if a reference does not resolve; /// [`TemplateError::MalformedRef`] on a syntactically-bad reference. pub fn resolve(value: &Value, ctx: &RunContext) -> Result { match value { Value::String(s) => resolve_string(s, ctx), Value::Array(a) => Ok(Value::Array( a.iter() .map(|v| resolve(v, ctx)) .collect::>()?, )), Value::Object(m) => { let mut out = Map::with_capacity(m.len()); for (k, v) in m { out.insert(k.clone(), resolve(v, ctx)?); } Ok(Value::Object(out)) } other => Ok(other.clone()), } } fn resolve_string(s: &str, ctx: &RunContext) -> Result { // Exact-single-reference: preserve the referenced value's JSON type. if let Some(inner) = exact_single_ref(s) { return ctx .resolve_ref(inner) .cloned() .ok_or_else(|| TemplateError::MissingRef(inner.to_string())); } // Otherwise interpolate references into the surrounding text. let mut out = String::with_capacity(s.len()); let mut rest = s; while let Some(open) = rest.find("{{") { out.push_str(&rest[..open]); let after = &rest[open + 2..]; let close = after .find("}}") .ok_or_else(|| TemplateError::MalformedRef(s.to_string()))?; let refpath = after[..close].trim(); let val = ctx .resolve_ref(refpath) .ok_or_else(|| TemplateError::MissingRef(refpath.to_string()))?; out.push_str(&render_scalar(val)); rest = &after[close + 2..]; } out.push_str(rest); Ok(Value::String(out)) } /// If `s` is exactly one `{{ ref }}` (ignoring surrounding whitespace and with /// no other braces), return the trimmed inner reference. fn exact_single_ref(s: &str) -> Option<&str> { let t = s.trim(); let inner = t.strip_prefix("{{")?.strip_suffix("}}")?; if inner.contains("{{") || inner.contains("}}") { return None; } Some(inner.trim()) } /// Render a JSON scalar for string interpolation (strings raw, others via JSON). fn render_scalar(v: &Value) -> String { match v { Value::String(s) => s.clone(), Value::Null => String::new(), other => other.to_string(), } } /// Apply-time validation: every reference in `value` must be syntactically valid /// and target either `input` or a declared step's `output`. Step outputs don't /// exist yet at apply time, so this checks *shape*, not resolvability. /// /// # Errors /// [`TemplateError`] on a malformed reference or one naming an undeclared step. pub fn validate(value: &Value, step_names: &BTreeSet) -> Result<(), TemplateError> { for r in collect_refs(value)? { let parts: Vec<&str> = r.split('.').map(str::trim).collect(); match parts.first().copied() { Some("input") => {} Some("steps") => { let name = parts .get(1) .filter(|s| !s.is_empty()) .ok_or_else(|| TemplateError::MalformedRef(r.clone()))?; if parts.get(2).copied() != Some("output") { return Err(TemplateError::MalformedRef(r.clone())); } if !step_names.contains(*name) { return Err(TemplateError::UnknownStep(r.clone(), (*name).to_string())); } } _ => return Err(TemplateError::MalformedRef(r.clone())), } } Ok(()) } /// Collect every `{{ … }}` reference path found anywhere in `value`. fn collect_refs(value: &Value) -> Result, TemplateError> { let mut acc = Vec::new(); collect_into(value, &mut acc)?; Ok(acc) } fn collect_into(value: &Value, acc: &mut Vec) -> Result<(), TemplateError> { match value { Value::String(s) => { let mut rest = s.as_str(); while let Some(open) = rest.find("{{") { let after = &rest[open + 2..]; let close = after .find("}}") .ok_or_else(|| TemplateError::MalformedRef(s.clone()))?; acc.push(after[..close].trim().to_string()); rest = &after[close + 2..]; } } Value::Array(a) => { for v in a { collect_into(v, acc)?; } } Value::Object(m) => { for v in m.values() { collect_into(v, acc)?; } } _ => {} } Ok(()) } #[cfg(test)] mod tests { use super::*; use serde_json::json; fn ctx() -> RunContext { let mut steps = BTreeMap::new(); steps.insert("validate".to_string(), json!({ "ok": true, "score": 7 })); steps.insert("fetch".to_string(), json!([10, 20, 30])); RunContext { input: json!({ "order": { "id": "abc", "total": 42 } }), steps, } } #[test] fn exact_single_ref_preserves_type() { let c = ctx(); assert_eq!( resolve(&json!("{{ input.order.total }}"), &c).unwrap(), json!(42) ); assert_eq!( resolve(&json!("{{ steps.validate.output.ok }}"), &c).unwrap(), json!(true) ); // whole-object ref assert_eq!( resolve(&json!("{{ steps.validate.output }}"), &c).unwrap(), json!({ "ok": true, "score": 7 }) ); } #[test] fn array_index_and_nested_object() { let c = ctx(); assert_eq!( resolve(&json!("{{ steps.fetch.output.1 }}"), &c).unwrap(), json!(20) ); assert_eq!( resolve(&json!({ "id": "{{ input.order.id }}" }), &c).unwrap(), json!({ "id": "abc" }) ); } #[test] fn interpolation_renders_text() { let c = ctx(); assert_eq!( resolve( &json!("order {{ input.order.id }} scored {{ steps.validate.output.score }}"), &c ) .unwrap(), json!("order abc scored 7") ); } #[test] fn missing_ref_is_error() { let c = ctx(); assert_eq!( resolve(&json!("{{ steps.nope.output }}"), &c).unwrap_err(), TemplateError::MissingRef("steps.nope.output".to_string()) ); assert!(matches!( resolve(&json!("{{ input.order.missing }}"), &c).unwrap_err(), TemplateError::MissingRef(_) )); } #[test] fn validate_shape_against_declared_steps() { let steps: BTreeSet = ["validate".to_string()].into_iter().collect(); // ok: input + declared step assert!(validate( &json!({ "a": "{{ input.x }}", "b": "{{ steps.validate.output.ok }}" }), &steps ) .is_ok()); // unknown step assert!(matches!( validate(&json!("{{ steps.ghost.output }}"), &steps).unwrap_err(), TemplateError::UnknownStep(_, _) )); // malformed root assert!(matches!( validate(&json!("{{ bogus.x }}"), &steps).unwrap_err(), TemplateError::MalformedRef(_) )); // steps without .output assert!(matches!( validate(&json!("{{ steps.validate.result }}"), &steps).unwrap_err(), TemplateError::MalformedRef(_) )); } #[test] fn non_template_values_pass_through() { let c = ctx(); assert_eq!(resolve(&json!(123), &c).unwrap(), json!(123)); assert_eq!(resolve(&json!(null), &c).unwrap(), json!(null)); assert_eq!(resolve(&json!("plain"), &c).unwrap(), json!("plain")); } }