1use std::collections::BTreeMap;
10use std::sync::Arc;
11
12use arrow::array::RecordBatch;
13use chrono::{DateTime, NaiveDate, Utc};
14use minijinja::{Environment, Value};
15use serde::{Deserialize, Serialize};
16use serde_json::{Map as JsonMap, Value as Json};
17
18use crate::dates::{Calendar, WeekNumbering, WeekStart};
19use crate::message::QueryResult;
20use crate::numbers::Locale;
21use crate::render::RunContext;
22
23pub const FORMATS: &[&str] = &["html", "pdf"];
25
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct Context {
29 #[serde(default)]
32 pub file: Option<String>,
33 pub project_root: String,
35 pub run: RunInfo,
36 #[serde(default)]
38 pub vars: JsonMap<String, Json>,
39 #[serde(default)]
41 pub cli_vars: BTreeMap<String, String>,
42 pub locale: String,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub each: Option<JsonMap<String, Json>>,
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
53pub struct Check {
54 #[serde(default)]
55 pub file: Option<String>,
56 pub project_root: String,
57 #[serde(default)]
58 pub queries: Vec<String>,
59}
60
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63pub struct RunInfo {
64 pub report: String,
65 #[serde(default)]
66 pub set: Option<String>,
67 pub target: String,
68 #[serde(default)]
69 pub schedule: Option<String>,
70 pub date: NaiveDate,
71 pub now: DateTime<Utc>,
72 #[serde(default)]
73 pub scheduled_at: Option<DateTime<Utc>>,
74 pub timezone: String,
75 pub week_start: String,
76 pub week_numbering: String,
77}
78
79impl Context {
80 pub fn new(
82 file: Option<&std::path::Path>,
83 project_root: &std::path::Path,
84 run: &RunContext,
85 vars: JsonMap<String, Json>,
86 cli_vars: BTreeMap<String, String>,
87 ) -> Context {
88 let abs = |p: &std::path::Path| {
90 std::path::absolute(p)
91 .unwrap_or_else(|_| p.to_path_buf())
92 .to_string_lossy()
93 .into_owned()
94 };
95 Context {
96 file: file.map(abs),
97 project_root: abs(project_root),
98 run: RunInfo {
99 report: run.report.clone(),
100 set: run.set.clone(),
101 target: run.target.clone(),
102 schedule: run.schedule.clone(),
103 date: run.date,
104 now: run.now,
105 scheduled_at: run.scheduled_at,
106 timezone: run.calendar.tz.name().to_string(),
107 week_start: match run.calendar.week_start {
108 WeekStart::Monday => "monday",
109 WeekStart::Sunday => "sunday",
110 }
111 .into(),
112 week_numbering: match run.calendar.numbering {
113 WeekNumbering::Iso => "iso",
114 WeekNumbering::Us => "us",
115 }
116 .into(),
117 },
118 vars,
119 cli_vars,
120 locale: run.locale.tag.to_string(),
121 each: None,
122 }
123 }
124
125 pub fn calendar(&self) -> Result<Calendar, String> {
127 Ok(Calendar {
128 tz: crate::dates::parse_tz(&self.run.timezone)?,
129 week_start: WeekStart::parse(&self.run.week_start)
130 .ok_or_else(|| format!("unknown week start `{}`", self.run.week_start))?,
131 numbering: WeekNumbering::parse(&self.run.week_numbering)
132 .ok_or_else(|| format!("unknown week numbering `{}`", self.run.week_numbering))?,
133 })
134 }
135
136 pub fn locale(&self) -> Result<Locale, String> {
137 Locale::parse(&self.locale)
138 }
139
140 pub fn run_context(&self) -> Result<RunContext, String> {
142 Ok(RunContext {
143 report: self.run.report.clone(),
144 set: self.run.set.clone(),
145 target: self.run.target.clone(),
146 schedule: self.run.schedule.clone(),
147 date: self.run.date,
148 now: self.run.now,
149 scheduled_at: self.run.scheduled_at,
150 calendar: self.calendar()?,
151 locale: self.locale()?,
152 })
153 }
154}
155
156pub fn environment(ctx: &Context) -> Result<Environment<'static>, String> {
160 let mut env = Environment::new();
161 env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
162 crate::render::add_common(
163 &mut env,
164 ctx.run_context()?,
165 ctx.vars.clone(),
166 ctx.cli_vars.clone(),
167 );
168 if let Some(each) = &ctx.each {
169 env.add_global("each", Value::from(minijinja::value::Serde(each.clone())));
170 }
171 Ok(env)
172}
173
174pub fn query_result(columns: Vec<String>, batches: &[RecordBatch], cal: Calendar) -> QueryResult {
176 let mut rows = Vec::new();
177 for b in batches {
178 rows.extend(crate::values::typed_rows(b, cal));
179 }
180 QueryResult {
181 columns: Arc::new(columns),
182 row_count: rows.len() as u64,
183 rows,
184 }
185}
186
187pub fn results_value(results: Vec<(String, Arc<QueryResult>)>) -> Value {
189 Value::from_object(Ordered(
190 results.into_iter().map(|(name, r)| (name, r.value())).collect(),
191 ))
192}
193
194#[derive(Debug)]
196struct Ordered(Vec<(String, Value)>);
197
198impl minijinja::value::Object for Ordered {
199 fn repr(self: &Arc<Self>) -> minijinja::value::ObjectRepr {
200 minijinja::value::ObjectRepr::Map
201 }
202
203 fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
204 let k = key.as_str()?;
205 self.0.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone())
206 }
207
208 fn enumerate(self: &Arc<Self>) -> minijinja::value::Enumerator {
209 minijinja::value::Enumerator::Values(self.0.iter().map(|(n, _)| Value::from(n.as_str())).collect())
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 fn ctx() -> Context {
218 Context {
219 file: Some("/p/s.html".into()),
220 project_root: "/p".into(),
221 run: RunInfo {
222 report: "statement".into(),
223 set: Some("acme".into()),
224 target: "prod".into(),
225 schedule: None,
226 date: NaiveDate::from_ymd_opt(2026, 9, 30).unwrap(),
227 now: "2026-10-01T02:00:00Z".parse().unwrap(),
228 scheduled_at: None,
229 timezone: "Australia/Sydney".into(),
230 week_start: "monday".into(),
231 week_numbering: "iso".into(),
232 },
233 vars: JsonMap::from_iter([("client".to_string(), Json::from("Acme"))]),
234 cli_vars: BTreeMap::from([("terms".to_string(), "30".to_string())]),
235 locale: "de-DE".into(),
236 each: None,
237 }
238 }
239
240 #[test]
241 fn round_trips_as_json() {
242 let c = ctx();
243 let back: Context = serde_json::from_value(serde_json::to_value(&c).unwrap()).unwrap();
244 assert_eq!(back, c);
245 }
246
247 #[test]
248 fn environment_reads_what_core_templates_read() {
249 let env = environment(&ctx()).unwrap();
250 let out = env
251 .render_str(
252 "{{ run.report }}|{{ run.set }}|{{ target.name }}|{{ run.date }}|{{ run.timezone }}|{{ var('client') }}|{{ var('terms') + 1 }}|{{ 1234.5 | number(2) }}|{{ var('x', 'd') }}",
253 (),
254 )
255 .unwrap();
256 assert_eq!(
257 out,
258 "statement|acme|prod|2026-09-30|Australia/Sydney|Acme|31|1.234,50|d"
259 );
260 assert_eq!(env.render_str("{{ run.date.yyyymm }}", ()).unwrap(), "202609");
261 let e = env.render_str("{{ var('missing') }}", ()).unwrap_err();
262 assert!(e.to_string().contains("no value and no default"), "{e}");
263 let e = env.render_str("{{ env_var('HOME') }}", ()).unwrap_err();
264 assert!(e.to_string().contains("can't be used in a document"), "{e}");
265 }
266
267 #[test]
268 fn results_keep_the_given_order() {
269 let r = |n: u64| {
270 Arc::new(QueryResult {
271 columns: Arc::new(vec!["a".into()]),
272 rows: Vec::new(),
273 row_count: n,
274 })
275 };
276 let v = results_value(vec![("zeta".into(), r(1)), ("alpha".into(), r(2))]);
277 let env = Environment::new();
278 let out = env
279 .render_str("{% for k in results %}{{ k }}={{ results[k].row_count }} {% endfor %}{{ results.alpha.row_count }}", minijinja::context! { results => v })
280 .unwrap();
281 assert_eq!(out, "zeta=1 alpha=2 2");
282 }
283}