1use std::collections::BTreeMap;
7
8use serde_json::Value;
9
10pub 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
26fn 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
38fn resolve_string(s: &str, field_values: &BTreeMap<String, Value>) -> Value {
44 if !s.contains("{{fields.") {
46 return Value::String(s.to_string());
47 }
48
49 if let Some(key) = extract_sole_placeholder(s) {
51 if let Some(value) = field_values.get(key) {
52 return value.clone();
53 }
54 return Value::String(s.to_string());
56 }
57
58 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
70fn extract_sole_placeholder(s: &str) -> Option<&str> {
72 let trimmed = s.trim();
73 if trimmed.starts_with("{{fields.") && trimmed.ends_with("}}") {
74 let inner = &trimmed[9..trimmed.len() - 2];
76 if !inner.contains('{') && !inner.contains('}') {
78 return Some(inner);
79 }
80 }
81 None
82}
83
84fn 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
95pub 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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &fields);
285 assert_eq!(resolved["config"]["nested"], json!("resolved"));
286 }
287}