use chrono::{NaiveDate, Utc};
use crate::config::DestinationConfig;
#[derive(Debug, Clone)]
pub struct PlaceholderContext {
pub date: NaiveDate,
pub export_name: String,
pub run_id: Option<String>,
}
impl PlaceholderContext {
pub fn for_today(export_name: impl Into<String>) -> Self {
Self {
date: Utc::now().date_naive(),
export_name: export_name.into(),
run_id: None,
}
}
pub fn for_date(date: NaiveDate, export_name: impl Into<String>) -> Self {
Self {
date,
export_name: export_name.into(),
run_id: None,
}
}
pub fn with_run_id(mut self, run_id: impl Into<String>) -> Self {
self.run_id = Some(run_id.into());
self
}
}
pub fn apply(s: &str, ctx: &PlaceholderContext) -> String {
let date = ctx.date.format("%Y-%m-%d").to_string();
let mut out = s
.replace("{date}", &date)
.replace("{export}", &ctx.export_name)
.replace("{table}", &ctx.export_name);
if let Some(rid) = &ctx.run_id {
out = out.replace("{run_id}", rid);
}
out
}
pub fn expand_destination_partition(
mut dest: DestinationConfig,
segment: &str,
) -> DestinationConfig {
dest.path = dest.path.map(|s| s.replace("{partition}", segment));
dest.prefix = dest.prefix.map(|s| s.replace("{partition}", segment));
dest
}
pub fn expand_destination(
mut dest: DestinationConfig,
ctx: &PlaceholderContext,
) -> DestinationConfig {
dest.path = dest.path.map(|s| apply(&s, ctx));
dest.prefix = dest.prefix.map(|s| apply(&s, ctx));
dest
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::DestinationType;
use chrono::NaiveDate;
fn ctx_on(date_str: &str, export: &str) -> PlaceholderContext {
PlaceholderContext::for_date(
NaiveDate::parse_from_str(date_str, "%Y-%m-%d").unwrap(),
export,
)
}
#[test]
fn apply_substitutes_date_export_table() {
let ctx = ctx_on("2026-05-21", "orders");
assert_eq!(
apply("runs/{date}/{export}/{table}", &ctx),
"runs/2026-05-21/orders/orders",
);
}
#[test]
fn apply_leaves_unknown_tokens_verbatim() {
let ctx = ctx_on("2026-05-21", "orders");
assert_eq!(
apply("runs/{date}/{unknown}/", &ctx),
"runs/2026-05-21/{unknown}/",
);
}
#[test]
fn apply_run_id_only_when_set() {
let ctx = ctx_on("2026-05-21", "orders");
assert_eq!(apply("runs/{run_id}/", &ctx), "runs/{run_id}/");
let ctx = ctx.with_run_id("r-abc123");
assert_eq!(apply("runs/{run_id}/", &ctx), "runs/r-abc123/");
}
#[test]
fn apply_is_idempotent_on_already_expanded_string() {
let ctx = ctx_on("2026-05-21", "orders");
let once = apply("runs/{date}/{export}/", &ctx);
let twice = apply(&once, &ctx);
assert_eq!(once, twice);
assert_eq!(once, "runs/2026-05-21/orders/");
}
#[test]
fn expand_destination_rewrites_path_and_prefix() {
let dest = DestinationConfig {
destination_type: DestinationType::S3,
prefix: Some("exports/{date}/{export}/".into()),
path: Some("/scratch/{table}/{date}".into()),
..Default::default()
};
let ctx = ctx_on("2026-05-21", "orders");
let expanded = expand_destination(dest, &ctx);
assert_eq!(
expanded.prefix.as_deref(),
Some("exports/2026-05-21/orders/")
);
assert_eq!(expanded.path.as_deref(), Some("/scratch/orders/2026-05-21"));
}
#[test]
fn expand_destination_no_placeholders_unchanged() {
let dest = DestinationConfig {
destination_type: DestinationType::Local,
path: Some("./out".into()),
..Default::default()
};
let ctx = ctx_on("2026-05-21", "orders");
let expanded = expand_destination(dest, &ctx);
assert_eq!(expanded.path.as_deref(), Some("./out"));
assert!(expanded.prefix.is_none());
}
#[test]
fn expand_destination_with_run_id() {
let dest = DestinationConfig {
destination_type: DestinationType::S3,
prefix: Some("runs/{date}/{run_id}/{export}/".into()),
..Default::default()
};
let ctx = ctx_on("2026-05-21", "orders").with_run_id("r-abc123");
let expanded = expand_destination(dest, &ctx);
assert_eq!(
expanded.prefix.as_deref(),
Some("runs/2026-05-21/r-abc123/orders/"),
);
}
}