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