Skip to main content

bnto_core/executor/
resolve.rs

1// Field template resolution — substitutes `{{fields.*}}` placeholders in node params.
2//
3// Called before passing params to a processor so templates like
4// `"{{fields.format}}"` become concrete values like `"mp4"`.
5
6use std::collections::BTreeMap;
7
8use serde_json::Value;
9
10/// Substitute `{{fields.*}}` placeholders in all string values within params.
11///
12/// Walks the params map and replaces occurrences of `{{fields.X}}` with the
13/// corresponding value from `field_values`. Non-string values pass through
14/// unchanged. Supports substitution inside array elements and nested strings
15/// containing multiple placeholders.
16pub fn resolve_fields(
17    params: &serde_json::Map<String, Value>,
18    field_values: &BTreeMap<String, Value>,
19) -> serde_json::Map<String, Value> {
20    params
21        .iter()
22        .map(|(k, v)| (k.clone(), resolve_value(v, field_values)))
23        .collect()
24}
25
26/// Resolve a single JSON value, recursing into arrays and objects.
27fn resolve_value(value: &Value, field_values: &BTreeMap<String, Value>) -> Value {
28    match value {
29        Value::String(s) => resolve_string(s, field_values),
30        Value::Array(arr) => {
31            Value::Array(arr.iter().map(|v| resolve_value(v, field_values)).collect())
32        }
33        Value::Object(map) => Value::Object(resolve_fields(map, field_values)),
34        other => other.clone(),
35    }
36}
37
38/// Resolve `{{fields.*}}` placeholders in a string.
39///
40/// If the entire string is a single `{{fields.X}}` placeholder and the value
41/// is a non-string type (number, boolean), returns the typed value directly
42/// to preserve JSON types. Otherwise, performs string interpolation.
43fn resolve_string(s: &str, field_values: &BTreeMap<String, Value>) -> Value {
44    // Fast path: no placeholders.
45    if !s.contains("{{fields.") {
46        return Value::String(s.to_string());
47    }
48
49    // Check if the entire string is exactly one placeholder — preserve type.
50    if let Some(key) = extract_sole_placeholder(s) {
51        if let Some(value) = field_values.get(key) {
52            return value.clone();
53        }
54        // Key not found — leave placeholder as-is.
55        return Value::String(s.to_string());
56    }
57
58    // Multiple or partial placeholders — string interpolation.
59    let mut result = s.to_string();
60    for (key, value) in field_values {
61        let placeholder = ["{{fields.", key, "}}"].concat();
62        if result.contains(&placeholder) {
63            let replacement = value_to_string(value);
64            result = result.replace(&placeholder, &replacement);
65        }
66    }
67    Value::String(result)
68}
69
70/// If the string is exactly `{{fields.X}}`, return the key `X`.
71fn extract_sole_placeholder(s: &str) -> Option<&str> {
72    let trimmed = s.trim();
73    if trimmed.starts_with("{{fields.") && trimmed.ends_with("}}") {
74        // "{{fields." is 9 chars, "}}" is 2 chars
75        let inner = &trimmed[9..trimmed.len() - 2];
76        // Only a sole placeholder if there's no nested braces.
77        if !inner.contains('{') && !inner.contains('}') {
78            return Some(inner);
79        }
80    }
81    None
82}
83
84/// Convert a JSON value to a string for interpolation.
85fn value_to_string(value: &Value) -> String {
86    match value {
87        Value::String(s) => s.clone(),
88        Value::Number(n) => n.to_string(),
89        Value::Bool(b) => b.to_string(),
90        Value::Null => String::new(),
91        other => other.to_string(),
92    }
93}
94
95/// Collect field values from field definitions and user overrides.
96///
97/// User overrides take precedence over defaults. Returns a map of
98/// field name → resolved value.
99pub fn collect_field_values(
100    field_defs: &BTreeMap<String, crate::field_def::FieldDef>,
101    overrides: &BTreeMap<String, Value>,
102) -> BTreeMap<String, Value> {
103    let mut values = BTreeMap::new();
104    for (name, def) in field_defs {
105        if let Some(override_val) = overrides.get(name) {
106            values.insert(name.clone(), override_val.clone());
107        } else {
108            let default = def.default_value();
109            if !default.is_null() {
110                values.insert(name.clone(), default);
111            }
112        }
113    }
114    values
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use serde_json::json;
121
122    fn make_params(pairs: &[(&str, Value)]) -> serde_json::Map<String, Value> {
123        pairs
124            .iter()
125            .map(|(k, v)| (k.to_string(), v.clone()))
126            .collect()
127    }
128
129    fn make_fields(pairs: &[(&str, Value)]) -> BTreeMap<String, Value> {
130        pairs
131            .iter()
132            .map(|(k, v)| (k.to_string(), v.clone()))
133            .collect()
134    }
135
136    #[test]
137    fn simple_string_substitution() {
138        let params = make_params(&[("format", json!("{{fields.format}}"))]);
139        let fields = make_fields(&[("format", json!("mp4"))]);
140        let resolved = resolve_fields(&params, &fields);
141        assert_eq!(resolved["format"], json!("mp4"));
142    }
143
144    #[test]
145    fn substitution_inside_array() {
146        let params = make_params(&[(
147            "args",
148            json!(["--format", "{{fields.format}}", "-o", "out"]),
149        )]);
150        let fields = make_fields(&[("format", json!("webm"))]);
151        let resolved = resolve_fields(&params, &fields);
152        assert_eq!(resolved["args"], json!(["--format", "webm", "-o", "out"]));
153    }
154
155    #[test]
156    fn multiple_placeholders_in_one_string() {
157        let params = make_params(&[(
158            "selector",
159            json!("vcodec:{{fields.videoCodec}},acodec:{{fields.audioCodec}}"),
160        )]);
161        let fields = make_fields(&[("videoCodec", json!("h264")), ("audioCodec", json!("m4a"))]);
162        let resolved = resolve_fields(&params, &fields);
163        assert_eq!(resolved["selector"], json!("vcodec:h264,acodec:m4a"));
164    }
165
166    #[test]
167    fn non_string_values_pass_through() {
168        let params = make_params(&[
169            ("quality", json!(80)),
170            ("enabled", json!(true)),
171            ("nothing", json!(null)),
172        ]);
173        let fields = make_fields(&[]);
174        let resolved = resolve_fields(&params, &fields);
175        assert_eq!(resolved["quality"], json!(80));
176        assert_eq!(resolved["enabled"], json!(true));
177        assert_eq!(resolved["nothing"], json!(null));
178    }
179
180    #[test]
181    fn missing_field_leaves_placeholder() {
182        let params = make_params(&[("x", json!("{{fields.missing}}"))]);
183        let fields = make_fields(&[]);
184        let resolved = resolve_fields(&params, &fields);
185        assert_eq!(resolved["x"], json!("{{fields.missing}}"));
186    }
187
188    #[test]
189    fn sole_placeholder_preserves_number_type() {
190        let params = make_params(&[("quality", json!("{{fields.quality}}"))]);
191        let fields = make_fields(&[("quality", json!(80))]);
192        let resolved = resolve_fields(&params, &fields);
193        assert_eq!(resolved["quality"], json!(80));
194        assert!(resolved["quality"].is_number());
195    }
196
197    #[test]
198    fn sole_placeholder_preserves_boolean_type() {
199        let params = make_params(&[("strip", json!("{{fields.strip}}"))]);
200        let fields = make_fields(&[("strip", json!(true))]);
201        let resolved = resolve_fields(&params, &fields);
202        assert_eq!(resolved["strip"], json!(true));
203        assert!(resolved["strip"].is_boolean());
204    }
205
206    #[test]
207    fn number_field_stringified_in_interpolation() {
208        let params = make_params(&[("label", json!("Quality: {{fields.quality}}%"))]);
209        let fields = make_fields(&[("quality", json!(80))]);
210        let resolved = resolve_fields(&params, &fields);
211        assert_eq!(resolved["label"], json!("Quality: 80%"));
212    }
213
214    #[test]
215    fn no_placeholder_passes_through() {
216        let params = make_params(&[("command", json!("yt-dlp"))]);
217        let fields = make_fields(&[("format", json!("mp4"))]);
218        let resolved = resolve_fields(&params, &fields);
219        assert_eq!(resolved["command"], json!("yt-dlp"));
220    }
221
222    #[test]
223    fn collect_field_values_override_beats_default() {
224        let mut defs = BTreeMap::new();
225        defs.insert(
226            "format".into(),
227            crate::field_def::FieldDef::Enum {
228                label: "Format".into(),
229                options: vec![],
230                description: None,
231                default: Some("mp4".into()),
232                order: None,
233            },
234        );
235        let mut overrides = BTreeMap::new();
236        overrides.insert("format".into(), json!("webm"));
237
238        let values = collect_field_values(&defs, &overrides);
239        assert_eq!(values["format"], json!("webm"));
240    }
241
242    #[test]
243    fn collect_field_values_default_used_when_no_override() {
244        let mut defs = BTreeMap::new();
245        defs.insert(
246            "format".into(),
247            crate::field_def::FieldDef::Enum {
248                label: "Format".into(),
249                options: vec![],
250                description: None,
251                default: Some("mp4".into()),
252                order: None,
253            },
254        );
255        let overrides = BTreeMap::new();
256
257        let values = collect_field_values(&defs, &overrides);
258        assert_eq!(values["format"], json!("mp4"));
259    }
260
261    #[test]
262    fn collect_field_values_no_default_no_override() {
263        let mut defs = BTreeMap::new();
264        defs.insert(
265            "name".into(),
266            crate::field_def::FieldDef::String {
267                label: "Name".into(),
268                description: None,
269                default: None,
270                placeholder: None,
271                order: None,
272            },
273        );
274        let overrides = BTreeMap::new();
275
276        let values = collect_field_values(&defs, &overrides);
277        assert!(!values.contains_key("name"));
278    }
279
280    #[test]
281    fn nested_object_substitution() {
282        let params = make_params(&[("config", json!({"nested": "{{fields.x}}"}))]);
283        let fields = make_fields(&[("x", json!("resolved"))]);
284        let resolved = resolve_fields(&params, &fields);
285        assert_eq!(resolved["config"]["nested"], json!("resolved"));
286    }
287}