Skip to main content

bnto_core/executor/
template.rs

1// Multi-namespace template resolution — extends {{fields.*}} with env, ctx, node, and item namespaces.
2//
3// Entry point: `resolve_templates()` walks params and substitutes placeholders from
4// all namespaces. Falls back to `resolve_fields()` for the {{fields.*}} namespace,
5// then handles {{env.*}}, {{ctx.*}}, {{node.<id>.*}}, and {{item.*}} additionally.
6
7use std::collections::BTreeMap;
8
9use serde_json::Value;
10
11use crate::context::ProcessContext;
12
13/// All template namespace sources bundled for resolution.
14pub struct TemplateContext<'a> {
15    /// Field values from node field definitions + user overrides.
16    pub field_values: &'a BTreeMap<String, Value>,
17    /// System access for env vars and working directory.
18    pub process_ctx: &'a dyn ProcessContext,
19    /// Per-node output params from previously executed nodes.
20    pub node_outputs: &'a BTreeMap<String, Value>,
21    /// Current loop iteration's per-file metadata for {{item.*}} templates.
22    pub loop_item: &'a Option<serde_json::Map<String, Value>>,
23}
24
25/// Resolve all template namespaces in params: fields, env, ctx, node, item.
26///
27/// Walks the params map and replaces placeholders from all namespaces.
28/// Namespace priority: {{fields.*}} first, then {{env.*}}, {{ctx.*}}, {{node.*}}, {{item.*}}.
29pub fn resolve_templates(
30    params: &serde_json::Map<String, Value>,
31    ctx: &TemplateContext,
32) -> serde_json::Map<String, Value> {
33    params
34        .iter()
35        .map(|(k, v)| (k.clone(), resolve_value(v, ctx)))
36        .collect()
37}
38
39/// Resolve a single JSON value, recursing into arrays and objects.
40fn resolve_value(value: &Value, ctx: &TemplateContext) -> Value {
41    match value {
42        Value::String(s) => resolve_string(s, ctx),
43        Value::Array(arr) => Value::Array(arr.iter().map(|v| resolve_value(v, ctx)).collect()),
44        Value::Object(map) => Value::Object(resolve_templates(map, ctx)),
45        other => other.clone(),
46    }
47}
48
49/// Check if a string contains any template placeholder.
50fn has_placeholder(s: &str) -> bool {
51    s.contains("{{fields.")
52        || s.contains("{{env.")
53        || s.contains("{{ctx.")
54        || s.contains("{{node.")
55        || s.contains("{{item.")
56}
57
58/// If the string is exactly one `{{namespace.key}}` placeholder, return (prefix, key).
59/// prefix is "fields", "env", "ctx", or the full "node.<id>" part.
60fn extract_sole_placeholder(s: &str) -> Option<(&str, &str)> {
61    let trimmed = s.trim();
62    if !trimmed.starts_with("{{") || !trimmed.ends_with("}}") {
63        return None;
64    }
65    let inner = &trimmed[2..trimmed.len() - 2];
66    // Must have no nested braces.
67    if inner.contains('{') || inner.contains('}') {
68        return None;
69    }
70    // Split on first dot: "fields.format" → ("fields", "format")
71    let (prefix, key) = inner.split_once('.')?;
72    // For node refs, prefix is "node" but we need to split further:
73    // "node.compress.format" → prefix="node", rest="compress.format"
74    // We return ("node.compress", "format") so the caller can extract node_id.
75    if prefix == "node" {
76        // key is "compress.format" — split on last dot for property
77        let (node_id, prop) = key.rsplit_once('.')?;
78        return Some((&inner[..5 + node_id.len()], prop)); // "node.compress"
79    }
80    // "item" is a simple namespace like "fields" — "item.url" → ("item", "url")
81    Some((prefix, key))
82}
83
84/// Resolve a single placeholder key from the appropriate namespace.
85fn resolve_placeholder(prefix: &str, key: &str, ctx: &TemplateContext) -> Option<Value> {
86    match prefix {
87        "fields" => ctx.field_values.get(key).cloned(),
88        "env" => {
89            let val = ctx.process_ctx.env_var(key).unwrap_or_default();
90            Some(Value::String(val))
91        }
92        "ctx" => resolve_ctx(key, ctx.process_ctx),
93        "item" => ctx.loop_item.as_ref().and_then(|m| m.get(key).cloned()),
94        p if p.starts_with("node.") => {
95            let node_id = &p[5..]; // strip "node."
96            resolve_node_ref(node_id, key, ctx.node_outputs)
97        }
98        _ => None,
99    }
100}
101
102/// Resolve {{ctx.*}} properties from ProcessContext.
103///
104/// Path-related variables live under the `paths.*` sub-namespace:
105///   - `paths.home_dir`   → ~/.bnto/
106///   - `paths.output_dir` → ~/.bnto/output/
107///   - `paths.work_dir`   → current working directory
108///   - `paths.temp_dir`   → system temp directory
109///
110/// Old flat names (`work_dir`, `temp_dir`) are kept as backward-compat aliases.
111fn resolve_ctx(key: &str, process_ctx: &dyn ProcessContext) -> Option<Value> {
112    match key {
113        // --- Paths namespace ---
114        "paths.home_dir" => process_ctx
115            .home_dir()
116            .map(|p| Value::String(p.to_string_lossy().into_owned())),
117        "paths.output_dir" => process_ctx
118            .output_dir()
119            .map(|p| Value::String(p.to_string_lossy().into_owned())),
120        "paths.work_dir" | "work_dir" => process_ctx
121            .work_dir()
122            .ok()
123            .map(|p| Value::String(p.to_string_lossy().into_owned())),
124        "paths.temp_dir" | "temp_dir" => {
125            let dir = std::env::temp_dir();
126            Some(Value::String(dir.to_string_lossy().into_owned()))
127        }
128        // --- Flat namespace ---
129        "platform" => Some(Value::String(current_platform().to_string())),
130        "date" | "time" | "timestamp" => {
131            let (y, m, d, hh, mm, ss) = now_civil();
132            let val = match key {
133                "date" => format!("{y:04}-{m:02}-{d:02}"),
134                "time" => format!("{hh:02}-{mm:02}-{ss:02}"),
135                "timestamp" => format!("{y:04}{m:02}{d:02}-{hh:02}{mm:02}{ss:02}"),
136                _ => unreachable!(),
137            };
138            Some(Value::String(val))
139        }
140        _ => None, // Unknown ctx property — leave as-is
141    }
142}
143
144/// Convert current wall-clock time to civil (year, month, day, hour, min, sec).
145///
146/// Uses Howard Hinnant's civil calendar algorithm on the Unix epoch.
147/// No `chrono` dependency — pure arithmetic. `SystemTime::now()` works in
148/// WASM (backed by `Date.now()`).
149fn now_civil() -> (i32, u32, u32, u32, u32, u32) {
150    use std::time::{SystemTime, UNIX_EPOCH};
151
152    let secs = SystemTime::now()
153        .duration_since(UNIX_EPOCH)
154        .unwrap_or_default()
155        .as_secs();
156
157    epoch_to_civil(secs)
158}
159
160/// Convert Unix epoch seconds to (year, month, day, hour, minute, second) in UTC.
161///
162/// Hinnant's algorithm: http://howardhinnant.github.io/date_algorithms.html
163fn epoch_to_civil(epoch_secs: u64) -> (i32, u32, u32, u32, u32, u32) {
164    let total_secs = epoch_secs;
165    let day_secs = (total_secs % 86400) as u32;
166    let hh = day_secs / 3600;
167    let mm = (day_secs % 3600) / 60;
168    let ss = day_secs % 60;
169
170    // Days since epoch, shifted so day 0 = 0000-03-01
171    let z = (total_secs / 86400) as i64 + 719468;
172    let era = if z >= 0 { z } else { z - 146096 } / 146097;
173    let doe = (z - era * 146097) as u32; // day of era [0, 146096]
174    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
175    let y = yoe as i64 + era * 400;
176    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
177    let mp = (5 * doy + 2) / 153;
178    let d = doy - (153 * mp + 2) / 5 + 1;
179    let m = if mp < 10 { mp + 3 } else { mp - 9 };
180    let y = if m <= 2 { y + 1 } else { y };
181
182    (y as i32, m, d, hh, mm, ss)
183}
184
185/// Resolve only `{{ctx.*}}` variables in a string.
186///
187/// The environment (CLI, TUI) calls this to expand template variables in
188/// the output directory path before deciding where to write files.
189/// Only resolves ctx namespace — ignores fields, env, item, node placeholders.
190pub fn resolve_ctx_templates(s: &str, ctx: &dyn ProcessContext) -> String {
191    if !s.contains("{{ctx.") {
192        return s.to_string();
193    }
194    let mut result = s.to_string();
195    resolve_ctx_interpolation(&mut result, ctx);
196    result
197}
198
199/// Returns the current platform name.
200fn current_platform() -> &'static str {
201    if cfg!(target_os = "macos") {
202        "macos"
203    } else if cfg!(target_os = "linux") {
204        "linux"
205    } else if cfg!(target_os = "windows") {
206        "windows"
207    } else {
208        "unknown"
209    }
210}
211
212/// Resolve {{node.<id>.<prop>}} from previously executed node outputs.
213fn resolve_node_ref(
214    node_id: &str,
215    prop: &str,
216    node_outputs: &BTreeMap<String, Value>,
217) -> Option<Value> {
218    let output = node_outputs.get(node_id)?;
219    match output {
220        Value::Object(map) => map.get(prop).cloned(),
221        _ => None,
222    }
223}
224
225/// Convert a JSON value to a string for interpolation.
226fn value_to_string(value: &Value) -> String {
227    match value {
228        Value::String(s) => s.clone(),
229        Value::Number(n) => n.to_string(),
230        Value::Bool(b) => b.to_string(),
231        Value::Null => String::new(),
232        other => other.to_string(),
233    }
234}
235
236/// Resolve all template placeholders in a string.
237///
238/// Sole placeholder (entire string is one `{{ns.key}}`) preserves the original
239/// JSON type. Partial or multiple placeholders use string interpolation.
240fn resolve_string(s: &str, ctx: &TemplateContext) -> Value {
241    if !has_placeholder(s) {
242        return Value::String(s.to_string());
243    }
244
245    // Sole placeholder — preserve type.
246    if let Some((prefix, key)) = extract_sole_placeholder(s) {
247        if let Some(value) = resolve_placeholder(prefix, key, ctx) {
248            return value;
249        }
250        // Not resolved — leave placeholder as-is.
251        return Value::String(s.to_string());
252    }
253
254    // Multiple or partial placeholders — string interpolation.
255    let mut result = s.to_string();
256
257    // Resolve {{fields.*}}
258    for (key, value) in ctx.field_values {
259        let placeholder = format!("{{{{fields.{key}}}}}");
260        if result.contains(&placeholder) {
261            result = result.replace(&placeholder, &value_to_string(value));
262        }
263    }
264
265    // Resolve {{env.*}} — scan for remaining env placeholders.
266    resolve_env_interpolation(&mut result, ctx.process_ctx);
267
268    // Resolve {{ctx.*}}
269    resolve_ctx_interpolation(&mut result, ctx.process_ctx);
270
271    // Resolve {{item.*}}
272    resolve_item_interpolation(&mut result, ctx.loop_item);
273
274    // Resolve {{node.*}}
275    resolve_node_interpolation(&mut result, ctx.node_outputs);
276
277    Value::String(result)
278}
279
280/// Replace all `{{env.KEY}}` occurrences in-place via string interpolation.
281fn resolve_env_interpolation(result: &mut String, process_ctx: &dyn ProcessContext) {
282    while let Some(start) = result.find("{{env.") {
283        let rest = &result[start + 6..];
284        let Some(end) = rest.find("}}") else { break };
285        let key = &result[start + 6..start + 6 + end];
286        let val = process_ctx.env_var(key).unwrap_or_default();
287        let placeholder = format!("{{{{env.{key}}}}}");
288        *result = result.replace(&placeholder, &val);
289    }
290}
291
292/// Replace all `{{ctx.KEY}}` occurrences in-place via string interpolation.
293fn resolve_ctx_interpolation(result: &mut String, process_ctx: &dyn ProcessContext) {
294    while let Some(start) = result.find("{{ctx.") {
295        let rest = &result[start + 6..];
296        let Some(end) = rest.find("}}") else { break };
297        let key = &result[start + 6..start + 6 + end];
298        let val = resolve_ctx(key, process_ctx)
299            .map(|v| value_to_string(&v))
300            .unwrap_or_default();
301        let placeholder = format!("{{{{ctx.{key}}}}}");
302        *result = result.replace(&placeholder, &val);
303    }
304}
305
306/// Replace all `{{item.KEY}}` occurrences in-place via string interpolation.
307fn resolve_item_interpolation(
308    result: &mut String,
309    loop_item: &Option<serde_json::Map<String, Value>>,
310) {
311    let Some(item) = loop_item.as_ref() else {
312        return;
313    };
314    while let Some(start) = result.find("{{item.") {
315        let rest = &result[start + 7..];
316        let Some(end) = rest.find("}}") else { break };
317        let key = &result[start + 7..start + 7 + end];
318        let val = item.get(key).map(value_to_string).unwrap_or_default();
319        let placeholder = format!("{{{{item.{key}}}}}");
320        *result = result.replace(&placeholder, &val);
321    }
322}
323
324/// Replace all `{{node.<id>.<prop>}}` occurrences in-place via string interpolation.
325fn resolve_node_interpolation(result: &mut String, node_outputs: &BTreeMap<String, Value>) {
326    while let Some(start) = result.find("{{node.") {
327        let rest = &result[start + 7..];
328        let Some(end) = rest.find("}}") else { break };
329        let ref_str = &result[start + 7..start + 7 + end]; // "compress.format"
330        let Some((node_id, prop)) = ref_str.rsplit_once('.') else {
331            break;
332        };
333        let val = resolve_node_ref(node_id, prop, node_outputs)
334            .map(|v| value_to_string(&v))
335            .unwrap_or_default();
336        let placeholder = format!("{{{{node.{ref_str}}}}}");
337        *result = result.replace(&placeholder, &val);
338    }
339}
340
341// --- Input File Metadata ---
342
343use crate::pipeline::{PipelineFile, PipelineNode};
344
345/// Build the initial `node_outputs` map, seeded with input file metadata.
346///
347/// Finds the `input` node in the pipeline and populates its namespace
348/// from the first file's name. This enables `{{node.input.filename_title}}`
349/// and related templates before any processing starts.
350pub fn build_node_outputs_for_input(
351    nodes: &[PipelineNode],
352    files: &[PipelineFile],
353) -> BTreeMap<String, Value> {
354    let mut outputs = BTreeMap::new();
355    if let Some(input_node) = nodes.iter().find(|n| n.node_type == "input")
356        && let Some(first_file) = files.first()
357    {
358        outputs.insert(
359            input_node.id.clone(),
360            build_input_metadata(&first_file.name),
361        );
362    }
363    outputs
364}
365
366/// Build a JSON object with filename metadata for template resolution.
367///
368/// Produces `filename`, `filename_stem`, `filename_title`, and `filename_ext`
369/// from a filename string. `filename_title` applies deslug + title case,
370/// e.g. `"vehicles-and-monsters"` → `"Vehicles And Monsters"`.
371pub fn build_input_metadata(filename: &str) -> Value {
372    let path = std::path::Path::new(filename);
373    let stem = path
374        .file_stem()
375        .and_then(|s| s.to_str())
376        .unwrap_or(filename);
377    let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
378    serde_json::json!({
379        "filename": filename,
380        "filename_stem": stem,
381        "filename_title": crate::case::deslug_title(stem),
382        "filename_ext": ext,
383    })
384}
385
386/// Resolve `{{node.*}}` placeholders in a plain string.
387///
388/// Used outside the full template system — e.g. for output directory paths
389/// that need node-derived values before pipeline execution starts.
390pub fn resolve_node_templates(s: &str, node_outputs: &BTreeMap<String, Value>) -> String {
391    if !s.contains("{{node.") {
392        return s.to_string();
393    }
394    let mut result = s.to_string();
395    resolve_node_interpolation(&mut result, node_outputs);
396    result
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402    use crate::context::NoopContext;
403    use serde_json::json;
404    use std::path::{Path, PathBuf};
405
406    fn make_params(pairs: &[(&str, Value)]) -> serde_json::Map<String, Value> {
407        pairs
408            .iter()
409            .map(|(k, v)| (k.to_string(), v.clone()))
410            .collect()
411    }
412
413    fn make_fields(pairs: &[(&str, Value)]) -> BTreeMap<String, Value> {
414        pairs
415            .iter()
416            .map(|(k, v)| (k.to_string(), v.clone()))
417            .collect()
418    }
419
420    /// Test context that returns controlled values for env vars and paths.
421    struct MockContext {
422        env_vars: BTreeMap<String, String>,
423        work_dir: PathBuf,
424        home_dir: PathBuf,
425        output_dir: PathBuf,
426    }
427
428    impl MockContext {
429        fn new() -> Self {
430            Self {
431                env_vars: BTreeMap::new(),
432                work_dir: PathBuf::from("/mock/work"),
433                home_dir: PathBuf::from("/mock/home/.bnto"),
434                output_dir: PathBuf::from("/mock/home/.bnto/output"),
435            }
436        }
437
438        fn with_env(mut self, key: &str, val: &str) -> Self {
439            self.env_vars.insert(key.to_string(), val.to_string());
440            self
441        }
442    }
443
444    impl ProcessContext for MockContext {
445        fn run_command(&self, _cmd: &str, _args: &[&str]) -> Result<Vec<u8>, crate::BntoError> {
446            Err(crate::BntoError::ProcessingFailed("mock".into()))
447        }
448        fn temp_file(&self, _suffix: &str) -> Result<PathBuf, crate::BntoError> {
449            Ok(PathBuf::from("/tmp/mock"))
450        }
451        fn env_var(&self, key: &str) -> Option<String> {
452            self.env_vars.get(key).cloned()
453        }
454        fn work_dir(&self) -> Result<&Path, crate::BntoError> {
455            Ok(&self.work_dir)
456        }
457        fn home_dir(&self) -> Option<&Path> {
458            Some(&self.home_dir)
459        }
460        fn output_dir(&self) -> Option<PathBuf> {
461            Some(self.output_dir.clone())
462        }
463    }
464
465    fn empty_fields() -> BTreeMap<String, Value> {
466        BTreeMap::new()
467    }
468
469    fn empty_outputs() -> BTreeMap<String, Value> {
470        BTreeMap::new()
471    }
472
473    // --- {{fields.*}} backward compatibility ---
474
475    #[test]
476    fn fields_simple_substitution() {
477        let params = make_params(&[("format", json!("{{fields.format}}"))]);
478        let fields = make_fields(&[("format", json!("mp4"))]);
479        let ctx = TemplateContext {
480            field_values: &fields,
481            process_ctx: &NoopContext,
482            node_outputs: &empty_outputs(),
483            loop_item: &None,
484        };
485        let resolved = resolve_templates(&params, &ctx);
486        assert_eq!(resolved["format"], json!("mp4"));
487    }
488
489    #[test]
490    fn fields_preserves_number_type() {
491        let params = make_params(&[("quality", json!("{{fields.quality}}"))]);
492        let fields = make_fields(&[("quality", json!(80))]);
493        let ctx = TemplateContext {
494            field_values: &fields,
495            process_ctx: &NoopContext,
496            node_outputs: &empty_outputs(),
497            loop_item: &None,
498        };
499        let resolved = resolve_templates(&params, &ctx);
500        assert_eq!(resolved["quality"], json!(80));
501        assert!(resolved["quality"].is_number());
502    }
503
504    #[test]
505    fn fields_missing_leaves_placeholder() {
506        let params = make_params(&[("x", json!("{{fields.missing}}"))]);
507        let ctx = TemplateContext {
508            field_values: &empty_fields(),
509            process_ctx: &NoopContext,
510            node_outputs: &empty_outputs(),
511            loop_item: &None,
512        };
513        let resolved = resolve_templates(&params, &ctx);
514        assert_eq!(resolved["x"], json!("{{fields.missing}}"));
515    }
516
517    // --- {{env.*}} ---
518
519    #[test]
520    fn env_simple_substitution() {
521        let params = make_params(&[("home", json!("{{env.HOME}}"))]);
522        let mock = MockContext::new().with_env("HOME", "/Users/test");
523        let ctx = TemplateContext {
524            field_values: &empty_fields(),
525            process_ctx: &mock,
526            node_outputs: &empty_outputs(),
527            loop_item: &None,
528        };
529        let resolved = resolve_templates(&params, &ctx);
530        assert_eq!(resolved["home"], json!("/Users/test"));
531    }
532
533    #[test]
534    fn env_missing_var_returns_empty() {
535        let params = make_params(&[("x", json!("{{env.NONEXISTENT}}"))]);
536        let mock = MockContext::new();
537        let ctx = TemplateContext {
538            field_values: &empty_fields(),
539            process_ctx: &mock,
540            node_outputs: &empty_outputs(),
541            loop_item: &None,
542        };
543        let resolved = resolve_templates(&params, &ctx);
544        assert_eq!(resolved["x"], json!(""));
545    }
546
547    #[test]
548    fn env_in_interpolation() {
549        let params = make_params(&[("path", json!("{{env.HOME}}/output"))]);
550        let mock = MockContext::new().with_env("HOME", "/Users/test");
551        let ctx = TemplateContext {
552            field_values: &empty_fields(),
553            process_ctx: &mock,
554            node_outputs: &empty_outputs(),
555            loop_item: &None,
556        };
557        let resolved = resolve_templates(&params, &ctx);
558        assert_eq!(resolved["path"], json!("/Users/test/output"));
559    }
560
561    // --- {{ctx.*}} ---
562
563    #[test]
564    fn ctx_work_dir() {
565        let params = make_params(&[("dir", json!("{{ctx.work_dir}}"))]);
566        let mock = MockContext::new();
567        let ctx = TemplateContext {
568            field_values: &empty_fields(),
569            process_ctx: &mock,
570            node_outputs: &empty_outputs(),
571            loop_item: &None,
572        };
573        let resolved = resolve_templates(&params, &ctx);
574        assert_eq!(resolved["dir"], json!("/mock/work"));
575    }
576
577    #[test]
578    fn ctx_platform() {
579        let params = make_params(&[("os", json!("{{ctx.platform}}"))]);
580        let mock = MockContext::new();
581        let ctx = TemplateContext {
582            field_values: &empty_fields(),
583            process_ctx: &mock,
584            node_outputs: &empty_outputs(),
585            loop_item: &None,
586        };
587        let resolved = resolve_templates(&params, &ctx);
588        let os = resolved["os"].as_str().unwrap();
589        assert!(
590            ["macos", "linux", "windows", "unknown"].contains(&os),
591            "Unexpected platform: {os}"
592        );
593    }
594
595    #[test]
596    fn ctx_temp_dir_resolves() {
597        let params = make_params(&[("tmp", json!("{{ctx.temp_dir}}"))]);
598        let mock = MockContext::new();
599        let ctx = TemplateContext {
600            field_values: &empty_fields(),
601            process_ctx: &mock,
602            node_outputs: &empty_outputs(),
603            loop_item: &None,
604        };
605        let resolved = resolve_templates(&params, &ctx);
606        let tmp = resolved["tmp"].as_str().unwrap();
607        assert!(
608            !tmp.is_empty(),
609            "temp_dir should resolve to a non-empty path"
610        );
611        assert!(
612            !tmp.contains("{{"),
613            "Should not contain unresolved placeholder"
614        );
615    }
616
617    #[test]
618    fn ctx_date_returns_iso_format() {
619        let params = make_params(&[("d", json!("{{ctx.date}}"))]);
620        let mock = MockContext::new();
621        let ctx = TemplateContext {
622            field_values: &empty_fields(),
623            process_ctx: &mock,
624            node_outputs: &empty_outputs(),
625            loop_item: &None,
626        };
627        let resolved = resolve_templates(&params, &ctx);
628        let val = resolved["d"].as_str().unwrap();
629        // ISO 8601 date: YYYY-MM-DD
630        assert!(
631            val.len() == 10 && val.chars().nth(4) == Some('-') && val.chars().nth(7) == Some('-'),
632            "Expected YYYY-MM-DD, got: {val}"
633        );
634        // Year should be reasonable (2020+)
635        let year: u32 = val[..4].parse().unwrap();
636        assert!(year >= 2020, "Year should be >= 2020, got: {year}");
637    }
638
639    #[test]
640    fn ctx_time_returns_hyphen_separated() {
641        let params = make_params(&[("t", json!("{{ctx.time}}"))]);
642        let mock = MockContext::new();
643        let ctx = TemplateContext {
644            field_values: &empty_fields(),
645            process_ctx: &mock,
646            node_outputs: &empty_outputs(),
647            loop_item: &None,
648        };
649        let resolved = resolve_templates(&params, &ctx);
650        let val = resolved["t"].as_str().unwrap();
651        // HH-MM-SS format (filesystem-safe)
652        assert_eq!(val.len(), 8, "Expected HH-MM-SS (8 chars), got: {val}");
653        let parts: Vec<&str> = val.split('-').collect();
654        assert_eq!(parts.len(), 3, "Expected 3 parts separated by hyphens");
655        let hour: u32 = parts[0].parse().unwrap();
656        let minute: u32 = parts[1].parse().unwrap();
657        let second: u32 = parts[2].parse().unwrap();
658        assert!(hour < 24, "Hour out of range: {hour}");
659        assert!(minute < 60, "Minute out of range: {minute}");
660        assert!(second < 60, "Second out of range: {second}");
661    }
662
663    #[test]
664    fn ctx_timestamp_returns_compact_sortable() {
665        let params = make_params(&[("ts", json!("{{ctx.timestamp}}"))]);
666        let mock = MockContext::new();
667        let ctx = TemplateContext {
668            field_values: &empty_fields(),
669            process_ctx: &mock,
670            node_outputs: &empty_outputs(),
671            loop_item: &None,
672        };
673        let resolved = resolve_templates(&params, &ctx);
674        let val = resolved["ts"].as_str().unwrap();
675        // YYYYMMDD-HHMMSS format
676        assert_eq!(
677            val.len(),
678            15,
679            "Expected YYYYMMDD-HHMMSS (15 chars), got: {val}"
680        );
681        assert_eq!(
682            val.chars().nth(8),
683            Some('-'),
684            "Expected hyphen at position 8"
685        );
686        // Date part should be all digits
687        assert!(val[..8].chars().all(|c| c.is_ascii_digit()));
688        // Time part should be all digits
689        assert!(val[9..].chars().all(|c| c.is_ascii_digit()));
690    }
691
692    #[test]
693    fn ctx_date_in_interpolation() {
694        let params = make_params(&[("dir", json!("{{ctx.date}}-bulk-download"))]);
695        let mock = MockContext::new();
696        let ctx = TemplateContext {
697            field_values: &empty_fields(),
698            process_ctx: &mock,
699            node_outputs: &empty_outputs(),
700            loop_item: &None,
701        };
702        let resolved = resolve_templates(&params, &ctx);
703        let val = resolved["dir"].as_str().unwrap();
704        assert!(
705            val.ends_with("-bulk-download"),
706            "Expected suffix, got: {val}"
707        );
708        assert!(
709            !val.contains("{{"),
710            "Should not contain unresolved placeholder"
711        );
712    }
713
714    #[test]
715    fn resolve_ctx_templates_public_function() {
716        let mock = MockContext::new();
717        let result = resolve_ctx_templates("{{ctx.date}}-output", &mock);
718        assert!(
719            !result.contains("{{"),
720            "Should not contain unresolved placeholder"
721        );
722        assert!(result.ends_with("-output"), "Suffix should be preserved");
723        assert!(result.len() > "-output".len(), "Date should be prepended");
724    }
725
726    #[test]
727    fn resolve_ctx_templates_no_placeholders() {
728        let mock = MockContext::new();
729        let result = resolve_ctx_templates("plain-string", &mock);
730        assert_eq!(result, "plain-string");
731    }
732
733    #[test]
734    fn resolve_ctx_templates_empty_string() {
735        let mock = MockContext::new();
736        let result = resolve_ctx_templates("", &mock);
737        assert_eq!(result, "");
738    }
739
740    #[test]
741    fn resolve_ctx_templates_paths_namespace() {
742        let mock = MockContext::new();
743        let result = resolve_ctx_templates("{{ctx.paths.output_dir}}/{{ctx.date}}-compress", &mock);
744        assert!(result.starts_with("/mock/home/.bnto/output/"));
745        assert!(result.ends_with("-compress"));
746        assert!(!result.contains("{{"));
747    }
748
749    // --- {{ctx.paths.*}} organized namespace ---
750
751    #[test]
752    fn ctx_paths_home_dir_resolves() {
753        let params = make_params(&[("dir", json!("{{ctx.paths.home_dir}}"))]);
754        let mock = MockContext::new();
755        let ctx = TemplateContext {
756            field_values: &empty_fields(),
757            process_ctx: &mock,
758            node_outputs: &empty_outputs(),
759            loop_item: &None,
760        };
761        let resolved = resolve_templates(&params, &ctx);
762        assert_eq!(resolved["dir"], json!("/mock/home/.bnto"));
763    }
764
765    #[test]
766    fn ctx_paths_output_dir_resolves() {
767        let params = make_params(&[("dir", json!("{{ctx.paths.output_dir}}"))]);
768        let mock = MockContext::new();
769        let ctx = TemplateContext {
770            field_values: &empty_fields(),
771            process_ctx: &mock,
772            node_outputs: &empty_outputs(),
773            loop_item: &None,
774        };
775        let resolved = resolve_templates(&params, &ctx);
776        assert_eq!(resolved["dir"], json!("/mock/home/.bnto/output"));
777    }
778
779    #[test]
780    fn ctx_paths_work_dir_resolves() {
781        let params = make_params(&[("dir", json!("{{ctx.paths.work_dir}}"))]);
782        let mock = MockContext::new();
783        let ctx = TemplateContext {
784            field_values: &empty_fields(),
785            process_ctx: &mock,
786            node_outputs: &empty_outputs(),
787            loop_item: &None,
788        };
789        let resolved = resolve_templates(&params, &ctx);
790        assert_eq!(resolved["dir"], json!("/mock/work"));
791    }
792
793    #[test]
794    fn ctx_paths_work_dir_alias() {
795        // Old flat form still works.
796        let params = make_params(&[
797            ("old", json!("{{ctx.work_dir}}")),
798            ("new", json!("{{ctx.paths.work_dir}}")),
799        ]);
800        let mock = MockContext::new();
801        let ctx = TemplateContext {
802            field_values: &empty_fields(),
803            process_ctx: &mock,
804            node_outputs: &empty_outputs(),
805            loop_item: &None,
806        };
807        let resolved = resolve_templates(&params, &ctx);
808        assert_eq!(resolved["old"], resolved["new"]);
809    }
810
811    #[test]
812    fn ctx_paths_temp_dir_resolves() {
813        let params = make_params(&[("dir", json!("{{ctx.paths.temp_dir}}"))]);
814        let mock = MockContext::new();
815        let ctx = TemplateContext {
816            field_values: &empty_fields(),
817            process_ctx: &mock,
818            node_outputs: &empty_outputs(),
819            loop_item: &None,
820        };
821        let resolved = resolve_templates(&params, &ctx);
822        let val = resolved["dir"].as_str().unwrap();
823        assert!(!val.is_empty());
824        assert!(!val.contains("{{"));
825    }
826
827    #[test]
828    fn ctx_paths_temp_dir_alias() {
829        // Old flat form still works.
830        let params = make_params(&[
831            ("old", json!("{{ctx.temp_dir}}")),
832            ("new", json!("{{ctx.paths.temp_dir}}")),
833        ]);
834        let mock = MockContext::new();
835        let ctx = TemplateContext {
836            field_values: &empty_fields(),
837            process_ctx: &mock,
838            node_outputs: &empty_outputs(),
839            loop_item: &None,
840        };
841        let resolved = resolve_templates(&params, &ctx);
842        assert_eq!(resolved["old"], resolved["new"]);
843    }
844
845    #[test]
846    fn ctx_paths_unknown_left_asis() {
847        let params = make_params(&[("x", json!("{{ctx.paths.unknown}}"))]);
848        let mock = MockContext::new();
849        let ctx = TemplateContext {
850            field_values: &empty_fields(),
851            process_ctx: &mock,
852            node_outputs: &empty_outputs(),
853            loop_item: &None,
854        };
855        let resolved = resolve_templates(&params, &ctx);
856        assert_eq!(resolved["x"], json!("{{ctx.paths.unknown}}"));
857    }
858
859    #[test]
860    fn ctx_paths_output_dir_in_interpolation() {
861        let params = make_params(&[("dir", json!("{{ctx.paths.output_dir}}/{{ctx.date}}-images"))]);
862        let mock = MockContext::new();
863        let ctx = TemplateContext {
864            field_values: &empty_fields(),
865            process_ctx: &mock,
866            node_outputs: &empty_outputs(),
867            loop_item: &None,
868        };
869        let resolved = resolve_templates(&params, &ctx);
870        let val = resolved["dir"].as_str().unwrap();
871        assert!(val.starts_with("/mock/home/.bnto/output/"));
872        assert!(val.ends_with("-images"));
873        assert!(!val.contains("{{"));
874    }
875
876    #[test]
877    fn ctx_unknown_property_left_asis() {
878        let params = make_params(&[("x", json!("{{ctx.bogus}}"))]);
879        let mock = MockContext::new();
880        let ctx = TemplateContext {
881            field_values: &empty_fields(),
882            process_ctx: &mock,
883            node_outputs: &empty_outputs(),
884            loop_item: &None,
885        };
886        let resolved = resolve_templates(&params, &ctx);
887        assert_eq!(resolved["x"], json!("{{ctx.bogus}}"));
888    }
889
890    // --- {{node.<id>.*}} ---
891
892    #[test]
893    fn node_ref_resolves_from_output_map() {
894        let params = make_params(&[("fmt", json!("{{node.compress.format}}"))]);
895        let mut outputs = BTreeMap::new();
896        outputs.insert("compress".to_string(), json!({"format": "webp"}));
897        let ctx = TemplateContext {
898            field_values: &empty_fields(),
899            process_ctx: &NoopContext,
900            node_outputs: &outputs,
901            loop_item: &None,
902        };
903        let resolved = resolve_templates(&params, &ctx);
904        assert_eq!(resolved["fmt"], json!("webp"));
905    }
906
907    #[test]
908    fn node_ref_missing_node_left_asis() {
909        let params = make_params(&[("x", json!("{{node.missing.prop}}"))]);
910        let ctx = TemplateContext {
911            field_values: &empty_fields(),
912            process_ctx: &NoopContext,
913            node_outputs: &empty_outputs(),
914            loop_item: &None,
915        };
916        let resolved = resolve_templates(&params, &ctx);
917        assert_eq!(resolved["x"], json!("{{node.missing.prop}}"));
918    }
919
920    #[test]
921    fn node_ref_missing_property_left_asis() {
922        let mut outputs = BTreeMap::new();
923        outputs.insert("compress".to_string(), json!({"format": "webp"}));
924        let params = make_params(&[("x", json!("{{node.compress.missing}}"))]);
925        let ctx = TemplateContext {
926            field_values: &empty_fields(),
927            process_ctx: &NoopContext,
928            node_outputs: &outputs,
929            loop_item: &None,
930        };
931        let resolved = resolve_templates(&params, &ctx);
932        assert_eq!(resolved["x"], json!("{{node.compress.missing}}"));
933    }
934
935    // --- Mixed namespaces ---
936
937    #[test]
938    fn mixed_namespaces_in_one_string() {
939        let params = make_params(&[("cmd", json!("{{env.HOME}}/{{ctx.platform}}/out"))]);
940        let mock = MockContext::new().with_env("HOME", "/Users/test");
941        let ctx = TemplateContext {
942            field_values: &empty_fields(),
943            process_ctx: &mock,
944            node_outputs: &empty_outputs(),
945            loop_item: &None,
946        };
947        let resolved = resolve_templates(&params, &ctx);
948        let val = resolved["cmd"].as_str().unwrap();
949        assert!(val.starts_with("/Users/test/"));
950        assert!(val.ends_with("/out"));
951        assert!(!val.contains("{{"), "All placeholders should be resolved");
952    }
953
954    #[test]
955    fn fields_and_env_mixed() {
956        let params = make_params(&[("x", json!("{{fields.name}}-{{env.SUFFIX}}"))]);
957        let fields = make_fields(&[("name", json!("hello"))]);
958        let mock = MockContext::new().with_env("SUFFIX", "world");
959        let ctx = TemplateContext {
960            field_values: &fields,
961            process_ctx: &mock,
962            node_outputs: &empty_outputs(),
963            loop_item: &None,
964        };
965        let resolved = resolve_templates(&params, &ctx);
966        assert_eq!(resolved["x"], json!("hello-world"));
967    }
968
969    #[test]
970    fn non_string_values_pass_through() {
971        let params = make_params(&[("n", json!(42)), ("b", json!(true))]);
972        let ctx = TemplateContext {
973            field_values: &empty_fields(),
974            process_ctx: &NoopContext,
975            node_outputs: &empty_outputs(),
976            loop_item: &None,
977        };
978        let resolved = resolve_templates(&params, &ctx);
979        assert_eq!(resolved["n"], json!(42));
980        assert_eq!(resolved["b"], json!(true));
981    }
982
983    #[test]
984    fn array_elements_resolved() {
985        let params = make_params(&[("args", json!(["--dir", "{{env.HOME}}", "-v"]))]);
986        let mock = MockContext::new().with_env("HOME", "/test");
987        let ctx = TemplateContext {
988            field_values: &empty_fields(),
989            process_ctx: &mock,
990            node_outputs: &empty_outputs(),
991            loop_item: &None,
992        };
993        let resolved = resolve_templates(&params, &ctx);
994        assert_eq!(resolved["args"], json!(["--dir", "/test", "-v"]));
995    }
996
997    #[test]
998    fn nested_object_resolved() {
999        let params = make_params(&[("config", json!({"dir": "{{env.HOME}}"}))]);
1000        let mock = MockContext::new().with_env("HOME", "/test");
1001        let ctx = TemplateContext {
1002            field_values: &empty_fields(),
1003            process_ctx: &mock,
1004            node_outputs: &empty_outputs(),
1005            loop_item: &None,
1006        };
1007        let resolved = resolve_templates(&params, &ctx);
1008        assert_eq!(resolved["config"]["dir"], json!("/test"));
1009    }
1010
1011    #[test]
1012    fn no_placeholder_passes_through() {
1013        let params = make_params(&[("cmd", json!("yt-dlp"))]);
1014        let ctx = TemplateContext {
1015            field_values: &empty_fields(),
1016            process_ctx: &NoopContext,
1017            node_outputs: &empty_outputs(),
1018            loop_item: &None,
1019        };
1020        let resolved = resolve_templates(&params, &ctx);
1021        assert_eq!(resolved["cmd"], json!("yt-dlp"));
1022    }
1023
1024    #[test]
1025    fn node_ref_in_interpolation() {
1026        let params = make_params(&[("label", json!("Format: {{node.compress.format}}"))]);
1027        let mut outputs = BTreeMap::new();
1028        outputs.insert("compress".to_string(), json!({"format": "webp"}));
1029        let ctx = TemplateContext {
1030            field_values: &empty_fields(),
1031            process_ctx: &NoopContext,
1032            node_outputs: &outputs,
1033            loop_item: &None,
1034        };
1035        let resolved = resolve_templates(&params, &ctx);
1036        assert_eq!(resolved["label"], json!("Format: webp"));
1037    }
1038
1039    // --- {{item.*}} — loop iteration metadata ---
1040
1041    fn make_item(pairs: &[(&str, Value)]) -> Option<serde_json::Map<String, Value>> {
1042        Some(
1043            pairs
1044                .iter()
1045                .map(|(k, v)| (k.to_string(), v.clone()))
1046                .collect(),
1047        )
1048    }
1049
1050    #[test]
1051    fn item_simple_substitution() {
1052        let params = make_params(&[("url", json!("{{item.url}}"))]);
1053        let item = make_item(&[("url", json!("https://example.com/video"))]);
1054        let ctx = TemplateContext {
1055            field_values: &empty_fields(),
1056            process_ctx: &NoopContext,
1057            node_outputs: &empty_outputs(),
1058            loop_item: &item,
1059        };
1060        let resolved = resolve_templates(&params, &ctx);
1061        assert_eq!(resolved["url"], json!("https://example.com/video"));
1062    }
1063
1064    #[test]
1065    fn item_preserves_number_type() {
1066        let params = make_params(&[("count", json!("{{item.count}}"))]);
1067        let item = make_item(&[("count", json!(42))]);
1068        let ctx = TemplateContext {
1069            field_values: &empty_fields(),
1070            process_ctx: &NoopContext,
1071            node_outputs: &empty_outputs(),
1072            loop_item: &item,
1073        };
1074        let resolved = resolve_templates(&params, &ctx);
1075        assert_eq!(resolved["count"], json!(42));
1076        assert!(resolved["count"].is_number());
1077    }
1078
1079    #[test]
1080    fn item_missing_key_left_asis() {
1081        let params = make_params(&[("x", json!("{{item.missing}}"))]);
1082        let item = make_item(&[("url", json!("https://example.com"))]);
1083        let ctx = TemplateContext {
1084            field_values: &empty_fields(),
1085            process_ctx: &NoopContext,
1086            node_outputs: &empty_outputs(),
1087            loop_item: &item,
1088        };
1089        let resolved = resolve_templates(&params, &ctx);
1090        assert_eq!(resolved["x"], json!("{{item.missing}}"));
1091    }
1092
1093    #[test]
1094    fn item_no_context_left_asis() {
1095        let params = make_params(&[("url", json!("{{item.url}}"))]);
1096        let ctx = TemplateContext {
1097            field_values: &empty_fields(),
1098            process_ctx: &NoopContext,
1099            node_outputs: &empty_outputs(),
1100            loop_item: &None,
1101        };
1102        let resolved = resolve_templates(&params, &ctx);
1103        assert_eq!(resolved["url"], json!("{{item.url}}"));
1104    }
1105
1106    #[test]
1107    fn item_in_interpolation() {
1108        let params = make_params(&[("path", json!("dir/{{item.group}}/out"))]);
1109        let item = make_item(&[("group", json!("Alpha"))]);
1110        let ctx = TemplateContext {
1111            field_values: &empty_fields(),
1112            process_ctx: &NoopContext,
1113            node_outputs: &empty_outputs(),
1114            loop_item: &item,
1115        };
1116        let resolved = resolve_templates(&params, &ctx);
1117        assert_eq!(resolved["path"], json!("dir/Alpha/out"));
1118    }
1119
1120    #[test]
1121    fn item_mixed_with_fields() {
1122        let params = make_params(&[("x", json!("{{fields.format}}_{{item.group}}"))]);
1123        let fields = make_fields(&[("format", json!("mp4"))]);
1124        let item = make_item(&[("group", json!("Beta"))]);
1125        let ctx = TemplateContext {
1126            field_values: &fields,
1127            process_ctx: &NoopContext,
1128            node_outputs: &empty_outputs(),
1129            loop_item: &item,
1130        };
1131        let resolved = resolve_templates(&params, &ctx);
1132        assert_eq!(resolved["x"], json!("mp4_Beta"));
1133    }
1134
1135    // --- build_input_metadata ---
1136
1137    #[test]
1138    fn input_metadata_with_extension() {
1139        let meta = build_input_metadata("vehicles-and-monsters.csv");
1140        assert_eq!(meta["filename"], json!("vehicles-and-monsters.csv"));
1141        assert_eq!(meta["filename_stem"], json!("vehicles-and-monsters"));
1142        assert_eq!(meta["filename_title"], json!("Vehicles And Monsters"));
1143        assert_eq!(meta["filename_ext"], json!("csv"));
1144    }
1145
1146    #[test]
1147    fn input_metadata_multi_dot_extension() {
1148        let meta = build_input_metadata("archive.tar.gz");
1149        assert_eq!(meta["filename"], json!("archive.tar.gz"));
1150        assert_eq!(meta["filename_stem"], json!("archive.tar"));
1151        assert_eq!(meta["filename_ext"], json!("gz"));
1152    }
1153
1154    #[test]
1155    fn input_metadata_no_extension() {
1156        let meta = build_input_metadata("Makefile");
1157        assert_eq!(meta["filename"], json!("Makefile"));
1158        assert_eq!(meta["filename_stem"], json!("Makefile"));
1159        assert_eq!(meta["filename_ext"], json!(""));
1160    }
1161
1162    #[test]
1163    fn input_metadata_deslug_title_transform() {
1164        let meta = build_input_metadata("my_project-name.txt");
1165        // deslug replaces both hyphens and underscores with spaces
1166        assert_eq!(meta["filename_title"], json!("My Project Name"));
1167    }
1168
1169    // --- build_node_outputs_for_input ---
1170
1171    use crate::pipeline::PipelineNode;
1172
1173    fn make_input_node(id: &str) -> PipelineNode {
1174        PipelineNode {
1175            id: id.to_string(),
1176            node_type: "input".to_string(),
1177            params: serde_json::Map::new(),
1178            fields: BTreeMap::new(),
1179            children: None,
1180        }
1181    }
1182
1183    fn make_processing_node(id: &str, node_type: &str) -> PipelineNode {
1184        PipelineNode {
1185            id: id.to_string(),
1186            node_type: node_type.to_string(),
1187            params: serde_json::Map::new(),
1188            fields: BTreeMap::new(),
1189            children: None,
1190        }
1191    }
1192
1193    fn make_pipeline_file(name: &str) -> PipelineFile {
1194        PipelineFile {
1195            name: name.to_string(),
1196            data: crate::file_data::FileData::Bytes(Vec::new()),
1197            mime_type: "text/plain".to_string(),
1198            metadata: serde_json::Map::new(),
1199        }
1200    }
1201
1202    #[test]
1203    fn node_outputs_for_input_finds_input_node() {
1204        let nodes = vec![
1205            make_input_node("input"),
1206            make_processing_node("compress", "image-compress"),
1207        ];
1208        let files = vec![make_pipeline_file("photos.zip")];
1209        let outputs = build_node_outputs_for_input(&nodes, &files);
1210        assert!(outputs.contains_key("input"));
1211        assert_eq!(outputs["input"]["filename"], json!("photos.zip"));
1212    }
1213
1214    #[test]
1215    fn node_outputs_for_input_empty_files() {
1216        let nodes = vec![make_input_node("input")];
1217        let files: Vec<PipelineFile> = Vec::new();
1218        let outputs = build_node_outputs_for_input(&nodes, &files);
1219        assert!(outputs.is_empty());
1220    }
1221
1222    #[test]
1223    fn node_outputs_for_input_no_input_node() {
1224        let nodes = vec![make_processing_node("compress", "image-compress")];
1225        let files = vec![make_pipeline_file("photo.jpg")];
1226        let outputs = build_node_outputs_for_input(&nodes, &files);
1227        assert!(outputs.is_empty());
1228    }
1229
1230    #[test]
1231    fn node_outputs_for_input_uses_first_file() {
1232        let nodes = vec![make_input_node("input")];
1233        let files = vec![
1234            make_pipeline_file("first.csv"),
1235            make_pipeline_file("second.csv"),
1236        ];
1237        let outputs = build_node_outputs_for_input(&nodes, &files);
1238        assert_eq!(outputs["input"]["filename"], json!("first.csv"));
1239    }
1240
1241    // --- resolve_node_templates (public function) ---
1242
1243    #[test]
1244    fn resolve_node_templates_resolves_placeholders() {
1245        let mut outputs = BTreeMap::new();
1246        outputs.insert("input".to_string(), build_input_metadata("data-set.csv"));
1247        let result = resolve_node_templates("/downloads/{{node.input.filename_title}}", &outputs);
1248        assert_eq!(result, "/downloads/Data Set");
1249    }
1250
1251    #[test]
1252    fn resolve_node_templates_no_placeholders() {
1253        let outputs = BTreeMap::new();
1254        let result = resolve_node_templates("plain-string", &outputs);
1255        assert_eq!(result, "plain-string");
1256    }
1257
1258    #[test]
1259    fn resolve_node_templates_empty_string() {
1260        let outputs = BTreeMap::new();
1261        let result = resolve_node_templates("", &outputs);
1262        assert_eq!(result, "");
1263    }
1264
1265    #[test]
1266    fn resolve_node_templates_multiple_placeholders() {
1267        let mut outputs = BTreeMap::new();
1268        outputs.insert(
1269            "input".to_string(),
1270            build_input_metadata("vehicles-and-monsters.csv"),
1271        );
1272        let result = resolve_node_templates(
1273            "/downloads/{{node.input.filename_title}}/{{node.input.filename_ext}}",
1274            &outputs,
1275        );
1276        assert_eq!(result, "/downloads/Vehicles And Monsters/csv");
1277    }
1278
1279    // --- {{node.input.*}} through full resolve_templates ---
1280
1281    #[test]
1282    fn node_input_filename_title_through_full_resolution() {
1283        let mut outputs = BTreeMap::new();
1284        outputs.insert(
1285            "input".to_string(),
1286            build_input_metadata("vehicles-and-monsters.csv"),
1287        );
1288        let params = make_params(&[("dest", json!("/downloads/{{node.input.filename_title}}"))]);
1289        let ctx = TemplateContext {
1290            field_values: &empty_fields(),
1291            process_ctx: &NoopContext,
1292            node_outputs: &outputs,
1293            loop_item: &None,
1294        };
1295        let resolved = resolve_templates(&params, &ctx);
1296        assert_eq!(resolved["dest"], json!("/downloads/Vehicles And Monsters"));
1297    }
1298
1299    #[test]
1300    fn node_input_filename_stem_sole_placeholder() {
1301        let mut outputs = BTreeMap::new();
1302        outputs.insert("input".to_string(), build_input_metadata("my-data.csv"));
1303        let params = make_params(&[("stem", json!("{{node.input.filename_stem}}"))]);
1304        let ctx = TemplateContext {
1305            field_values: &empty_fields(),
1306            process_ctx: &NoopContext,
1307            node_outputs: &outputs,
1308            loop_item: &None,
1309        };
1310        let resolved = resolve_templates(&params, &ctx);
1311        assert_eq!(resolved["stem"], json!("my-data"));
1312    }
1313}