use std::collections::BTreeMap;
use std::sync::Arc;
use arrow::array::RecordBatch;
use chrono::{DateTime, NaiveDate, Utc};
use minijinja::{Environment, Value};
use serde::{Deserialize, Serialize};
use serde_json::{Map as JsonMap, Value as Json};
use crate::dates::{Calendar, WeekNumbering, WeekStart};
use crate::message::QueryResult;
use crate::numbers::Locale;
use crate::render::RunContext;
pub const FORMATS: &[&str] = &["html", "pdf"];
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Context {
#[serde(default)]
pub file: Option<String>,
pub project_root: String,
pub run: RunInfo,
#[serde(default)]
pub vars: JsonMap<String, Json>,
#[serde(default)]
pub cli_vars: BTreeMap<String, String>,
pub locale: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub each: Option<JsonMap<String, Json>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Check {
#[serde(default)]
pub file: Option<String>,
pub project_root: String,
#[serde(default)]
pub queries: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunInfo {
pub report: String,
#[serde(default)]
pub set: Option<String>,
pub target: String,
#[serde(default)]
pub schedule: Option<String>,
pub date: NaiveDate,
pub now: DateTime<Utc>,
#[serde(default)]
pub scheduled_at: Option<DateTime<Utc>>,
pub timezone: String,
pub week_start: String,
pub week_numbering: String,
}
impl Context {
pub fn new(
file: Option<&std::path::Path>,
project_root: &std::path::Path,
run: &RunContext,
vars: JsonMap<String, Json>,
cli_vars: BTreeMap<String, String>,
) -> Context {
let abs = |p: &std::path::Path| {
std::path::absolute(p)
.unwrap_or_else(|_| p.to_path_buf())
.to_string_lossy()
.into_owned()
};
Context {
file: file.map(abs),
project_root: abs(project_root),
run: RunInfo {
report: run.report.clone(),
set: run.set.clone(),
target: run.target.clone(),
schedule: run.schedule.clone(),
date: run.date,
now: run.now,
scheduled_at: run.scheduled_at,
timezone: run.calendar.tz.name().to_string(),
week_start: match run.calendar.week_start {
WeekStart::Monday => "monday",
WeekStart::Sunday => "sunday",
}
.into(),
week_numbering: match run.calendar.numbering {
WeekNumbering::Iso => "iso",
WeekNumbering::Us => "us",
}
.into(),
},
vars,
cli_vars,
locale: run.locale.tag.to_string(),
each: None,
}
}
pub fn calendar(&self) -> Result<Calendar, String> {
Ok(Calendar {
tz: crate::dates::parse_tz(&self.run.timezone)?,
week_start: WeekStart::parse(&self.run.week_start)
.ok_or_else(|| format!("unknown week start `{}`", self.run.week_start))?,
numbering: WeekNumbering::parse(&self.run.week_numbering)
.ok_or_else(|| format!("unknown week numbering `{}`", self.run.week_numbering))?,
})
}
pub fn locale(&self) -> Result<Locale, String> {
Locale::parse(&self.locale)
}
pub fn run_context(&self) -> Result<RunContext, String> {
Ok(RunContext {
report: self.run.report.clone(),
set: self.run.set.clone(),
target: self.run.target.clone(),
schedule: self.run.schedule.clone(),
date: self.run.date,
now: self.run.now,
scheduled_at: self.run.scheduled_at,
calendar: self.calendar()?,
locale: self.locale()?,
})
}
}
pub fn environment(ctx: &Context) -> Result<Environment<'static>, String> {
let mut env = Environment::new();
env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
crate::render::add_common(
&mut env,
ctx.run_context()?,
ctx.vars.clone(),
ctx.cli_vars.clone(),
);
if let Some(each) = &ctx.each {
env.add_global("each", Value::from(minijinja::value::Serde(each.clone())));
}
Ok(env)
}
pub fn query_result(columns: Vec<String>, batches: &[RecordBatch], cal: Calendar) -> QueryResult {
let mut rows = Vec::new();
for b in batches {
rows.extend(crate::values::typed_rows(b, cal));
}
QueryResult {
columns: Arc::new(columns),
row_count: rows.len() as u64,
rows,
}
}
pub fn results_value(results: Vec<(String, Arc<QueryResult>)>) -> Value {
Value::from_object(Ordered(
results.into_iter().map(|(name, r)| (name, r.value())).collect(),
))
}
#[derive(Debug)]
struct Ordered(Vec<(String, Value)>);
impl minijinja::value::Object for Ordered {
fn repr(self: &Arc<Self>) -> minijinja::value::ObjectRepr {
minijinja::value::ObjectRepr::Map
}
fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
let k = key.as_str()?;
self.0.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone())
}
fn enumerate(self: &Arc<Self>) -> minijinja::value::Enumerator {
minijinja::value::Enumerator::Values(self.0.iter().map(|(n, _)| Value::from(n.as_str())).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx() -> Context {
Context {
file: Some("/p/s.html".into()),
project_root: "/p".into(),
run: RunInfo {
report: "statement".into(),
set: Some("acme".into()),
target: "prod".into(),
schedule: None,
date: NaiveDate::from_ymd_opt(2026, 9, 30).unwrap(),
now: "2026-10-01T02:00:00Z".parse().unwrap(),
scheduled_at: None,
timezone: "Australia/Sydney".into(),
week_start: "monday".into(),
week_numbering: "iso".into(),
},
vars: JsonMap::from_iter([("client".to_string(), Json::from("Acme"))]),
cli_vars: BTreeMap::from([("terms".to_string(), "30".to_string())]),
locale: "de-DE".into(),
each: None,
}
}
#[test]
fn round_trips_as_json() {
let c = ctx();
let back: Context = serde_json::from_value(serde_json::to_value(&c).unwrap()).unwrap();
assert_eq!(back, c);
}
#[test]
fn environment_reads_what_core_templates_read() {
let env = environment(&ctx()).unwrap();
let out = env
.render_str(
"{{ run.report }}|{{ run.set }}|{{ target.name }}|{{ run.date }}|{{ run.timezone }}|{{ var('client') }}|{{ var('terms') + 1 }}|{{ 1234.5 | number(2) }}|{{ var('x', 'd') }}",
(),
)
.unwrap();
assert_eq!(
out,
"statement|acme|prod|2026-09-30|Australia/Sydney|Acme|31|1.234,50|d"
);
assert_eq!(env.render_str("{{ run.date.yyyymm }}", ()).unwrap(), "202609");
let e = env.render_str("{{ var('missing') }}", ()).unwrap_err();
assert!(e.to_string().contains("no value and no default"), "{e}");
let e = env.render_str("{{ env_var('HOME') }}", ()).unwrap_err();
assert!(e.to_string().contains("can't be used in a document"), "{e}");
}
#[test]
fn results_keep_the_given_order() {
let r = |n: u64| {
Arc::new(QueryResult {
columns: Arc::new(vec!["a".into()]),
rows: Vec::new(),
row_count: n,
})
};
let v = results_value(vec![("zeta".into(), r(1)), ("alpha".into(), r(2))]);
let env = Environment::new();
let out = env
.render_str("{% for k in results %}{{ k }}={{ results[k].row_count }} {% endfor %}{{ results.alpha.row_count }}", minijinja::context! { results => v })
.unwrap();
assert_eq!(out, "zeta=1 alpha=2 2");
}
}