1use std::collections::BTreeMap;
8
9use serde_json::Value;
10
11use crate::context::ProcessContext;
12
13pub struct TemplateContext<'a> {
15 pub field_values: &'a BTreeMap<String, Value>,
17 pub process_ctx: &'a dyn ProcessContext,
19 pub node_outputs: &'a BTreeMap<String, Value>,
21 pub loop_item: &'a Option<serde_json::Map<String, Value>>,
23}
24
25pub 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
39fn 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
49fn 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
58fn 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 if inner.contains('{') || inner.contains('}') {
68 return None;
69 }
70 let (prefix, key) = inner.split_once('.')?;
72 if prefix == "node" {
76 let (node_id, prop) = key.rsplit_once('.')?;
78 return Some((&inner[..5 + node_id.len()], prop)); }
80 Some((prefix, key))
82}
83
84fn 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..]; resolve_node_ref(node_id, key, ctx.node_outputs)
97 }
98 _ => None,
99 }
100}
101
102fn resolve_ctx(key: &str, process_ctx: &dyn ProcessContext) -> Option<Value> {
112 match key {
113 "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 "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, }
142}
143
144fn 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
160fn 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 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; 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
185pub 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
199fn 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
212fn 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
225fn 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
236fn resolve_string(s: &str, ctx: &TemplateContext) -> Value {
241 if !has_placeholder(s) {
242 return Value::String(s.to_string());
243 }
244
245 if let Some((prefix, key)) = extract_sole_placeholder(s) {
247 if let Some(value) = resolve_placeholder(prefix, key, ctx) {
248 return value;
249 }
250 return Value::String(s.to_string());
252 }
253
254 let mut result = s.to_string();
256
257 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_interpolation(&mut result, ctx.process_ctx);
267
268 resolve_ctx_interpolation(&mut result, ctx.process_ctx);
270
271 resolve_item_interpolation(&mut result, ctx.loop_item);
273
274 resolve_node_interpolation(&mut result, ctx.node_outputs);
276
277 Value::String(result)
278}
279
280fn 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
292fn 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
306fn 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
324fn 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]; 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
341use crate::pipeline::{PipelineFile, PipelineNode};
344
345pub 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
366pub 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
386pub 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 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 #[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(¶ms, &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(¶ms, &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(¶ms, &ctx);
514 assert_eq!(resolved["x"], json!("{{fields.missing}}"));
515 }
516
517 #[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(¶ms, &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(¶ms, &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(¶ms, &ctx);
558 assert_eq!(resolved["path"], json!("/Users/test/output"));
559 }
560
561 #[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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &ctx);
628 let val = resolved["d"].as_str().unwrap();
629 assert!(
631 val.len() == 10 && val.chars().nth(4) == Some('-') && val.chars().nth(7) == Some('-'),
632 "Expected YYYY-MM-DD, got: {val}"
633 );
634 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(¶ms, &ctx);
650 let val = resolved["t"].as_str().unwrap();
651 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(¶ms, &ctx);
674 let val = resolved["ts"].as_str().unwrap();
675 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 assert!(val[..8].chars().all(|c| c.is_ascii_digit()));
688 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(¶ms, &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 #[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(¶ms, &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(¶ms, &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(¶ms, &ctx);
790 assert_eq!(resolved["dir"], json!("/mock/work"));
791 }
792
793 #[test]
794 fn ctx_paths_work_dir_alias() {
795 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(¶ms, &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(¶ms, &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 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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &ctx);
887 assert_eq!(resolved["x"], json!("{{ctx.bogus}}"));
888 }
889
890 #[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(¶ms, &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(¶ms, &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(¶ms, &ctx);
932 assert_eq!(resolved["x"], json!("{{node.compress.missing}}"));
933 }
934
935 #[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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &ctx);
1036 assert_eq!(resolved["label"], json!("Format: webp"));
1037 }
1038
1039 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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &ctx);
1132 assert_eq!(resolved["x"], json!("mp4_Beta"));
1133 }
1134
1135 #[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 assert_eq!(meta["filename_title"], json!("My Project Name"));
1167 }
1168
1169 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 #[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 #[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(¶ms, &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(¶ms, &ctx);
1311 assert_eq!(resolved["stem"], json!("my-data"));
1312 }
1313}