1use serde_json::{Map, Value};
9
10use crate::event::read_path;
11
12#[derive(Debug, thiserror::Error)]
14pub enum TemplateError {
15 #[error("template '{0}' references $event but no event is in scope")]
17 EventOutOfScope(String),
18 #[error("template '{token}' did not resolve (path not found)")]
20 Unresolved {
21 token: String,
23 },
24}
25
26pub 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 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}