use std::collections::BTreeMap;
use serde_json::Value;
use crate::context::ProcessContext;
pub struct TemplateContext<'a> {
pub field_values: &'a BTreeMap<String, Value>,
pub process_ctx: &'a dyn ProcessContext,
pub node_outputs: &'a BTreeMap<String, Value>,
pub loop_item: &'a Option<serde_json::Map<String, Value>>,
}
pub fn resolve_templates(
params: &serde_json::Map<String, Value>,
ctx: &TemplateContext,
) -> serde_json::Map<String, Value> {
params
.iter()
.map(|(k, v)| (k.clone(), resolve_value(v, ctx)))
.collect()
}
fn resolve_value(value: &Value, ctx: &TemplateContext) -> Value {
match value {
Value::String(s) => resolve_string(s, ctx),
Value::Array(arr) => Value::Array(arr.iter().map(|v| resolve_value(v, ctx)).collect()),
Value::Object(map) => Value::Object(resolve_templates(map, ctx)),
other => other.clone(),
}
}
fn has_placeholder(s: &str) -> bool {
s.contains("{{fields.")
|| s.contains("{{env.")
|| s.contains("{{ctx.")
|| s.contains("{{node.")
|| s.contains("{{item.")
}
fn extract_sole_placeholder(s: &str) -> Option<(&str, &str)> {
let trimmed = s.trim();
if !trimmed.starts_with("{{") || !trimmed.ends_with("}}") {
return None;
}
let inner = &trimmed[2..trimmed.len() - 2];
if inner.contains('{') || inner.contains('}') {
return None;
}
let (prefix, key) = inner.split_once('.')?;
if prefix == "node" {
let (node_id, prop) = key.rsplit_once('.')?;
return Some((&inner[..5 + node_id.len()], prop)); }
Some((prefix, key))
}
fn resolve_placeholder(prefix: &str, key: &str, ctx: &TemplateContext) -> Option<Value> {
match prefix {
"fields" => ctx.field_values.get(key).cloned(),
"env" => {
let val = ctx.process_ctx.env_var(key).unwrap_or_default();
Some(Value::String(val))
}
"ctx" => resolve_ctx(key, ctx.process_ctx),
"item" => ctx.loop_item.as_ref().and_then(|m| m.get(key).cloned()),
p if p.starts_with("node.") => {
let node_id = &p[5..]; resolve_node_ref(node_id, key, ctx.node_outputs)
}
_ => None,
}
}
fn resolve_ctx(key: &str, process_ctx: &dyn ProcessContext) -> Option<Value> {
match key {
"paths.home_dir" => process_ctx
.home_dir()
.map(|p| Value::String(p.to_string_lossy().into_owned())),
"paths.output_dir" => process_ctx
.output_dir()
.map(|p| Value::String(p.to_string_lossy().into_owned())),
"paths.work_dir" | "work_dir" => process_ctx
.work_dir()
.ok()
.map(|p| Value::String(p.to_string_lossy().into_owned())),
"paths.temp_dir" | "temp_dir" => {
let dir = std::env::temp_dir();
Some(Value::String(dir.to_string_lossy().into_owned()))
}
"platform" => Some(Value::String(current_platform().to_string())),
"date" | "time" | "timestamp" => {
let (y, m, d, hh, mm, ss) = now_civil();
let val = match key {
"date" => format!("{y:04}-{m:02}-{d:02}"),
"time" => format!("{hh:02}-{mm:02}-{ss:02}"),
"timestamp" => format!("{y:04}{m:02}{d:02}-{hh:02}{mm:02}{ss:02}"),
_ => unreachable!(),
};
Some(Value::String(val))
}
_ => None, }
}
fn now_civil() -> (i32, u32, u32, u32, u32, u32) {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
epoch_to_civil(secs)
}
fn epoch_to_civil(epoch_secs: u64) -> (i32, u32, u32, u32, u32, u32) {
let total_secs = epoch_secs;
let day_secs = (total_secs % 86400) as u32;
let hh = day_secs / 3600;
let mm = (day_secs % 3600) / 60;
let ss = day_secs % 60;
let z = (total_secs / 86400) as i64 + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = (z - era * 146097) as u32; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y as i32, m, d, hh, mm, ss)
}
pub fn resolve_ctx_templates(s: &str, ctx: &dyn ProcessContext) -> String {
if !s.contains("{{ctx.") {
return s.to_string();
}
let mut result = s.to_string();
resolve_ctx_interpolation(&mut result, ctx);
result
}
fn current_platform() -> &'static str {
if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "windows") {
"windows"
} else {
"unknown"
}
}
fn resolve_node_ref(
node_id: &str,
prop: &str,
node_outputs: &BTreeMap<String, Value>,
) -> Option<Value> {
let output = node_outputs.get(node_id)?;
match output {
Value::Object(map) => map.get(prop).cloned(),
_ => None,
}
}
fn value_to_string(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
Value::Null => String::new(),
other => other.to_string(),
}
}
fn resolve_string(s: &str, ctx: &TemplateContext) -> Value {
if !has_placeholder(s) {
return Value::String(s.to_string());
}
if let Some((prefix, key)) = extract_sole_placeholder(s) {
if let Some(value) = resolve_placeholder(prefix, key, ctx) {
return value;
}
return Value::String(s.to_string());
}
let mut result = s.to_string();
for (key, value) in ctx.field_values {
let placeholder = format!("{{{{fields.{key}}}}}");
if result.contains(&placeholder) {
result = result.replace(&placeholder, &value_to_string(value));
}
}
resolve_env_interpolation(&mut result, ctx.process_ctx);
resolve_ctx_interpolation(&mut result, ctx.process_ctx);
resolve_item_interpolation(&mut result, ctx.loop_item);
resolve_node_interpolation(&mut result, ctx.node_outputs);
Value::String(result)
}
fn resolve_env_interpolation(result: &mut String, process_ctx: &dyn ProcessContext) {
while let Some(start) = result.find("{{env.") {
let rest = &result[start + 6..];
let Some(end) = rest.find("}}") else { break };
let key = &result[start + 6..start + 6 + end];
let val = process_ctx.env_var(key).unwrap_or_default();
let placeholder = format!("{{{{env.{key}}}}}");
*result = result.replace(&placeholder, &val);
}
}
fn resolve_ctx_interpolation(result: &mut String, process_ctx: &dyn ProcessContext) {
while let Some(start) = result.find("{{ctx.") {
let rest = &result[start + 6..];
let Some(end) = rest.find("}}") else { break };
let key = &result[start + 6..start + 6 + end];
let val = resolve_ctx(key, process_ctx)
.map(|v| value_to_string(&v))
.unwrap_or_default();
let placeholder = format!("{{{{ctx.{key}}}}}");
*result = result.replace(&placeholder, &val);
}
}
fn resolve_item_interpolation(
result: &mut String,
loop_item: &Option<serde_json::Map<String, Value>>,
) {
let Some(item) = loop_item.as_ref() else {
return;
};
while let Some(start) = result.find("{{item.") {
let rest = &result[start + 7..];
let Some(end) = rest.find("}}") else { break };
let key = &result[start + 7..start + 7 + end];
let val = item.get(key).map(value_to_string).unwrap_or_default();
let placeholder = format!("{{{{item.{key}}}}}");
*result = result.replace(&placeholder, &val);
}
}
fn resolve_node_interpolation(result: &mut String, node_outputs: &BTreeMap<String, Value>) {
while let Some(start) = result.find("{{node.") {
let rest = &result[start + 7..];
let Some(end) = rest.find("}}") else { break };
let ref_str = &result[start + 7..start + 7 + end]; let Some((node_id, prop)) = ref_str.rsplit_once('.') else {
break;
};
let val = resolve_node_ref(node_id, prop, node_outputs)
.map(|v| value_to_string(&v))
.unwrap_or_default();
let placeholder = format!("{{{{node.{ref_str}}}}}");
*result = result.replace(&placeholder, &val);
}
}
use crate::pipeline::{PipelineFile, PipelineNode};
pub fn build_node_outputs_for_input(
nodes: &[PipelineNode],
files: &[PipelineFile],
) -> BTreeMap<String, Value> {
let mut outputs = BTreeMap::new();
if let Some(input_node) = nodes.iter().find(|n| n.node_type == "input")
&& let Some(first_file) = files.first()
{
outputs.insert(
input_node.id.clone(),
build_input_metadata(&first_file.name),
);
}
outputs
}
pub fn build_input_metadata(filename: &str) -> Value {
let path = std::path::Path::new(filename);
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(filename);
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
serde_json::json!({
"filename": filename,
"filename_stem": stem,
"filename_title": crate::case::deslug_title(stem),
"filename_ext": ext,
})
}
pub fn resolve_node_templates(s: &str, node_outputs: &BTreeMap<String, Value>) -> String {
if !s.contains("{{node.") {
return s.to_string();
}
let mut result = s.to_string();
resolve_node_interpolation(&mut result, node_outputs);
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::NoopContext;
use serde_json::json;
use std::path::{Path, PathBuf};
fn make_params(pairs: &[(&str, Value)]) -> serde_json::Map<String, Value> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect()
}
fn make_fields(pairs: &[(&str, Value)]) -> BTreeMap<String, Value> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect()
}
struct MockContext {
env_vars: BTreeMap<String, String>,
work_dir: PathBuf,
home_dir: PathBuf,
output_dir: PathBuf,
}
impl MockContext {
fn new() -> Self {
Self {
env_vars: BTreeMap::new(),
work_dir: PathBuf::from("/mock/work"),
home_dir: PathBuf::from("/mock/home/.bnto"),
output_dir: PathBuf::from("/mock/home/.bnto/output"),
}
}
fn with_env(mut self, key: &str, val: &str) -> Self {
self.env_vars.insert(key.to_string(), val.to_string());
self
}
}
impl ProcessContext for MockContext {
fn run_command(&self, _cmd: &str, _args: &[&str]) -> Result<Vec<u8>, crate::BntoError> {
Err(crate::BntoError::ProcessingFailed("mock".into()))
}
fn temp_file(&self, _suffix: &str) -> Result<PathBuf, crate::BntoError> {
Ok(PathBuf::from("/tmp/mock"))
}
fn env_var(&self, key: &str) -> Option<String> {
self.env_vars.get(key).cloned()
}
fn work_dir(&self) -> Result<&Path, crate::BntoError> {
Ok(&self.work_dir)
}
fn home_dir(&self) -> Option<&Path> {
Some(&self.home_dir)
}
fn output_dir(&self) -> Option<PathBuf> {
Some(self.output_dir.clone())
}
}
fn empty_fields() -> BTreeMap<String, Value> {
BTreeMap::new()
}
fn empty_outputs() -> BTreeMap<String, Value> {
BTreeMap::new()
}
#[test]
fn fields_simple_substitution() {
let params = make_params(&[("format", json!("{{fields.format}}"))]);
let fields = make_fields(&[("format", json!("mp4"))]);
let ctx = TemplateContext {
field_values: &fields,
process_ctx: &NoopContext,
node_outputs: &empty_outputs(),
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["format"], json!("mp4"));
}
#[test]
fn fields_preserves_number_type() {
let params = make_params(&[("quality", json!("{{fields.quality}}"))]);
let fields = make_fields(&[("quality", json!(80))]);
let ctx = TemplateContext {
field_values: &fields,
process_ctx: &NoopContext,
node_outputs: &empty_outputs(),
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["quality"], json!(80));
assert!(resolved["quality"].is_number());
}
#[test]
fn fields_missing_leaves_placeholder() {
let params = make_params(&[("x", json!("{{fields.missing}}"))]);
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &NoopContext,
node_outputs: &empty_outputs(),
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["x"], json!("{{fields.missing}}"));
}
#[test]
fn env_simple_substitution() {
let params = make_params(&[("home", json!("{{env.HOME}}"))]);
let mock = MockContext::new().with_env("HOME", "/Users/test");
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &mock,
node_outputs: &empty_outputs(),
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["home"], json!("/Users/test"));
}
#[test]
fn env_missing_var_returns_empty() {
let params = make_params(&[("x", json!("{{env.NONEXISTENT}}"))]);
let mock = MockContext::new();
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &mock,
node_outputs: &empty_outputs(),
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["x"], json!(""));
}
#[test]
fn env_in_interpolation() {
let params = make_params(&[("path", json!("{{env.HOME}}/output"))]);
let mock = MockContext::new().with_env("HOME", "/Users/test");
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &mock,
node_outputs: &empty_outputs(),
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["path"], json!("/Users/test/output"));
}
#[test]
fn ctx_work_dir() {
let params = make_params(&[("dir", json!("{{ctx.work_dir}}"))]);
let mock = MockContext::new();
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &mock,
node_outputs: &empty_outputs(),
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["dir"], json!("/mock/work"));
}
#[test]
fn ctx_platform() {
let params = make_params(&[("os", json!("{{ctx.platform}}"))]);
let mock = MockContext::new();
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &mock,
node_outputs: &empty_outputs(),
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
let os = resolved["os"].as_str().unwrap();
assert!(
["macos", "linux", "windows", "unknown"].contains(&os),
"Unexpected platform: {os}"
);
}
#[test]
fn ctx_temp_dir_resolves() {
let params = make_params(&[("tmp", json!("{{ctx.temp_dir}}"))]);
let mock = MockContext::new();
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &mock,
node_outputs: &empty_outputs(),
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
let tmp = resolved["tmp"].as_str().unwrap();
assert!(
!tmp.is_empty(),
"temp_dir should resolve to a non-empty path"
);
assert!(
!tmp.contains("{{"),
"Should not contain unresolved placeholder"
);
}
#[test]
fn ctx_date_returns_iso_format() {
let params = make_params(&[("d", json!("{{ctx.date}}"))]);
let mock = MockContext::new();
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &mock,
node_outputs: &empty_outputs(),
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
let val = resolved["d"].as_str().unwrap();
assert!(
val.len() == 10 && val.chars().nth(4) == Some('-') && val.chars().nth(7) == Some('-'),
"Expected YYYY-MM-DD, got: {val}"
);
let year: u32 = val[..4].parse().unwrap();
assert!(year >= 2020, "Year should be >= 2020, got: {year}");
}
#[test]
fn ctx_time_returns_hyphen_separated() {
let params = make_params(&[("t", json!("{{ctx.time}}"))]);
let mock = MockContext::new();
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &mock,
node_outputs: &empty_outputs(),
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
let val = resolved["t"].as_str().unwrap();
assert_eq!(val.len(), 8, "Expected HH-MM-SS (8 chars), got: {val}");
let parts: Vec<&str> = val.split('-').collect();
assert_eq!(parts.len(), 3, "Expected 3 parts separated by hyphens");
let hour: u32 = parts[0].parse().unwrap();
let minute: u32 = parts[1].parse().unwrap();
let second: u32 = parts[2].parse().unwrap();
assert!(hour < 24, "Hour out of range: {hour}");
assert!(minute < 60, "Minute out of range: {minute}");
assert!(second < 60, "Second out of range: {second}");
}
#[test]
fn ctx_timestamp_returns_compact_sortable() {
let params = make_params(&[("ts", json!("{{ctx.timestamp}}"))]);
let mock = MockContext::new();
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &mock,
node_outputs: &empty_outputs(),
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
let val = resolved["ts"].as_str().unwrap();
assert_eq!(
val.len(),
15,
"Expected YYYYMMDD-HHMMSS (15 chars), got: {val}"
);
assert_eq!(
val.chars().nth(8),
Some('-'),
"Expected hyphen at position 8"
);
assert!(val[..8].chars().all(|c| c.is_ascii_digit()));
assert!(val[9..].chars().all(|c| c.is_ascii_digit()));
}
#[test]
fn ctx_date_in_interpolation() {
let params = make_params(&[("dir", json!("{{ctx.date}}-bulk-download"))]);
let mock = MockContext::new();
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &mock,
node_outputs: &empty_outputs(),
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
let val = resolved["dir"].as_str().unwrap();
assert!(
val.ends_with("-bulk-download"),
"Expected suffix, got: {val}"
);
assert!(
!val.contains("{{"),
"Should not contain unresolved placeholder"
);
}
#[test]
fn resolve_ctx_templates_public_function() {
let mock = MockContext::new();
let result = resolve_ctx_templates("{{ctx.date}}-output", &mock);
assert!(
!result.contains("{{"),
"Should not contain unresolved placeholder"
);
assert!(result.ends_with("-output"), "Suffix should be preserved");
assert!(result.len() > "-output".len(), "Date should be prepended");
}
#[test]
fn resolve_ctx_templates_no_placeholders() {
let mock = MockContext::new();
let result = resolve_ctx_templates("plain-string", &mock);
assert_eq!(result, "plain-string");
}
#[test]
fn resolve_ctx_templates_empty_string() {
let mock = MockContext::new();
let result = resolve_ctx_templates("", &mock);
assert_eq!(result, "");
}
#[test]
fn resolve_ctx_templates_paths_namespace() {
let mock = MockContext::new();
let result = resolve_ctx_templates("{{ctx.paths.output_dir}}/{{ctx.date}}-compress", &mock);
assert!(result.starts_with("/mock/home/.bnto/output/"));
assert!(result.ends_with("-compress"));
assert!(!result.contains("{{"));
}
#[test]
fn ctx_paths_home_dir_resolves() {
let params = make_params(&[("dir", json!("{{ctx.paths.home_dir}}"))]);
let mock = MockContext::new();
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &mock,
node_outputs: &empty_outputs(),
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["dir"], json!("/mock/home/.bnto"));
}
#[test]
fn ctx_paths_output_dir_resolves() {
let params = make_params(&[("dir", json!("{{ctx.paths.output_dir}}"))]);
let mock = MockContext::new();
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &mock,
node_outputs: &empty_outputs(),
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["dir"], json!("/mock/home/.bnto/output"));
}
#[test]
fn ctx_paths_work_dir_resolves() {
let params = make_params(&[("dir", json!("{{ctx.paths.work_dir}}"))]);
let mock = MockContext::new();
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &mock,
node_outputs: &empty_outputs(),
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["dir"], json!("/mock/work"));
}
#[test]
fn ctx_paths_work_dir_alias() {
let params = make_params(&[
("old", json!("{{ctx.work_dir}}")),
("new", json!("{{ctx.paths.work_dir}}")),
]);
let mock = MockContext::new();
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &mock,
node_outputs: &empty_outputs(),
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["old"], resolved["new"]);
}
#[test]
fn ctx_paths_temp_dir_resolves() {
let params = make_params(&[("dir", json!("{{ctx.paths.temp_dir}}"))]);
let mock = MockContext::new();
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &mock,
node_outputs: &empty_outputs(),
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
let val = resolved["dir"].as_str().unwrap();
assert!(!val.is_empty());
assert!(!val.contains("{{"));
}
#[test]
fn ctx_paths_temp_dir_alias() {
let params = make_params(&[
("old", json!("{{ctx.temp_dir}}")),
("new", json!("{{ctx.paths.temp_dir}}")),
]);
let mock = MockContext::new();
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &mock,
node_outputs: &empty_outputs(),
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["old"], resolved["new"]);
}
#[test]
fn ctx_paths_unknown_left_asis() {
let params = make_params(&[("x", json!("{{ctx.paths.unknown}}"))]);
let mock = MockContext::new();
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &mock,
node_outputs: &empty_outputs(),
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["x"], json!("{{ctx.paths.unknown}}"));
}
#[test]
fn ctx_paths_output_dir_in_interpolation() {
let params = make_params(&[("dir", json!("{{ctx.paths.output_dir}}/{{ctx.date}}-images"))]);
let mock = MockContext::new();
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &mock,
node_outputs: &empty_outputs(),
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
let val = resolved["dir"].as_str().unwrap();
assert!(val.starts_with("/mock/home/.bnto/output/"));
assert!(val.ends_with("-images"));
assert!(!val.contains("{{"));
}
#[test]
fn ctx_unknown_property_left_asis() {
let params = make_params(&[("x", json!("{{ctx.bogus}}"))]);
let mock = MockContext::new();
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &mock,
node_outputs: &empty_outputs(),
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["x"], json!("{{ctx.bogus}}"));
}
#[test]
fn node_ref_resolves_from_output_map() {
let params = make_params(&[("fmt", json!("{{node.compress.format}}"))]);
let mut outputs = BTreeMap::new();
outputs.insert("compress".to_string(), json!({"format": "webp"}));
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &NoopContext,
node_outputs: &outputs,
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["fmt"], json!("webp"));
}
#[test]
fn node_ref_missing_node_left_asis() {
let params = make_params(&[("x", json!("{{node.missing.prop}}"))]);
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &NoopContext,
node_outputs: &empty_outputs(),
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["x"], json!("{{node.missing.prop}}"));
}
#[test]
fn node_ref_missing_property_left_asis() {
let mut outputs = BTreeMap::new();
outputs.insert("compress".to_string(), json!({"format": "webp"}));
let params = make_params(&[("x", json!("{{node.compress.missing}}"))]);
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &NoopContext,
node_outputs: &outputs,
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["x"], json!("{{node.compress.missing}}"));
}
#[test]
fn mixed_namespaces_in_one_string() {
let params = make_params(&[("cmd", json!("{{env.HOME}}/{{ctx.platform}}/out"))]);
let mock = MockContext::new().with_env("HOME", "/Users/test");
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &mock,
node_outputs: &empty_outputs(),
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
let val = resolved["cmd"].as_str().unwrap();
assert!(val.starts_with("/Users/test/"));
assert!(val.ends_with("/out"));
assert!(!val.contains("{{"), "All placeholders should be resolved");
}
#[test]
fn fields_and_env_mixed() {
let params = make_params(&[("x", json!("{{fields.name}}-{{env.SUFFIX}}"))]);
let fields = make_fields(&[("name", json!("hello"))]);
let mock = MockContext::new().with_env("SUFFIX", "world");
let ctx = TemplateContext {
field_values: &fields,
process_ctx: &mock,
node_outputs: &empty_outputs(),
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["x"], json!("hello-world"));
}
#[test]
fn non_string_values_pass_through() {
let params = make_params(&[("n", json!(42)), ("b", json!(true))]);
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &NoopContext,
node_outputs: &empty_outputs(),
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["n"], json!(42));
assert_eq!(resolved["b"], json!(true));
}
#[test]
fn array_elements_resolved() {
let params = make_params(&[("args", json!(["--dir", "{{env.HOME}}", "-v"]))]);
let mock = MockContext::new().with_env("HOME", "/test");
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &mock,
node_outputs: &empty_outputs(),
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["args"], json!(["--dir", "/test", "-v"]));
}
#[test]
fn nested_object_resolved() {
let params = make_params(&[("config", json!({"dir": "{{env.HOME}}"}))]);
let mock = MockContext::new().with_env("HOME", "/test");
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &mock,
node_outputs: &empty_outputs(),
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["config"]["dir"], json!("/test"));
}
#[test]
fn no_placeholder_passes_through() {
let params = make_params(&[("cmd", json!("yt-dlp"))]);
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &NoopContext,
node_outputs: &empty_outputs(),
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["cmd"], json!("yt-dlp"));
}
#[test]
fn node_ref_in_interpolation() {
let params = make_params(&[("label", json!("Format: {{node.compress.format}}"))]);
let mut outputs = BTreeMap::new();
outputs.insert("compress".to_string(), json!({"format": "webp"}));
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &NoopContext,
node_outputs: &outputs,
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["label"], json!("Format: webp"));
}
fn make_item(pairs: &[(&str, Value)]) -> Option<serde_json::Map<String, Value>> {
Some(
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect(),
)
}
#[test]
fn item_simple_substitution() {
let params = make_params(&[("url", json!("{{item.url}}"))]);
let item = make_item(&[("url", json!("https://example.com/video"))]);
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &NoopContext,
node_outputs: &empty_outputs(),
loop_item: &item,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["url"], json!("https://example.com/video"));
}
#[test]
fn item_preserves_number_type() {
let params = make_params(&[("count", json!("{{item.count}}"))]);
let item = make_item(&[("count", json!(42))]);
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &NoopContext,
node_outputs: &empty_outputs(),
loop_item: &item,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["count"], json!(42));
assert!(resolved["count"].is_number());
}
#[test]
fn item_missing_key_left_asis() {
let params = make_params(&[("x", json!("{{item.missing}}"))]);
let item = make_item(&[("url", json!("https://example.com"))]);
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &NoopContext,
node_outputs: &empty_outputs(),
loop_item: &item,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["x"], json!("{{item.missing}}"));
}
#[test]
fn item_no_context_left_asis() {
let params = make_params(&[("url", json!("{{item.url}}"))]);
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &NoopContext,
node_outputs: &empty_outputs(),
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["url"], json!("{{item.url}}"));
}
#[test]
fn item_in_interpolation() {
let params = make_params(&[("path", json!("dir/{{item.group}}/out"))]);
let item = make_item(&[("group", json!("Alpha"))]);
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &NoopContext,
node_outputs: &empty_outputs(),
loop_item: &item,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["path"], json!("dir/Alpha/out"));
}
#[test]
fn item_mixed_with_fields() {
let params = make_params(&[("x", json!("{{fields.format}}_{{item.group}}"))]);
let fields = make_fields(&[("format", json!("mp4"))]);
let item = make_item(&[("group", json!("Beta"))]);
let ctx = TemplateContext {
field_values: &fields,
process_ctx: &NoopContext,
node_outputs: &empty_outputs(),
loop_item: &item,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["x"], json!("mp4_Beta"));
}
#[test]
fn input_metadata_with_extension() {
let meta = build_input_metadata("vehicles-and-monsters.csv");
assert_eq!(meta["filename"], json!("vehicles-and-monsters.csv"));
assert_eq!(meta["filename_stem"], json!("vehicles-and-monsters"));
assert_eq!(meta["filename_title"], json!("Vehicles And Monsters"));
assert_eq!(meta["filename_ext"], json!("csv"));
}
#[test]
fn input_metadata_multi_dot_extension() {
let meta = build_input_metadata("archive.tar.gz");
assert_eq!(meta["filename"], json!("archive.tar.gz"));
assert_eq!(meta["filename_stem"], json!("archive.tar"));
assert_eq!(meta["filename_ext"], json!("gz"));
}
#[test]
fn input_metadata_no_extension() {
let meta = build_input_metadata("Makefile");
assert_eq!(meta["filename"], json!("Makefile"));
assert_eq!(meta["filename_stem"], json!("Makefile"));
assert_eq!(meta["filename_ext"], json!(""));
}
#[test]
fn input_metadata_deslug_title_transform() {
let meta = build_input_metadata("my_project-name.txt");
assert_eq!(meta["filename_title"], json!("My Project Name"));
}
use crate::pipeline::PipelineNode;
fn make_input_node(id: &str) -> PipelineNode {
PipelineNode {
id: id.to_string(),
node_type: "input".to_string(),
params: serde_json::Map::new(),
fields: BTreeMap::new(),
children: None,
}
}
fn make_processing_node(id: &str, node_type: &str) -> PipelineNode {
PipelineNode {
id: id.to_string(),
node_type: node_type.to_string(),
params: serde_json::Map::new(),
fields: BTreeMap::new(),
children: None,
}
}
fn make_pipeline_file(name: &str) -> PipelineFile {
PipelineFile {
name: name.to_string(),
data: crate::file_data::FileData::Bytes(Vec::new()),
mime_type: "text/plain".to_string(),
metadata: serde_json::Map::new(),
}
}
#[test]
fn node_outputs_for_input_finds_input_node() {
let nodes = vec![
make_input_node("input"),
make_processing_node("compress", "image-compress"),
];
let files = vec![make_pipeline_file("photos.zip")];
let outputs = build_node_outputs_for_input(&nodes, &files);
assert!(outputs.contains_key("input"));
assert_eq!(outputs["input"]["filename"], json!("photos.zip"));
}
#[test]
fn node_outputs_for_input_empty_files() {
let nodes = vec![make_input_node("input")];
let files: Vec<PipelineFile> = Vec::new();
let outputs = build_node_outputs_for_input(&nodes, &files);
assert!(outputs.is_empty());
}
#[test]
fn node_outputs_for_input_no_input_node() {
let nodes = vec![make_processing_node("compress", "image-compress")];
let files = vec![make_pipeline_file("photo.jpg")];
let outputs = build_node_outputs_for_input(&nodes, &files);
assert!(outputs.is_empty());
}
#[test]
fn node_outputs_for_input_uses_first_file() {
let nodes = vec![make_input_node("input")];
let files = vec![
make_pipeline_file("first.csv"),
make_pipeline_file("second.csv"),
];
let outputs = build_node_outputs_for_input(&nodes, &files);
assert_eq!(outputs["input"]["filename"], json!("first.csv"));
}
#[test]
fn resolve_node_templates_resolves_placeholders() {
let mut outputs = BTreeMap::new();
outputs.insert("input".to_string(), build_input_metadata("data-set.csv"));
let result = resolve_node_templates("/downloads/{{node.input.filename_title}}", &outputs);
assert_eq!(result, "/downloads/Data Set");
}
#[test]
fn resolve_node_templates_no_placeholders() {
let outputs = BTreeMap::new();
let result = resolve_node_templates("plain-string", &outputs);
assert_eq!(result, "plain-string");
}
#[test]
fn resolve_node_templates_empty_string() {
let outputs = BTreeMap::new();
let result = resolve_node_templates("", &outputs);
assert_eq!(result, "");
}
#[test]
fn resolve_node_templates_multiple_placeholders() {
let mut outputs = BTreeMap::new();
outputs.insert(
"input".to_string(),
build_input_metadata("vehicles-and-monsters.csv"),
);
let result = resolve_node_templates(
"/downloads/{{node.input.filename_title}}/{{node.input.filename_ext}}",
&outputs,
);
assert_eq!(result, "/downloads/Vehicles And Monsters/csv");
}
#[test]
fn node_input_filename_title_through_full_resolution() {
let mut outputs = BTreeMap::new();
outputs.insert(
"input".to_string(),
build_input_metadata("vehicles-and-monsters.csv"),
);
let params = make_params(&[("dest", json!("/downloads/{{node.input.filename_title}}"))]);
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &NoopContext,
node_outputs: &outputs,
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["dest"], json!("/downloads/Vehicles And Monsters"));
}
#[test]
fn node_input_filename_stem_sole_placeholder() {
let mut outputs = BTreeMap::new();
outputs.insert("input".to_string(), build_input_metadata("my-data.csv"));
let params = make_params(&[("stem", json!("{{node.input.filename_stem}}"))]);
let ctx = TemplateContext {
field_values: &empty_fields(),
process_ctx: &NoopContext,
node_outputs: &outputs,
loop_item: &None,
};
let resolved = resolve_templates(¶ms, &ctx);
assert_eq!(resolved["stem"], json!("my-data"));
}
}