use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy)]
pub struct RenderContext<'a> {
pub partition: &'a str,
pub run_id: &'a str,
pub run_summary: &'a str,
pub upstream: &'a BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum RenderError {
#[error("no output is recorded for upstream `{node}`")]
MissingUpstream {
node: String,
},
#[error("path `{path}` is absent from the output of upstream `{node}`")]
MissingPath {
node: String,
path: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Segment {
Literal(String),
Partition,
RunId,
RunSummary,
Upstream {
node: String,
path: Vec<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Template {
segments: Vec<Segment>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum TemplateError {
#[error("`{{{{` at byte {0} is not closed")]
Unclosed(usize),
#[error("`{0}` is not a template reference")]
UnknownReference(String),
}
impl Template {
pub fn parse(source: &str) -> Result<Template, TemplateError> {
let mut segments = Vec::new();
let mut rest = source;
let mut offset = 0;
while let Some(open) = rest.find("{{") {
if open > 0 {
segments.push(Segment::Literal(rest[..open].to_string()));
}
let after_open = &rest[open + 2..];
let close = after_open
.find("}}")
.ok_or(TemplateError::Unclosed(offset + open))?;
segments.push(reference(after_open[..close].trim())?);
let consumed = open + 2 + close + 2;
offset += consumed;
rest = &rest[consumed..];
}
if !rest.is_empty() {
segments.push(Segment::Literal(rest.to_string()));
}
Ok(Template { segments })
}
pub fn segments(&self) -> &[Segment] {
&self.segments
}
pub fn upstream_nodes(&self) -> impl Iterator<Item = &str> {
self.segments.iter().filter_map(|segment| match segment {
Segment::Upstream { node, .. } => Some(node.as_str()),
_ => None,
})
}
pub fn render(&self, ctx: &RenderContext<'_>) -> Result<String, RenderError> {
let mut out = String::new();
for segment in &self.segments {
match segment {
Segment::Literal(text) => out.push_str(text),
Segment::Partition => out.push_str(ctx.partition),
Segment::RunId => out.push_str(ctx.run_id),
Segment::RunSummary => out.push_str(ctx.run_summary),
Segment::Upstream { node, path } => {
let mut value = ctx
.upstream
.get(node)
.ok_or_else(|| RenderError::MissingUpstream { node: node.clone() })?;
for key in path {
value = match value {
serde_json::Value::Object(map) => map.get(key),
serde_json::Value::Array(items) => {
key.parse::<usize>().ok().and_then(|i| items.get(i))
}
_ => None,
}
.ok_or_else(|| RenderError::MissingPath {
node: node.clone(),
path: path.join("."),
})?;
}
match value {
serde_json::Value::String(text) => out.push_str(text),
other => out.push_str(&other.to_string()),
}
}
}
}
Ok(out)
}
}
fn reference(text: &str) -> Result<Segment, TemplateError> {
match text {
"partition" => Ok(Segment::Partition),
"run.id" => Ok(Segment::RunId),
"run.summary" => Ok(Segment::RunSummary),
_ => match text.strip_prefix("upstream.") {
Some(rest) if !rest.is_empty() => {
let mut parts = rest.split('.');
let node = parts.next().unwrap_or_default().to_string();
let path: Vec<String> = parts.map(str::to_string).collect();
if node.is_empty() || path.iter().any(String::is_empty) {
return Err(TemplateError::UnknownReference(text.to_string()));
}
Ok(Segment::Upstream { node, path })
}
_ => Err(TemplateError::UnknownReference(text.to_string())),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_literals_and_each_reference_in_source_order() {
let template = Template::parse(
"a/{{ partition }}/{{run.id}}-{{ run.summary }}{{ upstream.extract.rows.key }}",
)
.unwrap();
assert_eq!(
template.segments(),
[
Segment::Literal("a/".into()),
Segment::Partition,
Segment::Literal("/".into()),
Segment::RunId,
Segment::Literal("-".into()),
Segment::RunSummary,
Segment::Upstream {
node: "extract".into(),
path: vec!["rows".into(), "key".into()],
},
]
);
}
#[test]
fn upstream_reference_without_a_path_is_the_whole_output() {
let template = Template::parse("{{ upstream.extract }}").unwrap();
assert_eq!(
template.segments(),
[Segment::Upstream {
node: "extract".into(),
path: vec![],
}]
);
assert_eq!(template.upstream_nodes().collect::<Vec<_>>(), ["extract"]);
}
#[test]
fn text_without_a_reference_is_one_literal() {
let template = Template::parse("plain").unwrap();
assert_eq!(template.segments(), [Segment::Literal("plain".into())]);
assert_eq!(template.upstream_nodes().count(), 0);
}
#[test]
fn render_substitutes_each_reference_and_copies_strings_as_is() {
let upstream = BTreeMap::from([(
"extract".to_string(),
serde_json::json!({"rows": 3, "key": "a/b", "list": [{"x": true}]}),
)]);
let ctx = RenderContext {
partition: "20260915",
run_id: "g-20260915-load-r0",
run_summary: "{}",
upstream: &upstream,
};
let render = |text: &str| Template::parse(text).unwrap().render(&ctx);
assert_eq!(
render("{{ partition }}/{{ run.id }}/{{ run.summary }}").unwrap(),
"20260915/g-20260915-load-r0/{}"
);
assert_eq!(render("n={{ upstream.extract.rows }}").unwrap(), "n=3");
assert_eq!(render("{{ upstream.extract.key }}").unwrap(), "a/b");
assert_eq!(render("{{ upstream.extract.list.0.x }}").unwrap(), "true");
assert_eq!(
render("{{ upstream.extract }}").unwrap(),
r#"{"key":"a/b","list":[{"x":true}],"rows":3}"#
);
assert_eq!(
render("{{ upstream.other.rows }}"),
Err(RenderError::MissingUpstream {
node: "other".into()
})
);
assert_eq!(
render("{{ upstream.extract.rows.deeper }}"),
Err(RenderError::MissingPath {
node: "extract".into(),
path: "rows.deeper".into(),
})
);
}
#[test]
fn rejects_an_unclosed_reference() {
assert_eq!(
Template::parse("ab{{ partition"),
Err(TemplateError::Unclosed(2))
);
}
#[test]
fn rejects_an_unknown_reference() {
for text in [
"{{ }}",
"{{ date }}",
"{{ upstream. }}",
"{{ upstream.a..b }}",
"{{ run }}",
] {
assert!(
matches!(
Template::parse(text),
Err(TemplateError::UnknownReference(_))
),
"{text}"
);
}
}
}