Skip to main content

dre_core/
document.rs

1//! The context of a document template: what core sends the `document` plugin (the html and pdf
2//! formats) in a format `write` request's `template`, and the Jinja environment the plugin
3//! builds from it. Rendering happens in the plugin; core only runs the queries and sends this.
4//!
5//! The plugin builds its environment with [`environment`], so `var()`, `run.*`, `target.name`,
6//! the date functions and the number filters behave exactly as in core's own templates (one
7//! implementation, no copy to drift), and `results.<query>` is the same value messages read.
8
9use 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
23/// The formats rendered from a document template, which get a [`Context`] in every `write`.
24pub const FORMATS: &[&str] = &["html", "pdf"];
25
26/// A format `write` request's `template` for the html and pdf formats.
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct Context {
29    /// The template file, resolved by core ([`crate::template_file`]), absolute. `None`: the
30    /// output names no template, and the plugin uses its default (a table per query).
31    #[serde(default)]
32    pub file: Option<String>,
33    /// The project root: includes and assets are looked up inside it, never outside.
34    pub project_root: String,
35    pub run: RunInfo,
36    /// The Binding's vars with the schedule's on top: what `var()` reads after `--var`.
37    #[serde(default)]
38    pub vars: JsonMap<String, Json>,
39    /// `--var` values, as typed on the command line: `var()` reads them first.
40    #[serde(default)]
41    pub cli_vars: BTreeMap<String, String>,
42    /// The Binding's `locale:` tag, for the number filters.
43    pub locale: String,
44    /// A per-row output's driver row: `each.<column>`.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub each: Option<JsonMap<String, Json>>,
47}
48
49/// What `validate` sends the plugin for an html or pdf output, so `dre validate` checks its
50/// template: the file, the project root, and the output's queries (what `results.<name>` may
51/// name), in order.
52#[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/// `run.*` and `target.name`, as plain values.
62#[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    /// Build the context of a run.
81    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        // Absolute: the plugin checks every file it loads is inside the project root.
89        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    /// The run's calendar: its timezone and week settings.
126    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    /// The `run.*` context core's templates read.
141    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
156/// A Jinja environment with everything core's templates read: `var()`, `run.*`, `target.name`,
157/// the date functions and the number filters. Undefined values are errors. The caller adds its
158/// loader, escaping and its own filters.
159pub 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
174/// One query's result from its Arrow batches, as `results.<query>` holds it (every row).
175pub 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
187/// `results`: each query's result by name, iterating in the order given (the YAML's).
188pub 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/// A map that iterates its keys in insertion order.
195#[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}