Skip to main content

af_workflow/
template.rs

1//! `$config.X` / `$instance.X` / `$event.X` template resolution.
2//!
3//! Port of `platform/templates.py::substitute_templates`. The compiler
4//! instantiates one node for many running instances, so config templates are
5//! resolved lazily per-event, not at compile time. A node calls
6//! [`resolve_config`] at the top of `process`.
7
8use serde_json::{Map, Value};
9
10use crate::event::read_path;
11
12/// Template resolution failure.
13#[derive(Debug, thiserror::Error)]
14pub enum TemplateError {
15    /// Template '' references $event but no event is in scope.
16    #[error("template '{0}' references $event but no event is in scope")]
17    EventOutOfScope(String),
18    /// Template '`token`' did not resolve (path not found).
19    #[error("template '{token}' did not resolve (path not found)")]
20    Unresolved {
21        /// The unresolved token.
22        token: String,
23    },
24}
25
26/// Resolve every `$config.` / `$instance.` / `$event.` string in `config`
27/// against the running instance. Non-template strings and non-string scalars
28/// pass through unchanged; arrays and objects are walked recursively.
29pub fn resolve_config(
30    config: &Value,
31    instance_config: &Value,
32    instance_metadata: &Value,
33    event_payload: Option<&Value>,
34) -> Result<Value, TemplateError> {
35    match config {
36        Value::String(s) => resolve_string(s, instance_config, instance_metadata, event_payload),
37        Value::Array(items) => {
38            let mut out = Vec::with_capacity(items.len());
39            for it in items {
40                out.push(resolve_config(
41                    it,
42                    instance_config,
43                    instance_metadata,
44                    event_payload,
45                )?);
46            }
47            Ok(Value::Array(out))
48        }
49        Value::Object(map) => {
50            let mut out = Map::with_capacity(map.len());
51            for (k, v) in map {
52                out.insert(
53                    k.clone(),
54                    resolve_config(v, instance_config, instance_metadata, event_payload)?,
55                );
56            }
57            Ok(Value::Object(out))
58        }
59        other => Ok(other.clone()),
60    }
61}
62
63fn resolve_string(
64    s: &str,
65    instance_config: &Value,
66    instance_metadata: &Value,
67    event_payload: Option<&Value>,
68) -> Result<Value, TemplateError> {
69    let resolve_from = |src: &Value, path: &str| -> Option<Value> { read_path(src, path).cloned() };
70
71    if let Some(path) = s.strip_prefix("$config.") {
72        resolve_from(instance_config, path).ok_or_else(|| TemplateError::Unresolved {
73            token: s.to_string(),
74        })
75    } else if let Some(path) = s.strip_prefix("$instance.") {
76        resolve_from(instance_metadata, path).ok_or_else(|| TemplateError::Unresolved {
77            token: s.to_string(),
78        })
79    } else if let Some(path) = s.strip_prefix("$event.") {
80        let payload = event_payload.ok_or_else(|| TemplateError::EventOutOfScope(s.to_string()))?;
81        resolve_from(payload, path).ok_or_else(|| TemplateError::Unresolved {
82            token: s.to_string(),
83        })
84    } else {
85        // Not a template — pass through.
86        Ok(Value::String(s.to_string()))
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn instance_tokens_never_fall_back_to_config() {
96        assert!(matches!(
97            resolve_config(
98                &Value::String("$instance.owner".into()),
99                &serde_json::json!({"owner":"legacy"}),
100                &serde_json::json!({}),
101                None,
102            ),
103            Err(TemplateError::Unresolved { .. })
104        ));
105    }
106    use serde_json::json;
107
108    #[test]
109    fn resolves_each_namespace() {
110        let cfg = json!({
111            "a": "$config.threshold",
112            "b": "$event.price",
113            "c": "literal",
114            "nested": {"d": "$instance.run_id"}
115        });
116        let out = resolve_config(
117            &cfg,
118            &json!({"threshold": 0.5}),
119            &json!({"run_id": "r1"}),
120            Some(&json!({"price": 42})),
121        )
122        .unwrap();
123        assert_eq!(
124            out,
125            json!({"a": 0.5, "b": 42, "c": "literal", "nested": {"d": "r1"}})
126        );
127    }
128
129    #[test]
130    fn event_template_without_event_errors() {
131        let cfg = json!("$event.x");
132        assert!(matches!(
133            resolve_config(&cfg, &json!({}), &json!({}), None),
134            Err(TemplateError::EventOutOfScope(_))
135        ));
136    }
137}