Skip to main content

dre_core/
run.rs

1//! The run path: turn a resolved project into delivered files.
2//!
3//! Per Binding: parse pass → render → split → (unmanaged check) → execute, each query on its
4//! connection's session (one per connection, opened on first use, in strict YAML order) →
5//! format into `target/run/` → schema-drift check → deliver → snapshot → `run_results.json`.
6
7use std::collections::BTreeMap;
8use std::fs::File;
9use std::path::{Path, PathBuf};
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::{Arc, Mutex};
12use std::time::{Duration, Instant};
13
14use arrow::array::RecordBatch;
15use arrow::datatypes::SchemaRef;
16use arrow::ipc::reader::FileReader;
17use arrow::ipc::writer::FileWriter;
18use chrono::NaiveDate;
19use dre_protocol::host::{Execution, HostError, LogSink, PluginProcess};
20use dre_protocol::msg::{ColumnOptions, DeliveryFile, ResultSetMeta};
21use dre_protocol::{CAP_LOAD, CAP_MESSAGE, CAP_MESSAGE_ONLY, CAP_MULTI_FILE, CAP_READ_ONLY, CAP_SESSIONS};
22use serde::Serialize;
23use serde_json::{Map as JsonMap, Value as Json, json};
24
25use crate::codes::{Code, ErrorCode};
26use crate::dates::Calendar;
27use crate::engine::{CancelToken, Events, RunEvent, RunStore};
28use crate::lookups::Table;
29use crate::message::QueryResult;
30use crate::parse::ParsedBinding;
31use crate::profiles::{Entry, LOCAL_TYPE, ProfileTarget, Profiles, Role};
32use crate::project::{Binding, PluginKind, Project, QueryEntry, Report};
33use crate::render::{
34    Column, Connection, Connections, Mode, QueryRows, QueryRunner, RenderError, Renderer, RendererConfig,
35};
36use crate::run_results::{self, Delivery, RunResults};
37use crate::selector;
38use crate::sqlsplit::{self, StatementKind};
39
40pub(crate) mod per_row;
41
42/// What the caller asked for.
43#[derive(Debug, Clone, Default)]
44pub struct RunOptions {
45    pub selector: Option<String>,
46    /// `--set <name>` or `--set all`.
47    pub set: Option<String>,
48    /// `--target` as given; the run's target is [`Project::target_name`].
49    pub target: Option<String>,
50    /// `--profile`: the inherited connection, for every Binding.
51    pub profile: Option<String>,
52    pub vars: BTreeMap<String, String>,
53    pub output_name: Option<String>,
54    pub output_path: Option<String>,
55    pub dry_run: bool,
56    pub no_deliver: bool,
57    /// Optional isolated run store, plus typed rows under report/binding/query.json.
58    pub output_json: Option<PathBuf>,
59    /// `--from-results <DIR>`: replay results saved with `--output-json` instead of running any
60    /// query (implies `no_deliver`).
61    pub from_results: Option<PathBuf>,
62    /// `--row-concurrency` or `DRE_ROW_CONCURRENCY`: rows of a per-row output at once.
63    pub row_concurrency: Option<usize>,
64    /// `--hold`: hold every delivery for `dre release`.
65    pub hold: bool,
66    /// `--retry-failed <run id>`: redo that run's failed and never-started rows of its per-row
67    /// outputs, from its saved results.
68    pub retry_failed: Option<String>,
69    /// `--only-keys`: make only these rows of each per-row output.
70    pub only_keys: Option<Vec<String>>,
71    /// `--limit-rows`: make at most this many rows of each per-row output.
72    pub limit_rows: Option<usize>,
73    /// `--preview --unmask`: show per-row values in the preview as they are.
74    pub unmask: bool,
75    /// `--rate`: every destination's rate for this run, over its `rate:`.
76    pub rate: Option<crate::rate::Rate>,
77    /// `--preview [N]`: row limit per query.
78    pub preview: Option<u64>,
79    pub accept_schema_change: bool,
80    /// Whether a person is at a terminal to answer prompts.
81    pub interactive: bool,
82    /// `run.date` (`DRE_RUN_DATE`).
83    pub date: Option<NaiveDate>,
84    /// The instant this run was scheduled for (`DRE_RUN_AT`): `run.now` and `run.scheduled_at`,
85    /// and `run.date` in the run's timezone unless `date` is set.
86    pub scheduled_at: Option<chrono::DateTime<chrono::Utc>>,
87    /// `validate --live`: check statements instead of executing them.
88    pub live_check: bool,
89    /// `--schedule <name>`: run exactly the Bindings that schedule targets, with its vars.
90    pub schedule: Option<String>,
91    /// `--timezone` or `DRE_TIMEZONE`: above every configured `timezone:`.
92    pub timezone: Option<String>,
93    /// SHA-256 of the manifest this command wrote, for `run_results.json`.
94    pub manifest_checksum: Option<String>,
95    /// Stops the run: no further Binding, statement or delivery starts once it's cancelled.
96    pub cancel: CancelToken,
97    /// `--keep-runs` or `DRE_KEEP_RUNS`: above `flags: keep_runs`.
98    pub keep_runs: Option<usize>,
99    /// `--threads` or `DRE_THREADS`: how many Bindings may run at once in the whole run, over
100    /// each connection's `threads:`.
101    pub threads: Option<usize>,
102}
103
104impl RunOptions {
105    /// What the run was asked to do, for `run_results.json` and the log.
106    pub fn params(&self, date: NaiveDate) -> run_results::Params {
107        run_results::Params {
108            selector: self.selector.clone(),
109            set: self.set.clone(),
110            schedule: self.schedule.clone(),
111            target: self.target.clone(),
112            profile: self.profile.clone(),
113            vars: self.vars.clone(),
114            run_date: date.to_string(),
115            scheduled_at: self.scheduled_at.map(rfc3339),
116            timezone: self.timezone.clone(),
117            output_name: self.output_name.clone(),
118            output_path: self.output_path.clone(),
119            dry_run: self.dry_run,
120            preview: self.preview,
121            accept_schema_change: self.accept_schema_change,
122        }
123    }
124}
125
126/// How much a message matters: `Info` is shown by default, `Debug` with `-v`.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
128pub enum Level {
129    Debug,
130    Info,
131}
132
133/// How the run reports progress. The engine emits structured events; the CLI decides how
134/// they look (colour, progress bar, JSON, log file).
135pub trait Ui {
136    /// The run's target and each used profile's entry, once they're all known to exist.
137    fn targets(&mut self, _targets: &RunTargets) {}
138    /// The number of Bindings about to run, once Sets are resolved.
139    fn plan(&mut self, _bindings: usize) {}
140    fn binding_start(&mut self, _report: &str, _set: Option<&str>) {}
141    /// The running Binding's log file, in its run folder (a real run only).
142    fn binding_log(&mut self, _path: &Path) {}
143    /// Right after `binding_start`: the schedule it runs under (if any) and every var it uses.
144    fn binding_vars(
145        &mut self,
146        _schedule: Option<&str>,
147        _schedule_vars: Option<&JsonMap<String, Json>>,
148        _vars: &JsonMap<String, Json>,
149    ) {
150    }
151    /// One step inside the current Binding: a short verb, a detail, and how long it took.
152    fn step(&mut self, level: Level, verb: &str, detail: &str, elapsed: Option<Duration>);
153    fn warn(&mut self, msg: &str);
154    fn binding_end(&mut self, _outcome: &BindingOutcome) {}
155    /// A Binding was compiled (`--dry-run`, `dre compile`, `dre validate`): what it would do.
156    fn compiled(&mut self, _plan: &BindingPlan) {}
157    /// `--preview`: a rendered message, shown instead of sent.
158    fn message(&mut self, _message: &ShownMessage) {}
159    /// Ask which Set to run; `None` means "all".
160    fn choose_set(&mut self, report: &str, sets: &[String]) -> Result<Option<String>, String>;
161    /// Bindings running at once: `label` started (`running`) or finished. Its own events come
162    /// later, together.
163    fn running(&mut self, _label: &str, _running: bool) {}
164    /// Where plugin stderr goes.
165    fn plugin_log(&self) -> LogSink;
166    /// Where the full text of every statement sent to a source goes: `(label, sql)`.
167    fn sql_log(&self) -> LogSink {
168        Arc::new(|_, _| {})
169    }
170}
171
172/// A message `--preview` shows.
173#[derive(Debug, Clone, Serialize)]
174pub struct ShownMessage {
175    /// The output's `name:`.
176    pub output: Option<String>,
177    pub title: String,
178    /// Portable Markdown.
179    pub text: String,
180    /// Length, the row sample, `when:` and each destination's limit.
181    pub notes: Vec<String>,
182}
183
184pub use crate::run_results::Status;
185
186#[derive(Debug, Clone, Serialize)]
187pub struct BindingOutcome {
188    pub report: String,
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub set: Option<String>,
191    pub binding: String,
192    pub status: Status,
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub error: Option<String>,
195    /// `error`'s code.
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub error_code: Option<ErrorCode>,
198    pub files: Vec<PathBuf>,
199    /// One line describing what the Binding produced (result sets, rows, outputs, delivery).
200    pub summary: String,
201    /// The schedule this ran under (`--schedule`), and the vars it layered in.
202    pub schedule: Option<String>,
203    pub schedule_vars: Option<JsonMap<String, Json>>,
204    /// Every var the Binding rendered with: its own, the schedule's, then `--var`.
205    pub vars: JsonMap<String, Json>,
206    /// The run's timezone (IANA name); empty when the Binding never started.
207    #[serde(skip_serializing_if = "String::is_empty")]
208    pub timezone: String,
209    #[serde(skip)]
210    pub elapsed: Duration,
211}
212
213/// What a compiled Binding would use and produce if it ran.
214#[derive(Debug, Clone, Serialize)]
215pub struct BindingPlan {
216    pub report: String,
217    pub set: Option<String>,
218    /// Rendered SQL files, relative to the project.
219    pub compiled: Vec<PathBuf>,
220    /// The inherited connection, if any.
221    pub profile: Option<String>,
222    /// Each query's connection, its type and the sources it reads.
223    pub queries: Vec<PlannedQuery>,
224    pub target: String,
225    pub format: String,
226    /// The file written under `target/run/` (a single-table format writes one per result set
227    /// when there are several).
228    pub output: PathBuf,
229    pub destinations: Vec<PlannedDestination>,
230    /// Every output (the fields above repeat the first one).
231    pub outputs: Vec<PlannedOutput>,
232    pub schedules: Vec<String>,
233}
234
235#[derive(Debug, Clone, Serialize)]
236pub struct PlannedOutput {
237    pub name: Option<String>,
238    pub format: String,
239    /// The file written under `target/run/`.
240    pub output: PathBuf,
241    pub destinations: Vec<PlannedDestination>,
242}
243
244#[derive(Debug, Clone, Serialize)]
245pub struct PlannedQuery {
246    pub query: String,
247    pub connection: String,
248    #[serde(rename = "type")]
249    pub kind: String,
250    /// The connection's entry: the run's target, else its own `target:`, else `dev`.
251    pub target: String,
252    #[serde(skip_serializing_if = "Vec::is_empty")]
253    pub sources: Vec<String>,
254}
255
256#[derive(Debug, Clone, Serialize)]
257pub struct PlannedDestination {
258    pub profile: String,
259    /// The destination type of its entry for this run; `None` for `deliver: false`.
260    pub kind: Option<String>,
261    /// Its entry: the run's target, else the profile's own `target:`, else `dev`.
262    pub target: Option<String>,
263    pub path: Option<String>,
264    /// False for a `deliver: false` entry: the output stays in the target path.
265    pub delivers: bool,
266}
267
268#[derive(Debug, Default)]
269pub struct RunSummary {
270    pub outcomes: Vec<BindingOutcome>,
271    /// Selection failed, or a profile has no entry for the run: nothing ran.
272    pub error: Option<String>,
273    /// `error` is about a profile's missing entry.
274    pub missing_entry: bool,
275    /// Why the run was cancelled, if it was.
276    pub cancelled: Option<crate::engine::CancelReason>,
277    /// Bindings that never started because the run was cancelled.
278    pub not_run: usize,
279}
280
281impl RunSummary {
282    pub fn failed(&self) -> bool {
283        self.error.is_some()
284            || self.cancelled.is_some()
285            || self
286                .outcomes
287                .iter()
288                .any(|o| matches!(o.status, Status::Error | Status::Cancelled | Status::TimedOut))
289    }
290
291    /// The code of what failed first: why nothing ran, else the first failed Binding's.
292    pub fn error_code(&self) -> Option<ErrorCode> {
293        if self.error.is_some() {
294            return Some(ErrorCode::Core(if self.missing_entry {
295                Code::MissingTargetEntry
296            } else {
297                Code::InvalidSelector
298            }));
299        }
300        self.outcomes
301            .iter()
302            .find(|o| matches!(o.status, Status::Error | Status::Cancelled | Status::TimedOut))
303            .map(|o| o.error_code.clone().unwrap_or(ErrorCode::Core(Code::RunFailed)))
304            .or_else(|| {
305                self.cancelled.map(|r| {
306                    ErrorCode::Core(match r {
307                        crate::engine::CancelReason::Timeout => Code::RunTimedOut,
308                        _ => Code::RunCancelled,
309                    })
310                })
311            })
312    }
313
314    /// The exit code (see docs/exit-codes.md): 130, 143 or 124 for a cancelled or timed-out run;
315    /// 2 when nothing ran because the selection or a profile is wrong; 1 when a Binding failed;
316    /// else 0.
317    pub fn exit_code(&self) -> u8 {
318        if let Some(r) = self.cancelled {
319            return r.exit_code();
320        }
321        if self.error.is_some() {
322            // Nothing ran: the selection or a profile is wrong.
323            return self.error_code().map_or(2, |c| c.kind().exit_code());
324        }
325        let failed: Vec<&BindingOutcome> = self
326            .outcomes
327            .iter()
328            .filter(|o| matches!(o.status, Status::Error | Status::Cancelled | Status::TimedOut))
329            .collect();
330        // Every Binding that failed was refused before it started (already running): nothing ran.
331        if !failed.is_empty()
332            && failed.iter().all(|o| {
333                o.error_code
334                    .as_ref()
335                    .is_some_and(|c| c.kind() == crate::codes::Kind::Refused)
336            })
337            && self.outcomes.len() == failed.len()
338        {
339            return 2;
340        }
341        // The run happened; a failed Binding exits 1 whatever its kind.
342        if failed.is_empty() { 0 } else { 1 }
343    }
344}
345
346/// What a run will do: the Bindings to run, and the run's target with every profile's entry.
347pub struct Plan<'a> {
348    pub bindings: Vec<(&'a Report, Binding)>,
349    pub targets: RunTargets,
350}
351
352/// Run what `opts` selects: [`plan`] it, then run the plan on a worker thread while this thread
353/// shows its events through `ui`.
354pub fn run(project: &Project, opts: &RunOptions, ui: &mut dyn Ui) -> RunSummary {
355    let mut summary = RunSummary::default();
356    let Some(plan) = plan(project, opts, ui, &mut summary) else {
357        return summary;
358    };
359    ui.targets(&plan.targets);
360    ui.plan(plan.bindings.len());
361    let (plugin_log, sql_log) = (ui.plugin_log(), ui.sql_log());
362    let (tx, rx) = std::sync::mpsc::channel();
363    let cancel = opts.cancel.clone();
364    let outcomes = std::thread::scope(|s| {
365        let worker = s.spawn(|| execute(project, &plan, opts, &cancel, Events::new(tx)));
366        for e in rx {
367            crate::engine::dispatch(ui, e, &plugin_log, &sql_log);
368        }
369        worker.join().expect("the run's worker doesn't panic")
370    });
371    summary.not_run = plan.bindings.len().saturating_sub(outcomes.len());
372    summary.cancelled = cancel.reason();
373    summary.outcomes.extend(outcomes);
374    summary
375}
376
377/// Which Bindings `opts` selects, and every profile's entry for the run. Asking which Set to run
378/// happens here, through `ui`. A selection that fails for one report is recorded as that
379/// report's failed outcome in `summary`; a failure that stops the whole run is `None`, with
380/// `summary.error` set.
381pub fn plan<'a>(
382    project: &'a Project,
383    opts: &RunOptions,
384    ui: &mut dyn Ui,
385    summary: &mut RunSummary,
386) -> Option<Plan<'a>> {
387    if let Err(e) = crate::target::ensure(&project.target_dir) {
388        summary.error = Some(format!(
389            "can't write to the target path {} (from {}): {e}",
390            project.target_dir.display(),
391            project.target_source
392        ));
393        return None;
394    }
395    let planned: Vec<(&Report, Binding)> = match &opts.schedule {
396        Some(name) => {
397            if !project.schedules.iter().any(|e| &e.name == name) {
398                summary.error = Some(unknown_schedule(project, name));
399                return None;
400            }
401            match schedule_bindings(project, name, opts) {
402                Ok(p) => p,
403                Err(e) => {
404                    summary.error = Some(e);
405                    return None;
406                }
407            }
408        }
409        None => {
410            let reports: Vec<&Report> = match &opts.selector {
411                None => project.reports.iter().collect(),
412                Some(s) => match selector::resolve(project, s) {
413                    Ok(r) if r.is_empty() => {
414                        summary.error = Some(format!("selector `{s}` matches no report"));
415                        return None;
416                    }
417                    Ok(r) => r,
418                    Err(e) => {
419                        summary.error = Some(e.to_string());
420                        return None;
421                    }
422                },
423            };
424            // Resolve every report's Bindings first (prompts happen here), so progress has a
425            // total.
426            let mut planned = Vec::new();
427            for report in reports {
428                match choose_bindings(project, report, opts, ui) {
429                    Ok(bs) => planned.extend(bs.into_iter().map(|b| (report, b))),
430                    Err(e) => {
431                        let outcome = BindingOutcome {
432                            report: report.name.clone(),
433                            set: None,
434                            binding: "-".into(),
435                            status: Status::Error,
436                            error: Some(e),
437                            error_code: Some(Code::InvalidSelector.into()),
438                            files: Vec::new(),
439                            summary: String::new(),
440                            schedule: opts.schedule.clone(),
441                            schedule_vars: None,
442                            vars: JsonMap::new(),
443                            timezone: String::new(),
444                            elapsed: Duration::ZERO,
445                        };
446                        ui.binding_end(&outcome);
447                        summary.outcomes.push(outcome);
448                    }
449                }
450            }
451            planned
452        }
453    };
454    // Every profile the run uses needs an entry for it, before anything runs.
455    match run_targets(project, &planned, opts) {
456        Ok(targets) => Some(Plan {
457            bindings: planned,
458            targets,
459        }),
460        Err(e) => {
461            summary.error = Some(e);
462            summary.missing_entry = true;
463            None
464        }
465    }
466}
467
468/// Run a plan's Bindings, reporting through `events`; a cancelled run starts no further
469/// Binding. One at a time, in order, unless a connection's entry sets `threads:` above 1 or the
470/// run sets `--threads` (see [`Limits`]).
471pub fn execute(
472    project: &Project,
473    plan: &Plan<'_>,
474    opts: &RunOptions,
475    cancel: &CancelToken,
476    events: Events,
477) -> Vec<BindingOutcome> {
478    let limits = Limits::new(project, plan, opts);
479    let target = opts
480        .output_json
481        .as_ref()
482        .map(|p| project.root.join(p))
483        .unwrap_or_else(|| project.target_dir.clone());
484    if limits.sequential() {
485        let store = RunStore::new(&target);
486        let mut outcomes = Vec::new();
487        for (report, b) in &plan.bindings {
488            if cancel.is_cancelled() {
489                break;
490            }
491            events.emit(RunEvent::BindingStart {
492                report: report.name.clone(),
493                set: b.set.clone(),
494            });
495            let mut r = BindingRun::new(project, report, b, opts, events.clone(), &store);
496            let outcome = r.run();
497            events.emit(RunEvent::BindingEnd(outcome.clone()));
498            outcomes.push(outcome);
499        }
500        return outcomes;
501    }
502    let store = RunStore::concurrent(&target);
503    let n = plan.bindings.len();
504    let state = std::sync::Mutex::new(Running::new(n));
505    let wake = std::sync::Condvar::new();
506    let outcomes: std::sync::Mutex<Vec<Option<BindingOutcome>>> = std::sync::Mutex::new(vec![None; n]);
507    std::thread::scope(|s| {
508        for _ in 0..limits.cap.min(n) {
509            s.spawn(|| {
510                loop {
511                    let i = {
512                        let mut st = state.lock().unwrap();
513                        loop {
514                            if cancel.is_cancelled() || st.started.iter().all(|x| *x) {
515                                break None;
516                            }
517                            match limits.next(&st) {
518                                Some(i) => {
519                                    st.start(i, &limits.uses[i]);
520                                    break Some(i);
521                                }
522                                None => st = wake.wait(st).unwrap(),
523                            }
524                        }
525                    };
526                    let Some(i) = i else {
527                        wake.notify_all();
528                        return;
529                    };
530                    let (report, b) = &plan.bindings[i];
531                    let label = match &b.set {
532                        Some(set) => format!("{} [{set}]", report.name),
533                        None => report.name.clone(),
534                    };
535                    let held = events.held();
536                    held.emit_now(RunEvent::Running {
537                        label: label.clone(),
538                        running: true,
539                    });
540                    held.emit(RunEvent::BindingStart {
541                        report: report.name.clone(),
542                        set: b.set.clone(),
543                    });
544                    let mut r = BindingRun::new(project, report, b, opts, held.clone(), &store);
545                    let outcome = r.run();
546                    held.emit(RunEvent::BindingEnd(outcome.clone()));
547                    held.emit_now(RunEvent::Running {
548                        label,
549                        running: false,
550                    });
551                    held.flush();
552                    outcomes.lock().unwrap()[i] = Some(outcome);
553                    state.lock().unwrap().finish(&limits.uses[i]);
554                    wake.notify_all();
555                }
556            });
557        }
558    });
559    // Declaration order, whatever order they finished in.
560    outcomes.into_inner().unwrap().into_iter().flatten().collect()
561}
562
563/// How many Bindings may run at once: each connection entry's `threads:` (default 1; a DuckDB
564/// file is always 1, as only one process can write it), and `--threads` (`DRE_THREADS`) over
565/// the whole run.
566struct Limits {
567    /// The connections each Binding uses.
568    uses: Vec<Vec<String>>,
569    per_connection: BTreeMap<String, usize>,
570    cap: usize,
571}
572
573/// Which Bindings have started, and what's running on each connection.
574struct Running {
575    started: Vec<bool>,
576    total: usize,
577    on: BTreeMap<String, usize>,
578    /// Connections a Binding has used to the end: until then, a connection runs one Binding,
579    /// so its sign-in (a browser, a token refresh) happens once.
580    warm: std::collections::BTreeSet<String>,
581}
582
583impl Running {
584    fn new(n: usize) -> Running {
585        Running {
586            started: vec![false; n],
587            total: 0,
588            on: BTreeMap::new(),
589            warm: Default::default(),
590        }
591    }
592
593    fn start(&mut self, i: usize, uses: &[String]) {
594        self.started[i] = true;
595        self.total += 1;
596        for c in uses {
597            *self.on.entry(c.clone()).or_default() += 1;
598        }
599    }
600
601    fn finish(&mut self, uses: &[String]) {
602        self.total -= 1;
603        for c in uses {
604            *self.on.entry(c.clone()).or_default() -= 1;
605            self.warm.insert(c.clone());
606        }
607    }
608}
609
610impl Limits {
611    fn new(project: &Project, plan: &Plan<'_>, opts: &RunOptions) -> Limits {
612        let schedule_vars = opts
613            .schedule
614            .as_ref()
615            .and_then(|n| project.schedules.iter().find(|e| &e.name == n))
616            .map(|e| e.vars.clone());
617        let inputs = crate::parse::Inputs {
618            target: project.target_name.clone(),
619            cli_vars: opts.vars.clone(),
620            date: opts.date,
621            scheduled_at: opts.scheduled_at,
622            timezone: opts.timezone.clone(),
623            schedule: opts.schedule.clone(),
624            started_at: None,
625        };
626        let uses: Vec<Vec<String>> = plan
627            .bindings
628            .iter()
629            .map(|(report, b)| {
630                let mut vars = b.vars.clone();
631                vars.extend(schedule_vars.clone().unwrap_or_default());
632                crate::parse::binding(project, report, b, &vars, &inputs)
633                    .connections()
634                    .into_iter()
635                    .map(str::to_string)
636                    .collect()
637            })
638            .collect();
639        let mut per_connection = BTreeMap::new();
640        for c in uses.iter().flatten() {
641            per_connection
642                .entry(c.clone())
643                .or_insert_with(|| connection_threads(project, c));
644        }
645        // Only a real `dre run` (`--preview` included) runs Bindings at once.
646        let cap = if opts.dry_run || opts.live_check {
647            1
648        } else {
649            opts.threads.unwrap_or(usize::MAX).max(1)
650        };
651        Limits {
652            uses,
653            per_connection,
654            cap,
655        }
656    }
657
658    /// One at a time: `--threads 1`, or no `--threads` and no connection above 1.
659    fn sequential(&self) -> bool {
660        self.cap == 1 || (self.cap == usize::MAX && self.per_connection.values().all(|n| *n <= 1))
661    }
662
663    /// The first Binding, in declaration order, that may start now.
664    fn next(&self, st: &Running) -> Option<usize> {
665        if st.total >= self.cap {
666            return None;
667        }
668        (0..st.started.len()).find(|&i| {
669            !st.started[i]
670                && self.uses[i].iter().all(|c| {
671                    let on = st.on.get(c).copied().unwrap_or(0);
672                    let limit = if st.warm.contains(c) {
673                        self.per_connection[c]
674                    } else {
675                        1
676                    };
677                    on < limit
678                })
679        })
680    }
681}
682
683/// A connection entry's `threads:` (default 1); a DuckDB file is 1 whatever it says.
684fn connection_threads(project: &Project, connection: &str) -> usize {
685    let Some(t) = project.profiles.target(Role::Connection, connection) else {
686        return 1;
687    };
688    let n = match t.fields.get("threads") {
689        Some(Json::Number(n)) => n.as_u64().unwrap_or(1),
690        Some(Json::String(s)) => s.trim().parse().unwrap_or(1),
691        _ => 1,
692    } as usize;
693    let duckdb_file = t.kind == "duckdb"
694        && t.fields
695            .get("path")
696            .and_then(Json::as_str)
697            .is_some_and(|p| p != ":memory:");
698    if duckdb_file { 1 } else { n.max(1) }
699}
700
701/// A profile a run uses, and its entry.
702#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
703pub struct UsedProfile {
704    /// `connection` or `destination`.
705    pub role: &'static str,
706    pub profile: String,
707    /// Its entry: the run's target, else the profile's own `target:`, else `dev`.
708    pub target: String,
709    /// False for a destination entry written `deliver: false`.
710    pub deliver: bool,
711}
712
713/// The run's target and the entry each profile it uses picked.
714#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
715pub struct RunTargets {
716    /// `target.name`.
717    pub name: String,
718    /// `--target`, `DRE_TARGET` or `default`.
719    pub from: String,
720    pub profiles: Vec<UsedProfile>,
721}
722
723impl RunTargets {
724    /// `dev (default)`, then each profile whose entry differs: `; connection `warehouse`: prod`.
725    pub fn line(&self) -> String {
726        let mut out = format!("{} ({})", self.name, self.from);
727        for p in self.profiles.iter().filter(|p| p.target != self.name) {
728            out.push_str(&format!("; {} `{}`: {}", p.role, p.profile, p.target));
729        }
730        out
731    }
732
733    /// When every profile the run uses is on one target other than the run's, `target.name`
734    /// probably isn't what the templates expect.
735    pub fn mismatch(&self) -> Option<String> {
736        let first = &self.profiles.first()?.target;
737        if first == &self.name || self.profiles.iter().any(|p| &p.target != first) {
738            return None;
739        }
740        Some(format!(
741            "every profile is on `{first}` but the run's target is `{}`: pass `--target {first}` or set `{}`",
742            self.name,
743            crate::profiles::TARGET_ENV
744        ))
745    }
746}
747
748/// The profiles the planned Bindings use (by the parse pass, with this run's inputs) and each
749/// one's entry. A profile without an entry for the run is an error naming them all; profiles
750/// nothing planned uses aren't checked.
751pub fn run_targets(
752    project: &Project,
753    planned: &[(&Report, Binding)],
754    opts: &RunOptions,
755) -> Result<RunTargets, String> {
756    let profiles = &project.profiles;
757    let schedule_vars = opts
758        .schedule
759        .as_ref()
760        .and_then(|n| project.schedules.iter().find(|e| &e.name == n))
761        .map(|e| e.vars.clone());
762    let inputs = crate::parse::Inputs {
763        target: project.target_name.clone(),
764        cli_vars: opts.vars.clone(),
765        date: opts.date,
766        scheduled_at: opts.scheduled_at,
767        timezone: opts.timezone.clone(),
768        schedule: opts.schedule.clone(),
769        started_at: None,
770    };
771    let mut used: Vec<(Role, String)> = Vec::new();
772    for (report, b) in planned {
773        let mut vars = b.vars.clone();
774        vars.extend(schedule_vars.clone().unwrap_or_default());
775        // A Binding whose parse fails reports that when it runs.
776        let parsed = crate::parse::binding(project, report, b, &vars, &inputs);
777        let names = parsed
778            .connections()
779            .into_iter()
780            .map(|c| (Role::Connection, c.to_string()))
781            .chain(
782                parsed
783                    .destinations
784                    .iter()
785                    .flatten()
786                    .filter(|_| !opts.no_deliver)
787                    .filter(|d| !profiles.is_builtin_local(d))
788                    .map(|d| (Role::Destination, d.clone())),
789            );
790        for u in names {
791            if !used.contains(&u) {
792                used.push(u);
793            }
794        }
795    }
796    let mut missing = Vec::new();
797    let mut out = Vec::new();
798    for (role, name) in &used {
799        let deliver = match profiles.entry(*role, name) {
800            Entry::Use(_) => true,
801            Entry::Nowhere => false,
802            Entry::Missing => {
803                missing.push(profiles.missing_entry(*role, name));
804                continue;
805            }
806            // Reported by the load.
807            Entry::Unknown => continue,
808        };
809        out.push(UsedProfile {
810            role: role.as_str(),
811            profile: name.clone(),
812            target: profiles.target_of(*role, name),
813            deliver,
814        });
815    }
816    match missing.len() {
817        0 => Ok(RunTargets {
818            name: project.target_name.clone(),
819            from: project.target_from.to_string(),
820            profiles: out,
821        }),
822        1 => Err(format!("{}; nothing was run", missing[0])),
823        n => Err(format!(
824            "{n} profiles this run uses have no entry for it; nothing was run:\n    {}",
825            missing.join("\n    ")
826        )),
827    }
828}
829
830/// The Bindings `--schedule <name>` runs: all of them, or those a selector and/or `--set` pick.
831/// Picking something the schedule doesn't run is an error naming what it does run.
832fn schedule_bindings<'a>(
833    project: &'a Project,
834    name: &str,
835    opts: &RunOptions,
836) -> Result<Vec<(&'a Report, Binding)>, String> {
837    let reports: Option<Vec<&str>> = match &opts.selector {
838        None => None,
839        Some(s) => Some(
840            selector::resolve(project, s)
841                .map_err(|e| e.to_string())?
842                .into_iter()
843                .map(|r| r.name.as_str())
844                .collect(),
845        ),
846    };
847    let set = opts.set.as_deref().filter(|s| *s != "all");
848    let all: Vec<(&Report, &Binding)> = project
849        .reports
850        .iter()
851        .flat_map(|r| {
852            r.bindings
853                .iter()
854                .filter(|b| b.schedules.iter().any(|s| s == name))
855                .map(move |b| (r, b))
856        })
857        .collect();
858    let picked: Vec<(&Report, Binding)> = all
859        .iter()
860        .filter(|(r, _)| reports.as_ref().is_none_or(|rs| rs.contains(&r.name.as_str())))
861        .filter(|(_, b)| set.is_none() || b.set.as_deref() == set)
862        .map(|(r, b)| {
863            let mut b = (*b).clone();
864            if let Some(p) = &opts.profile {
865                b.profile = Some(p.clone());
866                b.profile_at = Some(profile_flag_at());
867            }
868            (*r, b)
869        })
870        .collect();
871    if picked.is_empty() && (reports.is_some() || set.is_some()) {
872        let mut asked = Vec::new();
873        if let Some(s) = &opts.selector {
874            asked.push(format!("`{s}`"));
875        }
876        if let Some(s) = set {
877            asked.push(format!("Set `{s}`"));
878        }
879        let runs: Vec<String> = all
880            .iter()
881            .map(|(r, b)| match &b.set {
882                Some(s) => format!("{}/{s}", r.name),
883                None => r.name.clone(),
884            })
885            .collect();
886        return Err(format!(
887            "schedule `{name}` doesn't run {}; it runs: {}",
888            asked.join(" with "),
889            if runs.is_empty() {
890                "nothing".to_string()
891            } else {
892                runs.join(", ")
893            }
894        ));
895    }
896    Ok(picked)
897}
898
899/// The usage error for `--schedule` with a name that isn't declared.
900pub fn unknown_schedule(project: &Project, name: &str) -> String {
901    let names: Vec<&str> = project.schedules.iter().map(|e| e.name.as_str()).collect();
902    if names.is_empty() {
903        format!("no schedule `{name}`: this project's schedules.yml declares none")
904    } else {
905        format!("no schedule `{name}`; valid names: {}", names.join(", "))
906    }
907}
908
909/// Which Bindings of a report run, following ADR 0009's Set rules.
910pub fn choose_bindings(
911    project: &Project,
912    report: &Report,
913    opts: &RunOptions,
914    ui: &mut dyn Ui,
915) -> Result<Vec<Binding>, String> {
916    let mut out: Vec<Binding> = match opts.set.as_deref() {
917        Some("all") => report.bindings.clone(),
918        Some(name) => match report.binding(name) {
919            Some(b) => vec![b.clone()],
920            None => vec![ad_hoc(project, report, name)],
921        },
922        None if !report.has_sets => report.bindings.clone(),
923        None => {
924            let names: Vec<String> = report.bindings.iter().filter_map(|b| b.set.clone()).collect();
925            let pick = report
926                .default_set
927                .clone()
928                .or_else(|| project.default_set.clone().filter(|d| names.contains(d)))
929                .or_else(|| (names.len() == 1).then(|| names[0].clone()));
930            match pick {
931                Some(p) => report.binding(&p).cloned().into_iter().collect(),
932                None if opts.interactive => match ui.choose_set(&report.name, &names)? {
933                    Some(p) => report.binding(&p).cloned().into_iter().collect(),
934                    None => report.bindings.clone(),
935                },
936                None => {
937                    return Err(format!(
938                        "report `{}` has several Sets ({}) and no `default_set`; declare `default_set:` or pass `--set <name>` or `--set all`",
939                        report.name,
940                        names.join(", ")
941                    ));
942                }
943            }
944        }
945    };
946    if let Some(p) = &opts.profile {
947        for b in &mut out {
948            b.profile = Some(p.clone());
949            b.profile_at = Some(profile_flag_at());
950        }
951    }
952    Ok(out)
953}
954
955/// A Set that isn't declared on the report: start from the report itself, then apply the
956/// `sets.yml` entry of that name if there is one. `--profile`/`--var` complete it.
957fn ad_hoc(project: &Project, report: &Report, name: &str) -> Binding {
958    let mut b = report.base.clone();
959    b.set = Some(name.to_string());
960    if let Some(reg) = project.sets.get(name) {
961        if let Some(p) = &reg.profile {
962            b.profile = Some(p.clone());
963            b.profile_at = Some(crate::project::ProfileAt {
964                file: reg.file.clone(),
965                line: reg.line,
966                key: format!("`profile` of Set `{name}`"),
967            });
968        }
969        b.vars.extend(reg.vars.clone());
970    }
971    b
972}
973
974/// `--profile` as the source of a Binding's inherited connection, for messages.
975fn profile_flag_at() -> crate::project::ProfileAt {
976    crate::project::ProfileAt {
977        file: PathBuf::from("--profile"),
978        line: None,
979        key: "`--profile`".into(),
980    }
981}
982
983/// The tab one query produced: its last statement's result set, spooled to disk.
984struct Produced {
985    query: String,
986    connection: String,
987    schema: SchemaRef,
988    rows: u64,
989    spool: PathBuf,
990    name: String,
991    anchor: Option<String>,
992    header: Option<bool>,
993    columns: BTreeMap<String, ColumnOptions>,
994    autofit: Option<bool>,
995    style: Option<dre_protocol::style::SheetStyle>,
996}
997
998struct Statement {
999    query: String,
1000    /// The connection it runs on.
1001    connection: String,
1002    file: PathBuf,
1003    line: usize,
1004    text: String,
1005    kind: StatementKind,
1006    /// The complete rendered query contained a registered secret before it was split. Every
1007    /// statement inherits this because splitting can itself separate a secret into fragments.
1008    sensitive: bool,
1009    /// Whether this statement's result is its query's tab: the file's last statement, when the
1010    /// query has `tab: true`.
1011    tab: bool,
1012}
1013
1014struct BindingRun<'a> {
1015    project: &'a Project,
1016    report: &'a Report,
1017    b: &'a Binding,
1018    /// The Binding's vars with the schedule's layered on (`--var` stays separate, on top).
1019    vars: JsonMap<String, Json>,
1020    /// Everything `var()` sees, `--var` included: what's recorded.
1021    rendered_vars: JsonMap<String, Json>,
1022    schedule_vars: Option<JsonMap<String, Json>>,
1023    opts: &'a RunOptions,
1024    /// `run.date`: `DRE_RUN_DATE`, else today in the run's timezone.
1025    date: NaiveDate,
1026    calendar: Calendar,
1027    ui: Events,
1028    store: &'a RunStore,
1029    compiled_dir: PathBuf,
1030    /// The Binding's folder in `target/run/`, with its runs and `current` pointer.
1031    runs: crate::runs::BindingRuns,
1032    /// This run's id and folder (`runs/<run id>/`).
1033    run_id: String,
1034    run_dir: PathBuf,
1035    /// Whether this run becomes the current one when it finishes: false when a run for a later
1036    /// instant already is (it then also leaves the drift snapshot alone).
1037    becomes_current: bool,
1038    schema_dir: PathBuf,
1039    /// The run's target (environment).
1040    target: String,
1041    /// The parse pass for this run's inputs.
1042    parsed: ParsedBinding,
1043    /// The Binding's connections and their sessions.
1044    pool: Option<Arc<Pool>>,
1045    /// One renderer per connection (`None`: the inherited one, for paths and template values).
1046    renderers: BTreeMap<Option<String>, Arc<Renderer>>,
1047    started: Instant,
1048    started_at: chrono::DateTime<chrono::Utc>,
1049    produced: Vec<Produced>,
1050    /// Each of the Binding's outputs, in order.
1051    outs: Vec<OutputRun>,
1052    /// How many tries the delivery being made took (a plugin retrying a temporary error).
1053    attempts: u32,
1054    drift: Vec<String>,
1055    /// The current schema with stable identities and any protection carried from the baseline.
1056    protected_snapshot: Option<Json>,
1057    /// `--retry-failed`: the run it redoes, and the rows each per-row output already finished.
1058    retry: Option<per_row::Retry>,
1059    /// Deliveries held for `dre release` (`hold: true`, `--hold`).
1060    held: Vec<crate::release::HeldEntry>,
1061}
1062
1063/// One output's progress through the run.
1064#[derive(Default)]
1065struct OutputRun {
1066    /// Its destinations with paths and options rendered.
1067    dests: Vec<RenderedDest>,
1068    /// Each file it wrote, and where it was first delivered.
1069    files: Vec<(PathBuf, Option<String>)>,
1070    /// One record per destination: `run_results.json`'s `deliveries`.
1071    deliveries: Vec<Delivery>,
1072    delivery_note: Option<String>,
1073    /// `None` until it's done.
1074    status: Option<OutputStatus>,
1075    error: Option<String>,
1076    /// The result of its `when:`, once evaluated.
1077    when: Option<bool>,
1078    /// A message output's rendered title and text.
1079    message: Option<(String, String)>,
1080    /// Why it was skipped or failed by design (`no_data`).
1081    reason: Option<String>,
1082    /// Its queries that returned no rows.
1083    no_data: Vec<String>,
1084    /// Empty queries left out (`on_empty: skip`).
1085    dropped: Vec<String>,
1086    /// A per-row output's summary.
1087    for_each: Option<run_results::ForEachResult>,
1088    /// A per-row output's files by row key, for a message that attaches it.
1089    row_files: std::collections::HashMap<String, Vec<PathBuf>>,
1090}
1091
1092/// How one output ended, as `run_results.json` records it.
1093#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1094enum OutputStatus {
1095    Delivered,
1096    /// It stays in the target path: no destination, `deliver: false`, or a preview.
1097    Kept,
1098    /// Its `when:` was false, or its message rendered empty.
1099    Skipped,
1100    Failed,
1101    /// Its deliveries are held for `dre release`.
1102    Held,
1103}
1104
1105impl OutputStatus {
1106    fn as_str(self) -> &'static str {
1107        match self {
1108            OutputStatus::Delivered => "delivered",
1109            OutputStatus::Kept => "kept",
1110            OutputStatus::Skipped => "skipped",
1111            OutputStatus::Failed => "failed",
1112            OutputStatus::Held => "held",
1113        }
1114    }
1115}
1116
1117/// A destination entry after rendering.
1118struct RenderedDest {
1119    profile: String,
1120    path: Option<String>,
1121    options: JsonMap<String, Json>,
1122    /// Other outputs whose files go with a message.
1123    attach: Vec<String>,
1124    /// `hold: true`: recorded for `dre release`, not delivered now.
1125    hold: bool,
1126    /// `rate:` (or `--rate`), recorded with a held delivery.
1127    rate: Option<crate::rate::Rate>,
1128}
1129
1130/// Why a Binding (or one of its steps) failed.
1131type Fail = crate::error::Error;
1132
1133fn masked_source_error(error: &HostError, sensitive: bool) -> String {
1134    let error = error.to_string();
1135    crate::secrets::mask_source_error(&error, sensitive).into_owned()
1136}
1137
1138fn masked_sql_summary(sql: &str, max: usize, sensitive: bool) -> String {
1139    if sensitive {
1140        crate::secrets::MASK.to_string()
1141    } else {
1142        dre_protocol::util::summarize(&crate::secrets::mask(sql), max)
1143    }
1144}
1145
1146fn protected_sql(sql: &str, sensitive: bool) -> std::borrow::Cow<'_, str> {
1147    if sensitive {
1148        std::borrow::Cow::Borrowed(crate::secrets::MASK)
1149    } else {
1150        crate::secrets::mask(sql)
1151    }
1152}
1153
1154impl<'a> BindingRun<'a> {
1155    fn new(
1156        project: &'a Project,
1157        report: &'a Report,
1158        b: &'a Binding,
1159        opts: &'a RunOptions,
1160        ui: Events,
1161        store: &'a RunStore,
1162    ) -> Self {
1163        let schedule = opts
1164            .schedule
1165            .as_ref()
1166            .and_then(|n| project.schedules.iter().find(|e| &e.name == n));
1167        let schedule_vars = schedule.map(|e| e.vars.clone());
1168        // Names were checked when the project and the command line were read.
1169        let calendar: Calendar = crate::parse::calendar(
1170            project,
1171            report,
1172            opts.timezone.as_deref(),
1173            opts.schedule.as_deref(),
1174        );
1175        let date = crate::parse::run_date(&calendar, opts.date, opts.scheduled_at);
1176        // Binding vars, then the schedule's, then `--var` on top.
1177        let mut vars = b.vars.clone();
1178        vars.extend(schedule_vars.clone().unwrap_or_default());
1179        let rendered_vars = {
1180            let mut v = vars.clone();
1181            v.extend(
1182                opts.vars
1183                    .iter()
1184                    .map(|(k, x)| (k.clone(), Json::String(x.clone()))),
1185            );
1186            v
1187        };
1188        ui.binding_vars(opts.schedule.as_deref(), schedule_vars.as_ref(), &rendered_vars);
1189        let started_at = chrono::Utc::now();
1190        let runs = crate::runs::BindingRuns::new(&store.run_dir(&report.name, b.dir_name()));
1191        // A dry run (compile, validate) makes no run folder: its plan shows where files would go.
1192        let run_id = if opts.dry_run || opts.live_check {
1193            "<run id>".to_string()
1194        } else {
1195            crate::runs::new_run_id(started_at)
1196        };
1197        BindingRun {
1198            project,
1199            report,
1200            b,
1201            vars,
1202            rendered_vars,
1203            schedule_vars,
1204            opts,
1205            date,
1206            calendar,
1207            ui,
1208            store,
1209            compiled_dir: store.compiled_dir(&report.name, b.dir_name()),
1210            run_dir: runs.run_dir(&run_id),
1211            runs,
1212            run_id,
1213            becomes_current: true,
1214            schema_dir: store.schema_dir(&report.name, b.dir_name()),
1215            target: project.target_name.clone(),
1216            parsed: ParsedBinding::default(),
1217            pool: None,
1218            renderers: BTreeMap::new(),
1219            started: Instant::now(),
1220            started_at,
1221            produced: Vec::new(),
1222            outs: b.outputs.iter().map(|_| OutputRun::default()).collect(),
1223            attempts: 1,
1224            drift: Vec::new(),
1225            protected_snapshot: None,
1226            retry: None,
1227            held: Vec::new(),
1228        }
1229    }
1230
1231    /// What `connection`, `destination` and `profile()` read for this Binding.
1232    fn connections(&self) -> Arc<ProfileConnections> {
1233        Arc::new(ProfileConnections {
1234            profiles: self.project.profiles.clone(),
1235            root: self.project.root.clone(),
1236            plugins: self.project.plugins.clone(),
1237            log: self.ui.plugin_log(),
1238        })
1239    }
1240
1241    /// The renderer for SQL on `connection` (`None`: the inherited connection, for output paths
1242    /// and template values), made on first use.
1243    fn renderer(&mut self, connection: Option<&str>) -> Result<Arc<Renderer>, Fail> {
1244        let key = connection.map(str::to_string);
1245        if let Some(r) = self.renderers.get(&key) {
1246            return Ok(r.clone());
1247        }
1248        let pool = self.pool.clone().expect("the pool is made before rendering");
1249        // Outside query SQL, `connection.*` and `run_query()` mean the inherited connection.
1250        let name = key.clone().or_else(|| self.parsed.inherited.clone());
1251        let source_type = match (&key, &name) {
1252            (Some(n), _) => pool.kind(n)?,
1253            // The inherited connection's type matters only if something uses it.
1254            (None, Some(n)) => pool.kind(n).unwrap_or_default(),
1255            (None, None) => String::new(),
1256        };
1257        let context =
1258            crate::parse::run_context(self.project, self.report, self.b, &self.inputs(), self.started_at);
1259        let limited = Arc::new(crate::render::Limited::new(
1260            context.clone(),
1261            self.vars.clone(),
1262            self.opts.vars.clone(),
1263        ));
1264        let r = Renderer::new(RendererConfig {
1265            root: &self.project.root,
1266            macros: &self.project.macros,
1267            context,
1268            vars: self.vars.clone(),
1269            cli_vars: self.opts.vars.clone(),
1270            runner: Some(Arc::new(PoolRunner {
1271                pool: pool.clone(),
1272                default: name.clone(),
1273                log: self.ui.sql_log(),
1274            })),
1275            connections: Some(pool.connections.clone()),
1276            mode: Mode::Run,
1277            connection: name,
1278            source_type,
1279            sources: crate::parse::resolver(self.project, limited),
1280            run_query_max_rows: self.project.run_query_max_rows,
1281            sql: self.project.sql.clone(),
1282            lookups: self.project.lookups.clone(),
1283            lookup_inline_max_rows: self.project.lookup_inline_max_rows,
1284            packages: self.project.packages.clone(),
1285            project_name: self.project.name.clone(),
1286            dispatch: self.project.dispatch.clone(),
1287        })
1288        .map_err(|e| e.to_string())?;
1289        let r = Arc::new(r);
1290        self.renderers.insert(key, r.clone());
1291        Ok(r)
1292    }
1293
1294    /// The parse pass's inputs for this run.
1295    fn inputs(&self) -> crate::parse::Inputs {
1296        crate::parse::Inputs {
1297            target: self.target.clone(),
1298            cli_vars: self.opts.vars.clone(),
1299            date: self.opts.date,
1300            scheduled_at: self.opts.scheduled_at,
1301            timezone: self.opts.timezone.clone(),
1302            schedule: self.opts.schedule.clone(),
1303            started_at: Some(self.started_at),
1304        }
1305    }
1306
1307    /// Each query's connection, from the parse pass.
1308    fn connection_of(&self, query: &str) -> Result<String, Fail> {
1309        self.parsed
1310            .query(query)
1311            .and_then(|q| q.connection.clone())
1312            .ok_or_else(|| format!("query `{query}` has no connection").into())
1313    }
1314
1315    /// `report` or `report (Set s)`, for messages.
1316    fn label(&self) -> String {
1317        match &self.b.set {
1318            Some(s) => format!("{} (Set {s})", self.report.name),
1319            None => self.report.name.clone(),
1320        }
1321    }
1322
1323    /// Why the Binding's lock couldn't be taken, with how to clear it.
1324    fn lock_failure(&self, e: crate::runs::LockError) -> Fail {
1325        let unlock = match &self.b.set {
1326            Some(s) => format!("dre unlock {} --binding {s}", self.report.name),
1327            None => format!("dre unlock {}", self.report.name),
1328        };
1329        let message = match e {
1330            crate::runs::LockError::Held(h) => format!(
1331                "{} is already running ({}); this run changed nothing. Wait for it, or if it's gone, `{unlock}`",
1332                self.label(),
1333                h.describe()
1334            ),
1335            crate::runs::LockError::Unreadable(p) => format!(
1336                "{} has an unreadable lock at {}; if no run is going on, `{unlock}`",
1337                self.label(),
1338                p.display()
1339            ),
1340            crate::runs::LockError::Io(e) => format!("can't lock {}: {e}", self.label()),
1341        };
1342        Fail::new(Code::RunInProgress, message)
1343    }
1344
1345    /// Whether the current run is for a later instant than this one (its `DRE_RUN_AT`, else
1346    /// its start): then this run doesn't replace it.
1347    fn older_than_current(&self) -> bool {
1348        let Some(dir) = self.runs.current_dir() else {
1349            return false;
1350        };
1351        let Ok(text) = std::fs::read_to_string(dir.join("run_results.json")) else {
1352            return false;
1353        };
1354        let Ok(v) = serde_json::from_str::<Json>(&text) else {
1355            return false;
1356        };
1357        let theirs = v
1358            .get("scheduled_at")
1359            .and_then(Json::as_str)
1360            .or_else(|| v.get("started_at").and_then(Json::as_str))
1361            .and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
1362            .map(|t| t.with_timezone(&chrono::Utc));
1363        let mine = self.opts.scheduled_at.unwrap_or(self.started_at);
1364        theirs.is_some_and(|t| mine < t)
1365    }
1366
1367    /// Fail once the run is cancelled, so no further statement, file or delivery starts.
1368    /// `--preview`'s row limit per query; none for a Binding with a per-row output, whose
1369    /// population the preview counts in full.
1370    fn row_limit(&self) -> Option<u64> {
1371        if self.b.outputs.iter().any(|o| o.for_each.is_some()) {
1372            None
1373        } else {
1374            self.opts.preview
1375        }
1376    }
1377
1378    fn not_cancelled(&self) -> Result<(), Fail> {
1379        match self.opts.cancel.reason() {
1380            None => Ok(()),
1381            Some(crate::engine::CancelReason::Timeout) => {
1382                Err(Fail::new(Code::RunTimedOut, "the run timed out"))
1383            }
1384            Some(_) => Err(Fail::new(Code::RunCancelled, "the run was cancelled")),
1385        }
1386    }
1387
1388    fn outcome(&self, status: Status, error: Option<Fail>) -> BindingOutcome {
1389        BindingOutcome {
1390            report: self.report.name.clone(),
1391            set: self.b.set.clone(),
1392            binding: self.b.dir_name().to_string(),
1393            status,
1394            error_code: error.as_ref().map(|e| e.code.clone()),
1395            error: error.map(|e| e.message),
1396            files: self.files().map(|(p, _)| p.clone()).collect(),
1397            summary: self.summary_line(),
1398            schedule: self.opts.schedule.clone(),
1399            schedule_vars: self.schedule_vars.clone(),
1400            vars: self.rendered_vars.clone(),
1401            timezone: self.calendar.tz.name().to_string(),
1402            elapsed: self.started.elapsed(),
1403        }
1404    }
1405
1406    fn run(&mut self) -> BindingOutcome {
1407        let dry = self.opts.dry_run || self.opts.live_check;
1408        // A real run holds the Binding's lock: the same Binding never runs twice at once.
1409        let guard = if dry {
1410            None
1411        } else {
1412            match self.runs.lock(&self.run_id, self.started_at) {
1413                Ok(g) => Some(g),
1414                Err(e) => {
1415                    let error = self.lock_failure(e);
1416                    return self.outcome(Status::Error, Some(error));
1417                }
1418            }
1419        };
1420        if let Some(g) = &guard {
1421            if let Some(h) = &g.took_over {
1422                self.ui.warn(&format!(
1423                    "took over the lock of {} for {}: that process is gone (it crashed or was killed)",
1424                    h.describe(),
1425                    self.label()
1426                ));
1427            }
1428            match self.runs.migrate_legacy(self.started_at) {
1429                Ok(Some(id)) => self.ui.step(
1430                    Level::Debug,
1431                    "Moved",
1432                    &format!("the files of the run before run folders into runs/{id}/"),
1433                    None,
1434                ),
1435                Ok(None) => {}
1436                Err(e) => self
1437                    .ui
1438                    .warn(&format!("can't move the earlier run's files into runs/: {e}")),
1439            }
1440            // A retry redoes some rows of an earlier run: it never replaces the current one.
1441            self.becomes_current = !self.older_than_current() && self.opts.retry_failed.is_none();
1442            // The run's own log, in its folder.
1443            if std::fs::create_dir_all(&self.run_dir).is_ok() {
1444                self.ui.emit(RunEvent::BindingLog(self.run_dir.join("dre.log")));
1445            }
1446        }
1447        let result = self.run_inner();
1448        // A Binding that fails after the run was cancelled was stopped by it (a plugin's
1449        // cancelled reply, or a check between steps).
1450        let stopped =
1451            matches!(&result, Err(e) if e.code == Code::RunCancelled || e.code == Code::RunTimedOut);
1452        let result = match (result, self.opts.cancel.reason()) {
1453            (Err(e), Some(reason)) if !stopped => {
1454                let (code, what) = match reason {
1455                    crate::engine::CancelReason::Timeout => (Code::RunTimedOut, "timed out"),
1456                    _ => (Code::RunCancelled, "was cancelled"),
1457                };
1458                Err(Fail::new(code, format!("the run {what}: {}", e.message)))
1459            }
1460            (r, _) => r,
1461        };
1462        let status = match (&result, dry, self.opts.live_check) {
1463            (Err(e), _, _) if e.code == Code::RunCancelled => Status::Cancelled,
1464            (Err(e), _, _) if e.code == Code::RunTimedOut => Status::TimedOut,
1465            (Err(_), _, _) => Status::Error,
1466            (Ok(()), _, true) => Status::Checked,
1467            (Ok(()), true, _) => Status::DryRun,
1468            (Ok(()), false, _) => Status::Success,
1469        };
1470        let err = result.err();
1471        if !dry && let Err(e) = self.write_results(&status, err.as_ref()) {
1472            self.ui.warn(&format!("can't write run_results.json: {e}"));
1473        }
1474        if guard.is_some() {
1475            if self.becomes_current {
1476                if let Err(e) = self.runs.switch(&self.run_id) {
1477                    self.ui.warn(&format!("can't make this run the current one: {e}"));
1478                }
1479            } else {
1480                self.ui.warn(&format!(
1481                    "{}: a run for a later instant is current, so this one doesn't replace it; its files are in {}",
1482                    self.label(),
1483                    rel(&self.project.root, &self.run_dir).display()
1484                ));
1485            }
1486            for id in self
1487                .runs
1488                .prune(self.opts.keep_runs.unwrap_or(self.project.keep_runs))
1489            {
1490                self.ui.step(Level::Debug, "Removed", &format!("run {id}"), None);
1491            }
1492        }
1493        // Spools are scratch space.
1494        let _ = std::fs::remove_dir_all(self.run_dir.join(".spool"));
1495        self.outcome(status, err)
1496    }
1497
1498    fn run_inner(&mut self) -> Result<(), Fail> {
1499        let errors = crate::variables::check_run_values(&self.report.var_schema, &self.vars, &self.opts.vars);
1500        if !errors.is_empty() {
1501            return Err(errors.join("; ").into());
1502        }
1503        // The parse pass with this run's inputs: each query's connection and sources.
1504        self.parsed = crate::parse::binding(self.project, self.report, self.b, &self.vars, &self.inputs());
1505        if !self.parsed.errors.is_empty() {
1506            let msgs: Vec<String> = self
1507                .parsed
1508                .errors
1509                .iter()
1510                .map(|p| match p.line {
1511                    Some(l) => format!("{}:{l}: {}", p.file.display(), p.message),
1512                    None => format!("{}: {}", p.file.display(), p.message),
1513                })
1514                .collect();
1515            return Err(Fail::new(Code::ParseFailed, msgs.join("\n    ")));
1516        }
1517        // Sessions are opened only when something needs the database, so compiling a report
1518        // whose templates don't query it works without the plugin or credentials.
1519        let pool = Arc::new(Pool::new(
1520            self.connections(),
1521            self.project.root.clone(),
1522            self.ui.plugin_log(),
1523            !self.report.managed,
1524        ));
1525        self.pool = Some(pool.clone());
1526
1527        if !self.report.managed {
1528            self.ui.warn(&format!(
1529                "`{}` is an unmanaged report ({}): for quick tests only — add a YAML to make it a managed report",
1530                self.report.name,
1531                self.report.file.display()
1532            ));
1533        }
1534
1535        // 1. Render.
1536        std::fs::create_dir_all(&self.compiled_dir).map_err(|e| e.to_string())?;
1537        // How many queries run on each connection: one session holds them all.
1538        let mut per_connection: BTreeMap<String, usize> = BTreeMap::new();
1539        for q in &self.b.queries {
1540            *per_connection.entry(self.connection_of(&q.query)?).or_default() += 1;
1541        }
1542        // A real run of a managed report renders each query just before running it, so a
1543        // template can look at what earlier queries made (`columns()` of a temp table).
1544        // Compiling, checking and unmanaged reports render everything first.
1545        let interleave = !self.opts.dry_run && !self.opts.live_check && self.report.managed;
1546        let mut statements = Vec::new();
1547        // Reported after the unmanaged-report check, which matters more.
1548        let mut two_tabs: Option<String> = None;
1549        let retry_snapshot = match &self.opts.retry_failed {
1550            Some(id) => self.start_retry(id)?,
1551            None => None,
1552        };
1553        if let Some(dir) = retry_snapshot {
1554            self.render_destinations().map_err(|e| e.or(Code::RenderFailed))?;
1555            self.replay_from(&dir, "the saved results")?;
1556        } else if let Some(dir) = self.opts.from_results.clone() {
1557            self.replay(&dir)?;
1558        } else if interleave {
1559            self.render_destinations().map_err(|e| e.or(Code::RenderFailed))?;
1560            let mut checked: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1561            for (c, n) in &per_connection {
1562                if *n > 1 {
1563                    self.check_sessions(&pool, c, *n)?;
1564                    checked.insert(c.clone());
1565                }
1566            }
1567            std::fs::create_dir_all(self.run_dir.join(".spool")).map_err(|e| e.to_string())?;
1568            let mut i = 0;
1569            let queries = self.b.queries.clone();
1570            for q in &queries {
1571                let conn = self.connection_of(&q.query)?;
1572                let renderer = self.renderer(Some(&conn))?;
1573                let sts = self.render_query(&renderer, q, &conn, &mut two_tabs)?;
1574                for w in renderer.take_warnings() {
1575                    self.ui.warn(&w);
1576                }
1577                if let Some(e) = two_tabs.take() {
1578                    return Err(e.into());
1579                }
1580                if sts.len() > 1 && checked.insert(conn.clone()) {
1581                    self.check_sessions(&pool, &conn, sts.len())?;
1582                }
1583                let session = pool.session(&conn)?;
1584                for st in &sts {
1585                    self.execute_statement(&session, i, st)?;
1586                    i += 1;
1587                }
1588            }
1589        } else {
1590            let queries = self.b.queries.clone();
1591            for q in &queries {
1592                let conn = self.connection_of(&q.query)?;
1593                let renderer = self.renderer(Some(&conn))?;
1594                statements.extend(self.render_query(&renderer, q, &conn, &mut two_tabs)?);
1595            }
1596            self.render_destinations().map_err(|e| e.or(Code::RenderFailed))?;
1597            for r in self.renderers.values() {
1598                for w in r.take_warnings() {
1599                    self.ui.warn(&w);
1600                }
1601            }
1602
1603            // 3. Unmanaged: every rendered statement must only read (or create temp objects).
1604            if !self.report.managed {
1605                if let Some(bad) = statements.iter().find(|s| !s.kind.is_read_only_safe()) {
1606                    return Err(format!(
1607                        "{}:{}: unmanaged report `{}` may only run SELECT/WITH or CREATE [OR REPLACE] TEMP|TEMPORARY TABLE|VIEW, but found `{}`; nothing was run — rewrite the statement, or give the report a YAML to declare it",
1608                        bad.file.display(),
1609                        bad.line,
1610                        self.report.name,
1611                        masked_sql_summary(&bad.text, 60, bad.sensitive)
1612                    ).into());
1613                }
1614                for conn in per_connection.keys() {
1615                    let session = pool.session(conn)?;
1616                    let creates_temp = statements
1617                        .iter()
1618                        .any(|s| &s.connection == conn && s.kind == StatementKind::TempCreate)
1619                        || session.lock().unwrap().loaded;
1620                    session.lock().unwrap().want_read_only(!creates_temp);
1621                }
1622            }
1623            if let Some(e) = two_tabs {
1624                return Err(e.into());
1625            }
1626
1627            if self.opts.dry_run {
1628                let plan = self.plan();
1629                self.ui.compiled(&plan);
1630                return Ok(());
1631            }
1632
1633            // The one-session-per-connection guarantee.
1634            for conn in per_connection.keys() {
1635                let n = statements.iter().filter(|s| &s.connection == conn).count();
1636                if n > 1 {
1637                    self.check_sessions(&pool, conn, n)?;
1638                }
1639            }
1640
1641            if self.opts.live_check {
1642                return self.live_check(&statements);
1643            }
1644
1645            // 4. Execute in YAML order, each on its connection's session.
1646            std::fs::create_dir_all(self.run_dir.join(".spool")).map_err(|e| e.to_string())?;
1647            for (i, st) in statements.iter().enumerate() {
1648                let session = pool.session(&st.connection)?;
1649                self.execute_statement(&session, i, st)?;
1650            }
1651        }
1652        pool.close_all();
1653
1654        self.name_result_sets()?;
1655        if let Some(dir) = &self.opts.output_json {
1656            let dir = self
1657                .project
1658                .root
1659                .join(dir)
1660                .join(&self.report.name)
1661                .join(self.b.dir_name());
1662            std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
1663            let mut index = crate::saved::Index {
1664                schema_version: crate::saved::SCHEMA_VERSION.into(),
1665                run_id: self.run_id.clone(),
1666                results: Vec::new(),
1667            };
1668            for result in &self.produced {
1669                crate::rows::write_spool(
1670                    &result.spool,
1671                    &dir.join(format!("{}.json", result.name)),
1672                    result.rows,
1673                )
1674                .map_err(|e| e.to_string())?;
1675                // The exact Arrow stream too, for `--from-results`.
1676                let file = format!("{}.arrows", result.name);
1677                crate::saved::spool_to_stream(&result.spool, &dir.join(&file))?;
1678                index.results.push(crate::saved::SavedResult {
1679                    query: result.query.clone(),
1680                    name: result.name.clone(),
1681                    connection: result.connection.clone(),
1682                    rows: result.rows,
1683                    sql_sha256: std::fs::read_to_string(
1684                        self.compiled_dir.join(format!("{}.sha256", result.query)),
1685                    )
1686                    .ok()
1687                    .map(|h| h.trim().to_string()),
1688                    file,
1689                });
1690            }
1691            crate::saved::write_index(&dir, &index).map_err(|e| e.to_string())?;
1692        }
1693
1694        // 5. Format every file output into target/run/; one whose `when:` is false is skipped.
1695        let (file_outs, message_outs): (Vec<usize>, Vec<usize>) =
1696            (0..self.outs.len()).partition(|&i| !self.b.outputs[i].is_message());
1697        // A per-row output keeps the run's results, so `--retry-failed` redoes rows from them.
1698        if self.retry.is_none()
1699            && self.opts.from_results.is_none()
1700            && self.opts.preview.is_none()
1701            && self
1702                .b
1703                .outputs
1704                .iter()
1705                .any(|o| o.for_each.as_ref().is_some_and(|f| f.keep_results))
1706        {
1707            self.save_snapshot()?;
1708        }
1709        // `on_empty` first, for every output: a failure stops the run before anything is written.
1710        for oi in 0..self.outs.len() {
1711            self.has_data(oi)?;
1712        }
1713        if self.retry.is_some() {
1714            for oi in 0..self.outs.len() {
1715                if self.b.outputs[oi].for_each.is_none() {
1716                    self.skip(oi, "--retry-failed redoes per-row outputs only");
1717                    self.outs[oi].reason = Some("retry".into());
1718                }
1719            }
1720        }
1721        for &oi in &file_outs {
1722            if self.outs[oi].status == Some(OutputStatus::Skipped) || !self.passes_when(oi)? {
1723                continue;
1724            }
1725            // A per-row output is made and delivered row by row, after the drift check.
1726            if self.b.outputs[oi].for_each.is_some() {
1727                continue;
1728            }
1729            let filename = self.file_names(oi);
1730            self.format(oi, &filename).map_err(|e| e.or(Code::FormatFailed))?;
1731        }
1732
1733        // Schema drift, before delivery.
1734        if self.opts.preview.is_none() {
1735            self.drift = self.schema_drift();
1736            if !self.drift.is_empty() {
1737                if self.opts.accept_schema_change {
1738                    self.ui.warn(&format!(
1739                        "  schema changed since the last successful run (accepted): {}",
1740                        self.drift.join("; ")
1741                    ));
1742                } else {
1743                    return Err(format!(
1744                        "schema drift since the last successful run: {}; the output is in {} but was not delivered — pass --accept-schema-change to deliver it and accept the new schema",
1745                        self.drift.join("; "),
1746                        rel(&self.project.root, &self.run_dir).display()
1747                    ).into());
1748                }
1749            }
1750        }
1751
1752        // 6. Deliver the file outputs, then render and deliver each message, so a message can
1753        // link to what was delivered. A failed output doesn't stop the next one.
1754        let preview = self.opts.preview.is_some() || self.opts.no_deliver;
1755        let mut failures = Vec::new();
1756        for oi in file_outs.into_iter().chain(message_outs) {
1757            if self.outs[oi].status == Some(OutputStatus::Skipped) {
1758                continue;
1759            }
1760            if self.b.outputs[oi].for_each.is_some() {
1761                if let Err(e) = self.run_rows(oi) {
1762                    if e.code == Code::RunCancelled || e.code == Code::RunTimedOut {
1763                        return Err(e);
1764                    }
1765                    self.outs[oi].status = Some(OutputStatus::Failed);
1766                    self.outs[oi].error.get_or_insert_with(|| e.message.clone());
1767                    failures.push(e);
1768                }
1769                continue;
1770            }
1771            if self.b.outputs[oi].is_message() {
1772                match self.render_message(oi) {
1773                    Ok(true) => {}
1774                    Ok(false) => continue,
1775                    Err(e) => {
1776                        let e = e.or(Code::RenderFailed);
1777                        let e = Fail::new(e.code.clone(), format!("{}: {e}", self.b.outputs[oi].label(oi)));
1778                        self.outs[oi].status = Some(OutputStatus::Failed);
1779                        self.outs[oi].error = Some(e.message.clone());
1780                        failures.push(e);
1781                        continue;
1782                    }
1783                }
1784            }
1785            if preview {
1786                self.outs[oi].delivery_note = Some(if self.opts.no_deliver {
1787                    "--no-deliver: output kept locally".into()
1788                } else {
1789                    "preview: not delivered".into()
1790                });
1791                self.outs[oi].status = Some(OutputStatus::Kept);
1792            } else if let Err(e) = self.deliver(oi) {
1793                failures.push(e.or(Code::DeliveryFailed));
1794            }
1795        }
1796        if preview {
1797            let dir = rel(&self.project.root, &self.run_dir);
1798            self.ui.step(
1799                Level::Info,
1800                "Preview",
1801                &format!("not delivered; output stays in {}", dir.display()),
1802                None,
1803            );
1804        }
1805        if let Some(first) = failures.first() {
1806            let messages: Vec<&str> = failures.iter().map(|f| f.message.as_str()).collect();
1807            return Err(Fail::new(first.code.clone(), messages.join("; ")));
1808        }
1809
1810        // 7. Snapshot the schema for the next drift check (not from a run for an earlier instant
1811        // than the current one).
1812        if self.opts.preview.is_none()
1813            && self.becomes_current
1814            && let Err(error) = self.write_snapshot()
1815        {
1816            self.ui.warn(&format!(
1817                "delivery completed, but the schema snapshot could not be written: {error}; the next run may compare against an older schema"
1818            ));
1819        }
1820        Ok(())
1821    }
1822
1823    /// Output `oi`'s queries' results, `rows` capped at `max_rows`.
1824    fn results_for(&mut self, oi: usize, max_rows: u64) -> Result<Vec<(String, Arc<QueryResult>)>, Fail> {
1825        let o = &self.b.outputs[oi];
1826        let mut out = Vec::new();
1827        for p in self.produced.iter().filter(|p| o.feeds(&p.query)) {
1828            let r = QueryResult::read(&p.spool, p.rows, max_rows, self.calendar)
1829                .map_err(|e| format!("can't read the result of `{}`: {e}", p.query))?;
1830            out.push((p.query.clone(), Arc::new(r)));
1831        }
1832        Ok(out)
1833    }
1834
1835    /// What output `oi`'s templates read besides the usual context: `results` and `outputs`
1836    /// (every other named output: `location`, `files` and `status`).
1837    fn template_context(&self, oi: usize, results: &[(String, Arc<QueryResult>)]) -> minijinja::Value {
1838        use minijinja::Value;
1839        let results: BTreeMap<String, Value> = results
1840            .iter()
1841            .map(|(q, r)| (q.clone(), r.clone().value()))
1842            .collect();
1843        let mut outputs: BTreeMap<String, Value> = BTreeMap::new();
1844        for (j, (o, run)) in self.b.outputs.iter().zip(&self.outs).enumerate() {
1845            let Some(name) = &o.name else { continue };
1846            if j == oi {
1847                continue;
1848            }
1849            let locations: Vec<&str> = run
1850                .deliveries
1851                .iter()
1852                .filter_map(|d| d.location.as_deref())
1853                .collect();
1854            let files: Vec<String> = run
1855                .files
1856                .iter()
1857                .map(|(f, _)| record_path(self.project, f).to_string_lossy().to_string())
1858                .collect();
1859            let status = run.status.map_or("pending", OutputStatus::as_str);
1860            outputs.insert(
1861                name.clone(),
1862                Value::from(minijinja::value::Serde(json!({
1863                    "location": locations.join(", "),
1864                    "files": files,
1865                    "status": status,
1866                }))),
1867            );
1868        }
1869        Value::from(BTreeMap::from([
1870            ("results".to_string(), Value::from(results)),
1871            ("outputs".to_string(), Value::from(outputs)),
1872        ]))
1873    }
1874
1875    /// Apply output `oi`'s `on_empty` (and its queries'). `Ok(false)`: skipped for no data;
1876    /// an error fails the run (`no-data`).
1877    fn has_data(&mut self, oi: usize) -> Result<bool, Fail> {
1878        let o = &self.b.outputs[oi];
1879        let label = o.label(oi);
1880        // A per-row output's population is its driver: each row's slices are checked per row.
1881        let queries: Vec<crate::no_data::Query<'_>> = self
1882            .produced
1883            .iter()
1884            .filter(|p| match &o.for_each {
1885                Some(f) => p.query == f.query,
1886                None => o.feeds(&p.query),
1887            })
1888            .map(|p| crate::no_data::Query {
1889                name: &p.query,
1890                rows: p.rows,
1891                on_empty: self
1892                    .b
1893                    .queries
1894                    .iter()
1895                    .find(|q| q.query == p.query)
1896                    .and_then(|q| q.on_empty),
1897            })
1898            .collect();
1899        let droppable = !(o.is_message()
1900            || crate::document::FORMATS.contains(&o.format.as_str())
1901            || o.for_each.is_some());
1902        let d = crate::no_data::decide(o.on_empty, &queries, droppable);
1903        self.outs[oi].no_data = d.empty.clone();
1904        for w in &d.warnings {
1905            self.ui.warn(&format!("  {label}: {w}"));
1906        }
1907        if let Some(info) = &d.info {
1908            self.ui
1909                .step(Level::Info, "No data", &format!("{label}: {info}"), None);
1910        }
1911        if let Some(msg) = d.fail {
1912            self.outs[oi].reason = Some("no_data".into());
1913            return Err(Fail::new(Code::NoData, format!("{label}: {msg}")));
1914        }
1915        if let Some(why) = d.skip {
1916            self.outs[oi].reason = Some("no_data".into());
1917            self.skip(oi, &why);
1918            return Ok(false);
1919        }
1920        self.outs[oi].dropped = d.drop;
1921        Ok(true)
1922    }
1923
1924    /// Evaluate output `oi`'s `when:`. False: the output is skipped (recorded, not a failure).
1925    fn passes_when(&mut self, oi: usize) -> Result<bool, Fail> {
1926        if self.b.outputs[oi].when.is_none() {
1927            return Ok(true);
1928        }
1929        let results = self.results_for(oi, crate::message::DEFAULT_MAX_ROWS)?;
1930        self.when_holds(oi, &results)
1931    }
1932
1933    /// Output `oi`'s `when:` over `results` (the same ones its message reads).
1934    fn when_holds(&mut self, oi: usize, results: &[(String, Arc<QueryResult>)]) -> Result<bool, Fail> {
1935        let Some(when) = self.b.outputs[oi].when.clone() else {
1936            return Ok(true);
1937        };
1938        let ctx = self.template_context(oi, results);
1939        let renderer = self.renderer(None)?;
1940        let label = self.b.outputs[oi].label(oi);
1941        let src = format!("{{% if {when} %}}true{{% endif %}}");
1942        let passed = renderer
1943            .render_with(&self.report.file, &src, ctx, false)
1944            .map_err(|e| format!("{label}: `when` `{when}`: {}", e.message))?
1945            == "true";
1946        self.outs[oi].when = Some(passed);
1947        if !passed {
1948            self.skip(oi, &format!("`when` is false ({when})"));
1949        }
1950        Ok(passed)
1951    }
1952
1953    fn skip(&mut self, oi: usize, why: &str) {
1954        let label = self.b.outputs[oi].label(oi);
1955        self.outs[oi].status = Some(OutputStatus::Skipped);
1956        self.outs[oi].delivery_note = Some(format!("skipped: {why}"));
1957        self.ui
1958            .step(Level::Info, "Skipped", &format!("{label}: {why}"), None);
1959    }
1960
1961    /// Render message output `oi` and write its `.md` file. `Ok(false)`: skipped (`when:` false,
1962    /// or the text rendered empty).
1963    fn render_message(&mut self, oi: usize) -> Result<bool, Fail> {
1964        let o = &self.b.outputs[oi];
1965        let opt = |k: &str| o.options.get(k).and_then(Json::as_str).map(str::to_string);
1966        let (text_src, file_src, title_src) = (opt("text"), opt("file"), opt("title"));
1967        let max_rows = o
1968            .options
1969            .get("max_rows")
1970            .and_then(Json::as_u64)
1971            .unwrap_or(crate::message::DEFAULT_MAX_ROWS);
1972        let results = self.results_for(oi, max_rows)?;
1973        if !self.when_holds(oi, &results)? {
1974            return Ok(false);
1975        }
1976        for (q, r) in results.iter().filter(|(_, r)| r.capped()) {
1977            self.ui.warn(&format!(
1978                "  results.{q}.rows holds the first {} of {} rows (`max_rows`); aggregate in SQL, or raise `max_rows`",
1979                thousands(r.rows.len() as u64),
1980                thousands(r.row_count)
1981            ));
1982        }
1983        let ctx = self.template_context(oi, &results);
1984        let renderer = self.renderer(None)?;
1985        let file = self.report.file.clone();
1986        let text = match (text_src, file_src) {
1987            (Some(t), _) => renderer
1988                .render_with(&file, &t, ctx.clone(), true)
1989                .map_err(|e| e.message)?,
1990            (None, Some(f)) => {
1991                let root = &self.project.root;
1992                let path = crate::project::find_template(root, &f)
1993                    .ok_or_else(|| format!("message file `{f}` doesn't exist"))?;
1994                let src = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
1995                let shown = rel(root, &path);
1996                renderer
1997                    .render_with(&shown, &src, ctx.clone(), true)
1998                    .map_err(|e| e.to_string())?
1999            }
2000            (None, None) => crate::message::default_text(&results, self.locale()),
2001        };
2002        let text = text.trim().to_string();
2003        if text.is_empty() {
2004            self.skip(oi, "the message is empty");
2005            return Ok(false);
2006        }
2007        let title = match title_src {
2008            Some(t) => renderer
2009                .render_with(&file, &t, ctx, false)
2010                .map_err(|e| e.message)?,
2011            None => format!("{}: {}", self.report.name, self.date),
2012        };
2013        let title = title.split_whitespace().collect::<Vec<_>>().join(" ");
2014        let name = self.file_names(oi);
2015        let path = self.run_dir.join(&name);
2016        if let Some(prev) = (0..oi).find(|&j| self.outs[j].files.iter().any(|(f, _)| f == &path)) {
2017            return Err(format!(
2018                "it writes {name}, as {} does; give them different `name:`s",
2019                self.b.outputs[prev].label(prev)
2020            )
2021            .into());
2022        }
2023        std::fs::create_dir_all(&self.run_dir).map_err(|e| e.to_string())?;
2024        std::fs::write(&path, crate::message::file_body(&title, &text)).map_err(|e| e.to_string())?;
2025        self.outs[oi].files.push((path, None));
2026        // Protect preview and metadata copies; report files and deliveries retain their values.
2027        let shown_title = crate::secrets::mask_message_title(&title);
2028        let shown_text = crate::secrets::mask_markdown(&text);
2029        if self.opts.preview.is_some() {
2030            let mut notes = Vec::new();
2031            if let Some(n) = self.opts.preview {
2032                notes.push(format!(
2033                    "numbers come from a sample of at most {} rows per query (--preview)",
2034                    thousands(n)
2035                ));
2036            }
2037            if let Some(w) = &self.b.outputs[oi].when {
2038                notes.push(format!("`when` passed ({w})"));
2039            }
2040            let length = text.chars().count();
2041            notes.push(format!("{} characters", thousands(length as u64)));
2042            notes.extend(self.destination_limits(oi, length));
2043            self.ui.message(&ShownMessage {
2044                output: self.b.outputs[oi].name.clone(),
2045                title: shown_title.into_owned(),
2046                text: shown_text.into_owned(),
2047                notes,
2048            });
2049        } else {
2050            self.ui.step(
2051                Level::Info,
2052                "Message",
2053                &crate::message::excerpt(&title, &text, 100),
2054                None,
2055            );
2056        }
2057        self.outs[oi].message = Some((title, text));
2058        Ok(true)
2059    }
2060
2061    /// For `--preview`: how each destination of message output `oi` takes it, and its limit.
2062    fn destination_limits(&self, oi: usize, length: usize) -> Vec<String> {
2063        let mut out = Vec::new();
2064        let Some(pool) = &self.pool else { return out };
2065        for d in &self.outs[oi].dests {
2066            let Some(t) = self.project.profiles.target(Role::Destination, &d.profile) else {
2067                out.push(format!("{}: delivers nowhere on this target", d.profile));
2068                continue;
2069            };
2070            let described = pool.connections.described(Role::Destination, &t.kind);
2071            let takes = described
2072                .as_ref()
2073                .is_some_and(|x| x.capabilities.iter().any(|c| c == CAP_MESSAGE));
2074            let limit = described.and_then(|x| x.message_limit);
2075            out.push(match (takes, limit) {
2076                (false, _) => format!("{} ({}): delivers the .md file", d.profile, t.kind),
2077                (true, Some(l)) if length as u64 > l => format!(
2078                    "{} ({}): {} of {} characters: over the limit, so it will be cut short",
2079                    d.profile,
2080                    t.kind,
2081                    thousands(length as u64),
2082                    thousands(l)
2083                ),
2084                (true, Some(l)) => format!(
2085                    "{} ({}): {} of {} characters",
2086                    d.profile,
2087                    t.kind,
2088                    thousands(length as u64),
2089                    thousands(l)
2090                ),
2091                (true, None) => format!("{} ({}): posts the message", d.profile, t.kind),
2092            });
2093        }
2094        out
2095    }
2096
2097    /// The Binding's locale for the number filters.
2098    fn locale(&self) -> crate::numbers::Locale {
2099        self.b
2100            .locale
2101            .as_deref()
2102            .and_then(|l| crate::numbers::Locale::parse(l).ok())
2103            .unwrap_or_default()
2104    }
2105
2106    /// Render one query file into its statements, writing it to `target/compiled/`.
2107    fn render_query(
2108        &mut self,
2109        renderer: &Renderer,
2110        q: &QueryEntry,
2111        connection: &str,
2112        two_tabs: &mut Option<String>,
2113    ) -> Result<Vec<Statement>, Fail> {
2114        let mut out = Vec::new();
2115        let src = std::fs::read_to_string(self.project.root.join(&q.path))
2116            .map_err(|e| format!("{}: {e}", q.path.display()))?;
2117        let _ = renderer.take_sources();
2118        let sql = renderer
2119            .render(&q.path, &src)
2120            .map_err(|e: RenderError| e.to_string())?;
2121        // The manifest's `depends_on` and the query's connection come from the parse pass, so
2122        // a `source()` it couldn't see would make both wrong.
2123        let known = self
2124            .parsed
2125            .query(&q.query)
2126            .map(|p| p.sources.clone())
2127            .unwrap_or_default();
2128        if let Some((s, t)) = renderer
2129            .take_sources()
2130            .into_iter()
2131            .find(|(s, t)| !known.contains(&format!("{s}.{t}")))
2132        {
2133            return Err(format!(
2134                "{}: `source('{s}', '{t}')` was reached while rendering `{}`, but the parse pass didn't find it. The parse pass renders without data (`run_query()` returns no rows, `connection.*` nothing), so it can't see a `source()` that depends on them; call it where it's reached either way",
2135                q.path.display(),
2136                q.query
2137            ).into());
2138        }
2139        let sensitive = crate::secrets::contains_secret(&sql);
2140        std::fs::write(
2141            self.compiled_dir.join(format!("{}.sql", q.query)),
2142            crate::secrets::mask(&sql).as_bytes(),
2143        )
2144        .map_err(|e| e.to_string())?;
2145        // Hash the exact rendered SQL before masking display artifacts. Integrations can
2146        // identify executed SQL without receiving any secret embedded in it.
2147        {
2148            use sha2::Digest;
2149            let digest = sha2::Sha256::digest(sql.as_bytes());
2150            let hash = digest
2151                .iter()
2152                .map(|byte| format!("{byte:02x}"))
2153                .collect::<String>();
2154            std::fs::write(self.compiled_dir.join(format!("{}.sha256", q.query)), hash)
2155                .map_err(|e| e.to_string())?;
2156        }
2157        self.ui
2158            .step(Level::Debug, "Rendered", &q.path.display().to_string(), None);
2159        // 2. Split. One file makes at most one tab, from its last statement, so any earlier
2160        // SELECT would be lost: that's an error, not a guess.
2161        let parts = sqlsplit::split(&sql);
2162        if q.tab && parts.is_empty() {
2163            return Err(format!(
2164                "{}: `{}` makes a tab but has no statements; add a query, or `tab: false` if it's meant to be empty",
2165                q.path.display(),
2166                q.query
2167            ).into());
2168        }
2169        let n = parts.len();
2170        for (i, st) in parts.into_iter().enumerate() {
2171            let body = sqlsplit::strip_leading_comments(&st.text);
2172            let line = st.line + st.text[..st.text.len() - body.len()].matches('\n').count();
2173            let kind = sqlsplit::classify(&st.text);
2174            let last = i + 1 == n;
2175            if q.tab && !last && kind == StatementKind::Read && two_tabs.is_none() {
2176                *two_tabs = Some(format!(
2177                    "{}:{line}: `{}` has more than one SELECT, but one .sql file makes one tab (from its last statement); put each tab's query in its own .sql file and list each in `queries:`",
2178                    q.path.display(),
2179                    q.query
2180                ));
2181            }
2182            out.push(Statement {
2183                query: q.query.clone(),
2184                connection: connection.to_string(),
2185                file: q.path.clone(),
2186                line,
2187                kind,
2188                text: st.text,
2189                sensitive,
2190                tab: q.tab && last,
2191            });
2192        }
2193        Ok(out)
2194    }
2195
2196    /// Fail unless `connection`'s plugin can hold one session across `n` statements.
2197    fn check_sessions(&self, pool: &Pool, connection: &str, n: usize) -> Result<(), Fail> {
2198        let session = pool.session(connection)?;
2199        let mut s = session.lock().unwrap();
2200        if !s
2201            .get()
2202            .map_err(|e| Fail::new(Code::ConnectionFailed, e))?
2203            .has(CAP_SESSIONS)
2204        {
2205            let kind = pool.kind(connection).unwrap_or_default();
2206            return Err(format!(
2207                "this Binding runs {n} statements on connection `{connection}`, but the `{kind}` source plugin can't hold one session across them; nothing was run"
2208            ).into());
2209        }
2210        Ok(())
2211    }
2212
2213    /// Run statement `i`, spooling its result if it makes a tab.
2214    fn execute_statement(
2215        &mut self,
2216        session: &Arc<Mutex<Session>>,
2217        i: usize,
2218        st: &Statement,
2219    ) -> Result<(), Fail> {
2220        self.not_cancelled()?;
2221        let sql_log = self.ui.sql_log();
2222        sql_log(
2223            &format!("{}:{}", st.file.display(), st.line),
2224            &protected_sql(&st.text, st.sensitive),
2225        );
2226        let spool_path = self.run_dir.join(".spool").join(format!("{i}.arrow"));
2227        let mut writer: Option<FileWriter<File>> = None;
2228        let t = Instant::now();
2229        let exec = {
2230            let mut s = session.lock().unwrap();
2231            let (p, _log_scope) = s
2232                .statement(st.sensitive)
2233                .map_err(|e| Fail::new(Code::ConnectionFailed, e))?;
2234            p.execute(&st.text, self.row_limit(), |schema, batch| {
2235                if writer.is_none() {
2236                    let f = File::create(&spool_path).map_err(|e| e.to_string())?;
2237                    writer = Some(FileWriter::try_new(f, schema).map_err(|e| e.to_string())?);
2238                }
2239                writer.as_mut().unwrap().write(&batch).map_err(|e| e.to_string())
2240            })
2241            .map_err(|e| {
2242                let message = format!(
2243                    "{}:{}: {}",
2244                    st.file.display(),
2245                    st.line,
2246                    masked_source_error(&e, st.sensitive)
2247                );
2248                Fail::from_plugin(&e, message).or(Code::QueryFailed)
2249            })?
2250        };
2251        let what = match &exec {
2252            Execution::Result { rows, .. } => {
2253                format!("{} row{}", thousands(*rows), if *rows == 1 { "" } else { "s" })
2254            }
2255            Execution::NoResult {
2256                rows_affected: Some(n),
2257            } => format!("no result set ({n} affected)"),
2258            Execution::NoResult { .. } => "no result set".to_string(),
2259        };
2260        let at = format!("{}:{}", st.file.display(), st.line);
2261        self.ui.step(
2262            Level::Debug,
2263            "Executed",
2264            &format!("{at}  {what}"),
2265            Some(t.elapsed()),
2266        );
2267        // The YAML decides the tabs; the result only has to match it. A tab with no rows
2268        // still gets its column names.
2269        let (schema, rows) = match (st.tab, exec) {
2270            (true, Execution::Result { schema, rows }) => (schema, rows),
2271            (true, Execution::NoResult { .. }) => {
2272                return Err(Fail::new(
2273                    Code::NoResultSet,
2274                    format!(
2275                        "{at}: `{}` makes a tab, but its last statement returned no result set; if the query only prepares data (a temp view, a SET), add `tab: false` to it in the YAML",
2276                        st.query
2277                    ),
2278                ));
2279            }
2280            (false, _) => {
2281                drop(writer);
2282                let _ = std::fs::remove_file(&spool_path);
2283                return Ok(());
2284            }
2285        };
2286        {
2287            let mut w = match writer {
2288                Some(w) => w,
2289                None => FileWriter::try_new(File::create(&spool_path).map_err(|e| e.to_string())?, &schema)
2290                    .map_err(|e| e.to_string())?,
2291            };
2292            w.finish().map_err(|e| e.to_string())?;
2293            self.produced.push(Produced {
2294                query: st.query.clone(),
2295                connection: st.connection.clone(),
2296                schema,
2297                rows,
2298                spool: spool_path,
2299                name: String::new(),
2300                anchor: None,
2301                header: None,
2302                columns: Default::default(),
2303                autofit: None,
2304                style: None,
2305            });
2306        }
2307        Ok(())
2308    }
2309
2310    /// `--from-results`: this Binding's results from `dir` (saved by `--output-json`), as if its
2311    /// queries had run. No query runs and no connection opens; a query whose SQL changed since
2312    /// is warned about, when its SQL renders without the database.
2313    fn replay(&mut self, dir: &Path) -> Result<(), Fail> {
2314        let shown = dir.display().to_string();
2315        let dir = self
2316            .project
2317            .root
2318            .join(dir)
2319            .join(&self.report.name)
2320            .join(self.b.dir_name());
2321        if let Err(e) = crate::saved::read_index(&dir) {
2322            return Err(format!(
2323                "--from-results {shown}: no saved results for this report and Set ({e}); save them with `dre run {} --no-deliver --output-json {shown}`",
2324                self.report.name
2325            )
2326            .into());
2327        }
2328        self.replay_from(&dir, &format!("--from-results {shown}"))
2329    }
2330
2331    /// Replay the saved results in `dir` (an index and its streams); `shown` names them in
2332    /// messages.
2333    fn replay_from(&mut self, dir: &Path, shown: &str) -> Result<(), Fail> {
2334        let dir = dir.to_path_buf();
2335        let index = crate::saved::read_index(&dir).map_err(|e| format!("{shown}: {e}"))?;
2336        let queries: Vec<QueryEntry> = self.b.queries.iter().filter(|q| q.tab).cloned().collect();
2337        let missing: Vec<&str> = queries
2338            .iter()
2339            .filter(|q| index.find(&q.query).is_none())
2340            .map(|q| q.query.as_str())
2341            .collect();
2342        if !missing.is_empty() {
2343            return Err(format!(
2344                "{shown}: no saved result for {} (saved: {}); save them again with `--output-json`",
2345                missing
2346                    .iter()
2347                    .map(|q| format!("`{q}`"))
2348                    .collect::<Vec<_>>()
2349                    .join(", "),
2350                index
2351                    .results
2352                    .iter()
2353                    .map(|r| r.query.as_str())
2354                    .collect::<Vec<_>>()
2355                    .join(", ")
2356            )
2357            .into());
2358        }
2359        // Warn when a query's SQL changed since the results were saved.
2360        for q in &queries {
2361            let saved = index.find(&q.query).and_then(|r| r.sql_sha256.clone());
2362            let rendered = self
2363                .connection_of(&q.query)
2364                .and_then(|conn| {
2365                    let renderer = self.renderer(Some(&conn))?;
2366                    let mut two = None;
2367                    self.render_query(&renderer, q, &conn, &mut two)
2368                })
2369                .map(|_| {
2370                    std::fs::read_to_string(self.compiled_dir.join(format!("{}.sha256", q.query)))
2371                        .ok()
2372                        .map(|h| h.trim().to_string())
2373                });
2374            match (rendered, saved) {
2375                (Ok(Some(now)), Some(then)) if now != then => self.ui.warn(&format!(
2376                    "  `{}`'s SQL changed since its results were saved; re-run with `--output-json` to refresh them",
2377                    q.query
2378                )),
2379                (Err(e), _) => self.ui.warn(&format!(
2380                    "  `{}`: can't render its SQL to compare with the saved results ({}); using them as saved",
2381                    q.query, e.message
2382                )),
2383                _ => {}
2384            }
2385        }
2386        std::fs::create_dir_all(self.run_dir.join(".spool")).map_err(|e| e.to_string())?;
2387        for (i, q) in queries.iter().enumerate() {
2388            let r = index.find(&q.query).expect("checked above");
2389            let spool = self.run_dir.join(".spool").join(format!("{i}.arrow"));
2390            let (schema, rows) = crate::saved::stream_to_spool(&dir.join(&r.file), &spool)?;
2391            self.produced.push(Produced {
2392                query: q.query.clone(),
2393                connection: r.connection.clone(),
2394                schema,
2395                rows,
2396                spool,
2397                name: String::new(),
2398                anchor: None,
2399                header: None,
2400                columns: Default::default(),
2401                autofit: None,
2402                style: None,
2403            });
2404        }
2405        self.ui.step(
2406            Level::Info,
2407            "Replayed",
2408            &format!("{} result(s) from {shown}; no query ran", queries.len()),
2409            None,
2410        );
2411        Ok(())
2412    }
2413
2414    /// Each destination's rendered `profile`, `path` and options; `destination.*` is that
2415    /// destination while its values render.
2416    fn render_destinations(&mut self) -> Result<(), Fail> {
2417        if self.opts.no_deliver {
2418            return Ok(());
2419        }
2420        if self.b.destinations().next().is_none() {
2421            return Ok(());
2422        }
2423        let renderer = self.renderer(None)?;
2424        let connections = self.pool.as_ref().expect("pool").connections.clone();
2425        // Destinations are numbered across every output, as the parse pass renders them.
2426        let numbered: Vec<(usize, usize, &crate::project::Destination)> = self
2427            .b
2428            .outputs
2429            .iter()
2430            .enumerate()
2431            .flat_map(|(oi, o)| o.destinations.iter().map(move |d| (oi, d)))
2432            .enumerate()
2433            .map(|(i, (oi, d))| (i, oi, d))
2434            .collect();
2435        for (i, oi, d) in numbered {
2436            // A per-row output's destinations are rendered for each row, with its `each`.
2437            if self.b.outputs[oi].for_each.is_some() {
2438                continue;
2439            }
2440            let profile = self
2441                .parsed
2442                .destinations
2443                .get(i)
2444                .cloned()
2445                .flatten()
2446                .ok_or_else(|| format!("destination `{}` has no profile", d.profile))?;
2447            // Nothing is delivered, so there's no path or options to render.
2448            if matches!(
2449                self.project.profiles.entry(Role::Destination, &profile),
2450                Entry::Nowhere
2451            ) {
2452                self.outs[oi].dests.push(RenderedDest {
2453                    profile,
2454                    path: None,
2455                    options: JsonMap::new(),
2456                    attach: Vec::new(),
2457                    hold: d.hold,
2458                    rate: self.opts.rate.or(d.rate),
2459                });
2460                continue;
2461            }
2462            renderer.set_destination(connections.profile(&profile, Some("destination")).ok());
2463            let rendered = (|| {
2464                let path = match &d.path {
2465                    Some(p) => Some(
2466                        renderer
2467                            .render(&self.report.file, p)
2468                            .map_err(|e| format!("output path: {e}"))?,
2469                    ),
2470                    None => None,
2471                };
2472                let mut options = JsonMap::new();
2473                for (k, v) in &d.options {
2474                    let v = render_json(&renderer, &self.report.file, v)
2475                        .map_err(|e| format!("destination `{profile}` option `{k}`: {e}"))?;
2476                    options.insert(k.clone(), v);
2477                }
2478                Ok::<_, Fail>(RenderedDest {
2479                    profile: profile.clone(),
2480                    path,
2481                    options,
2482                    attach: d.attach.clone(),
2483                    hold: d.hold,
2484                    rate: self.opts.rate.or(d.rate),
2485                })
2486            })();
2487            renderer.set_destination(None);
2488            self.outs[oi].dests.push(rendered?);
2489        }
2490        Ok(())
2491    }
2492
2493    /// Tab names: `tab_name`, else the query's basename.
2494    fn name_result_sets(&mut self) -> Result<(), Fail> {
2495        let entries: BTreeMap<&str, &QueryEntry> =
2496            self.b.queries.iter().map(|q| (q.query.as_str(), q)).collect();
2497        for p in &mut self.produced {
2498            let q = entries[p.query.as_str()];
2499            p.name = q.tab_name.clone().unwrap_or_else(|| q.query.clone());
2500            p.anchor = q.anchor.clone();
2501            p.header = q.header;
2502            p.columns = q.columns.clone();
2503            p.autofit = q.autofit;
2504            p.style = q.style.clone();
2505        }
2506        for o in self.b.outputs.iter().filter(|o| o.format == "xlsx") {
2507            let mut seen: BTreeMap<String, String> = BTreeMap::new();
2508            for p in self.produced.iter().filter(|p| o.feeds(&p.query)) {
2509                check_sheet_name(&p.name)?;
2510                if let Some(prev) = seen.insert(p.name.to_lowercase(), p.query.clone()) {
2511                    return Err(format!(
2512                        "sheet name `{}` is used twice (by `{prev}` and `{}`); Excel sheet names must be unique",
2513                        p.name, p.query
2514                    ).into());
2515                }
2516            }
2517        }
2518        Ok(())
2519    }
2520
2521    /// What a real run of this Binding would use and produce, for `dre compile` and `validate`.
2522    fn plan(&mut self) -> BindingPlan {
2523        let compiled = self
2524            .b
2525            .queries
2526            .iter()
2527            .map(|q| {
2528                rel(
2529                    &self.project.root,
2530                    &self.compiled_dir.join(format!("{}.sql", q.query)),
2531                )
2532            })
2533            .collect();
2534        let mut outputs = Vec::new();
2535        for oi in 0..self.outs.len() {
2536            let file = self.file_names(oi);
2537            let o = &self.b.outputs[oi];
2538            let destinations = self.outs[oi]
2539                .dests
2540                .iter()
2541                .map(|d| {
2542                    let out = self.project.profiles.target(Role::Destination, &d.profile);
2543                    PlannedDestination {
2544                        profile: d.profile.clone(),
2545                        kind: out.map(|o| o.kind.clone()),
2546                        target: Some(self.project.profiles.target_of(Role::Destination, &d.profile)),
2547                        path: d.path.clone(),
2548                        delivers: out.is_some(),
2549                    }
2550                })
2551                .collect();
2552            outputs.push(PlannedOutput {
2553                name: o.name.clone(),
2554                format: o.format.clone(),
2555                output: rel(&self.project.root, &self.run_dir.join(file)),
2556                destinations,
2557            });
2558        }
2559        let first = outputs.first().cloned();
2560        let pool = self.pool.clone().expect("pool");
2561        let queries = self
2562            .parsed
2563            .queries
2564            .iter()
2565            .map(|q| {
2566                let connection = q.connection.clone().unwrap_or_default();
2567                PlannedQuery {
2568                    query: q.query.clone(),
2569                    kind: pool.kind(&connection).unwrap_or_default(),
2570                    target: self.project.profiles.target_of(Role::Connection, &connection),
2571                    connection,
2572                    sources: q.sources.clone(),
2573                }
2574            })
2575            .collect();
2576        BindingPlan {
2577            report: self.report.name.clone(),
2578            set: self.b.set.clone(),
2579            compiled,
2580            profile: self.parsed.inherited.clone(),
2581            queries,
2582            target: self.target.clone(),
2583            format: first.as_ref().map(|o| o.format.clone()).unwrap_or_default(),
2584            output: first.as_ref().map(|o| o.output.clone()).unwrap_or_default(),
2585            destinations: first.map(|o| o.destinations).unwrap_or_default(),
2586            outputs,
2587            schedules: self.b.schedules.clone(),
2588        }
2589    }
2590
2591    /// Apply `--output-path`/`--output-name` to every destination of the first output that has a
2592    /// path, and return output `oi`'s local file name: `--output-name` (first output only), else
2593    /// the first destination path's file name, else `<name>.<ext>` (the output's `name:`, else
2594    /// the report's).
2595    fn file_names(&mut self, oi: usize) -> String {
2596        let first = oi == 0;
2597        let (output_path, output_name) = if first {
2598            (self.opts.output_path.clone(), self.opts.output_name.clone())
2599        } else {
2600            (None, None)
2601        };
2602        for d in self.outs[oi].dests.iter_mut().filter(|d| d.path.is_some()) {
2603            if let Some(p) = &output_path {
2604                d.path = Some(p.clone());
2605            }
2606            if let (Some(n), Some(r)) = (&output_name, d.path.as_mut()) {
2607                *r = match r.rfind(['/', '\\']) {
2608                    Some(i) => format!("{}{n}", &r[..=i]),
2609                    None => n.clone(),
2610                };
2611            }
2612        }
2613        let o = &self.b.outputs[oi];
2614        let ext = match &o.extension {
2615            Some(e) => e.as_str(),
2616            None => extension(&o.format),
2617        };
2618        let stem = o.name.clone().unwrap_or_else(|| self.report.name.clone());
2619        let from_remote = self.outs[oi]
2620            .dests
2621            .iter()
2622            .find_map(|d| d.path.as_deref())
2623            .and_then(|r| r.rsplit(['/', '\\']).next())
2624            .filter(|s| !s.is_empty())
2625            .map(str::to_string);
2626        output_name.or(from_remote).unwrap_or_else(|| match ext {
2627            "" => stem,
2628            ext => format!("{stem}.{ext}"),
2629        })
2630    }
2631
2632    /// Write output `oi`'s files from the result sets of its queries.
2633    fn format(&mut self, oi: usize, filename: &str) -> Result<(), Fail> {
2634        self.not_cancelled()?;
2635        std::fs::create_dir_all(&self.run_dir).map_err(|e| e.to_string())?;
2636        let b = self.b;
2637        let out = &b.outputs[oi];
2638        let mine: Vec<usize> = (0..self.produced.len())
2639            .filter(|&i| {
2640                out.feeds(&self.produced[i].query) && !self.outs[oi].dropped.contains(&self.produced[i].query)
2641            })
2642            .collect();
2643        if mine.is_empty() {
2644            let what = if self.outs.len() > 1 {
2645                format!("{} gets no tab", out.label(oi))
2646            } else {
2647                "no query makes a tab (every query has `tab: false`)".to_string()
2648            };
2649            self.ui.warn(&format!("  {what}; no output file was written"));
2650            return Ok(());
2651        }
2652        let multi = out.format == "xlsx" || out.template.is_some() || !is_single_table(&out.format);
2653        let groups: Vec<(String, Vec<usize>)> = if multi || mine.len() == 1 {
2654            vec![(filename.to_string(), mine)]
2655        } else {
2656            let (stem, ext) = split_ext(filename);
2657            let mut g = Vec::new();
2658            for i in mine {
2659                let r = &self.produced[i];
2660                if r.name.contains(['/', '\\']) {
2661                    return Err(format!("`{}` can't be used in a file name", r.name).into());
2662                }
2663                g.push((format!("{stem}_{}{ext}", r.name), vec![i]));
2664            }
2665            g
2666        };
2667        // Two outputs writing one file would overwrite each other.
2668        for (name, _) in &groups {
2669            let path = self.run_dir.join(name);
2670            if let Some(prev) = (0..oi).find(|&j| self.outs[j].files.iter().any(|(f, _)| f == &path)) {
2671                return Err(format!(
2672                    "{} and {} both write {name}; give them different `name:`s or destination paths",
2673                    self.b.outputs[prev].label(prev),
2674                    out.label(oi)
2675                )
2676                .into());
2677            }
2678        }
2679        let plugin = find_plugin(self.project, PluginKind::Format, &out.format)
2680            .map_err(|e| Fail::new(Code::FormatFailed, e.to_string()))?;
2681        let mut p = plugin
2682            .start(self.ui.plugin_log(), Some(&self.project.root))
2683            .map_err(|e| e.to_string())?;
2684        let template = match &out.template {
2685            _ if crate::document::FORMATS.contains(&out.format.as_str()) => {
2686                Some(self.document_payload(out.template.as_ref())?)
2687            }
2688            Some(t) => Some(self.template_payload(t)?),
2689            None => None,
2690        };
2691        for (name, idxs) in groups {
2692            let t = Instant::now();
2693            let path = self.run_dir.join(&name);
2694            let metas: Vec<ResultSetMeta> = idxs
2695                .iter()
2696                .map(|&i| {
2697                    let r = &self.produced[i];
2698                    ResultSetMeta {
2699                        name: r.name.clone(),
2700                        query: r.query.clone(),
2701                        result_index: 1,
2702                        anchor: r.anchor.clone(),
2703                        header: r.header,
2704                        columns: r.columns.clone(),
2705                        autofit: r.autofit,
2706                        style: r.style.clone(),
2707                    }
2708                })
2709                .collect();
2710            p.write_begin(
2711                &path.to_string_lossy(),
2712                &out.format,
2713                out.options.clone(),
2714                metas,
2715                template.clone(),
2716            )
2717            .map_err(|e| e.to_string())?;
2718            for &i in &idxs {
2719                let r = &self.produced[i];
2720                let reader = FileReader::try_new(File::open(&r.spool).map_err(|e| e.to_string())?, None)
2721                    .map_err(|e| e.to_string())?;
2722                let schema = r.schema.clone();
2723                // Stream batch by batch so memory stays flat.
2724                let mut any = false;
2725                for b in reader {
2726                    let b = b.map_err(|e| e.to_string())?;
2727                    any = true;
2728                    p.send_batch(&b)
2729                        .map_err(|e| format!("{} format: {e}", out.format))?;
2730                }
2731                if !any {
2732                    p.send_batch(&RecordBatch::new_empty(schema))
2733                        .map_err(|e| format!("{} format: {e}", out.format))?;
2734                }
2735                p.send(&dre_protocol::msg::Request::ResultSetEnd {})
2736                    .map_err(|e| format!("{} format: {e}", out.format))?;
2737            }
2738            let (files, warnings) = p
2739                .write_finish_with_warnings()
2740                .map_err(|e| Fail::from_plugin(&e, format!("{} format: {e}", out.format)))?;
2741            for w in warnings {
2742                self.ui.warn(&format!("  {} format: {w}", out.format));
2743            }
2744            for f in files {
2745                let f = PathBuf::from(f);
2746                let size = std::fs::metadata(&f).map(|m| m.len()).unwrap_or(0);
2747                let shown = rel(&self.project.root, &f);
2748                self.ui.step(
2749                    Level::Debug,
2750                    "Wrote",
2751                    &format!("{} ({})", shown.display(), human_bytes(size)),
2752                    Some(t.elapsed()),
2753                );
2754                self.outs[oi].files.push((f, None));
2755            }
2756        }
2757        let _ = p.close();
2758        Ok(())
2759    }
2760
2761    fn template_payload(&mut self, t: &crate::project::Template) -> Result<Json, Fail> {
2762        let file = crate::project::resolve_template(&self.project.root, &self.report.file, &t.file)?;
2763        let renderer = self.renderer(None)?;
2764        let mut values = JsonMap::new();
2765        for b in &t.bindings {
2766            if let (Some(cell), Some(v)) = (&b.cell, &b.value) {
2767                let rendered = renderer
2768                    .render(&self.report.file, v)
2769                    .map_err(|e| format!("template value: {e}"))?;
2770                values.insert(format!("{}!{}", b.sheet, cell), Json::String(rendered));
2771            }
2772        }
2773        Ok(json!({"file": file.to_string_lossy(), "bindings": t.bindings, "values": values}))
2774    }
2775
2776    /// An html or pdf output's `template`: the template file (if any) and the context it's rendered with
2777    /// ([`crate::document::Context`]).
2778    fn document_payload(&mut self, t: Option<&crate::project::Template>) -> Result<Json, Fail> {
2779        let file = match t {
2780            Some(t) => Some(crate::project::resolve_template(
2781                &self.project.root,
2782                &self.report.file,
2783                &t.file,
2784            )?),
2785            None => None,
2786        };
2787        let run =
2788            crate::parse::run_context(self.project, self.report, self.b, &self.inputs(), self.started_at);
2789        let ctx = crate::document::Context::new(
2790            file.as_deref(),
2791            &self.project.root,
2792            &run,
2793            self.vars.clone(),
2794            self.opts.vars.clone(),
2795        );
2796        Ok(serde_json::to_value(ctx).map_err(|e| e.to_string())?)
2797    }
2798
2799    /// Deliver output `oi` to each of its destinations in order. A failure is recorded and the
2800    /// rest are still attempted; the output fails if any did.
2801    fn deliver(&mut self, oi: usize) -> Result<(), Fail> {
2802        let result = self.deliver_output(oi);
2803        let o = &mut self.outs[oi];
2804        o.status = Some(match &result {
2805            Err(_) => OutputStatus::Failed,
2806            Ok(()) if o.deliveries.iter().any(|d| d.status == "delivered") => OutputStatus::Delivered,
2807            Ok(()) if o.deliveries.iter().any(|d| d.status == "held") => OutputStatus::Held,
2808            Ok(()) => OutputStatus::Kept,
2809        });
2810        o.error = result.as_ref().err().map(|f| f.message.clone());
2811        result
2812    }
2813
2814    fn deliver_output(&mut self, oi: usize) -> Result<(), Fail> {
2815        if self.outs[oi].dests.is_empty() {
2816            self.outs[oi].delivery_note = Some("no destination declared; output stays in target/".into());
2817            let dir = rel(&self.project.root, &self.run_dir);
2818            self.ui.step(
2819                Level::Debug,
2820                "Kept",
2821                &format!("no destination declared: output stays in {}", dir.display()),
2822                None,
2823            );
2824            return Ok(());
2825        }
2826        if self.outs[oi].files.is_empty() {
2827            return Ok(());
2828        }
2829        let dests = std::mem::take(&mut self.outs[oi].dests);
2830        let mut failures = Vec::new();
2831        let mut nowhere = Vec::new();
2832        for d in &dests {
2833            let profiles = &self.project.profiles;
2834            let target = profiles.target_of(Role::Destination, &d.profile);
2835            // `None` for an entry that delivers nowhere.
2836            let kind = profiles
2837                .target(Role::Destination, &d.profile)
2838                .map(|o| o.kind.clone());
2839            self.attempts = 1;
2840            if d.hold || self.opts.hold {
2841                self.hold(oi, d);
2842                self.outs[oi].deliveries.push(Delivery {
2843                    profile: d.profile.clone(),
2844                    kind,
2845                    target,
2846                    status: "held",
2847                    location: None,
2848                    attempts: None,
2849                    error_code: None,
2850                    error: None,
2851                });
2852                continue;
2853            }
2854            let (status, location, error) =
2855                match self.deliver_one(oi, d).map_err(|e| e.or(Code::DeliveryFailed)) {
2856                    Ok(Some(loc)) => ("delivered", Some(loc), None),
2857                    Ok(None) => {
2858                        nowhere.push(nowhere_note(&d.profile, &target));
2859                        ("not_delivered", None, None)
2860                    }
2861                    Err(e) => {
2862                        failures.push(e.clone());
2863                        ("failed", None, Some(e))
2864                    }
2865                };
2866            let record = Delivery {
2867                profile: d.profile.clone(),
2868                kind,
2869                target,
2870                status,
2871                location,
2872                attempts: Some(self.attempts).filter(|n| *n > 1),
2873                error_code: error.as_ref().map(|e| e.code.clone()),
2874                error: error.map(|e| e.message),
2875            };
2876            self.outs[oi].deliveries.push(record);
2877        }
2878        let o = &mut self.outs[oi];
2879        o.dests = dests;
2880        if o.files.iter().all(|(_, d)| d.is_none()) && !nowhere.is_empty() {
2881            o.delivery_note = Some(nowhere.join("; "));
2882        }
2883        match failures.len() {
2884            0 => Ok(()),
2885            1 if o.dests.len() == 1 => Err(failures.remove(0)),
2886            n => Err(Fail::new(
2887                failures[0].code.clone(),
2888                format!(
2889                    "{n} of {} destinations failed: {}",
2890                    o.dests.len(),
2891                    failures
2892                        .iter()
2893                        .map(|f| f.message.as_str())
2894                        .collect::<Vec<_>>()
2895                        .join("; ")
2896                ),
2897            )),
2898        }
2899    }
2900
2901    /// Record a held delivery of output `oi` to `d`, for `dre release`.
2902    fn hold(&mut self, oi: usize, d: &RenderedDest) {
2903        let attach: Vec<crate::release::HeldFile> = d
2904            .attach
2905            .iter()
2906            .filter_map(|name| {
2907                self.b
2908                    .outputs
2909                    .iter()
2910                    .position(|o| o.name.as_deref() == Some(name))
2911            })
2912            .flat_map(|j| {
2913                self.outs[j]
2914                    .files
2915                    .iter()
2916                    .map(|(f, _)| crate::release::held_file(f))
2917            })
2918            .collect();
2919        let idx = self.outs[oi].deliveries.len();
2920        self.held.push(crate::release::HeldEntry {
2921            id: format!(
2922                "{}/{idx}",
2923                self.b.outputs[oi]
2924                    .name
2925                    .clone()
2926                    .unwrap_or_else(|| format!("output{}", oi + 1))
2927            ),
2928            output: self.b.outputs[oi].name.clone(),
2929            key: None,
2930            profile: d.profile.clone(),
2931            path: d.path.clone(),
2932            options: d.options.clone(),
2933            attach,
2934            rate: d.rate,
2935            message: self.outs[oi].message.clone(),
2936            files: self.outs[oi]
2937                .files
2938                .iter()
2939                .map(|(f, _)| crate::release::HeldFile {
2940                    path: f.clone(),
2941                    sha256: crate::release::sha256_file(f).unwrap_or_default(),
2942                })
2943                .collect(),
2944        });
2945        self.ui.step(
2946            Level::Info,
2947            "Held",
2948            &format!(
2949                "{} to `{}`: held for `dre release`",
2950                self.b.outputs[oi].label(oi),
2951                d.profile
2952            ),
2953            None,
2954        );
2955    }
2956
2957    /// Deliver every file to one destination. `Ok(None)`: its entry for this run is
2958    /// `deliver: false`, so nothing was sent.
2959    fn deliver_one(&mut self, oi: usize, d: &RenderedDest) -> Result<Option<String>, Fail> {
2960        // An output is never delivered once the run is cancelled.
2961        self.not_cancelled()?;
2962        let profiles = &self.project.profiles;
2963        let out = match profiles.entry(Role::Destination, &d.profile) {
2964            Entry::Use(o) => o,
2965            Entry::Nowhere => {
2966                let note = nowhere_note(&d.profile, &profiles.target_of(Role::Destination, &d.profile));
2967                self.ui.step(Level::Info, "Kept", &note, None);
2968                return Ok(None);
2969            }
2970            // Both are checked before the run starts.
2971            Entry::Missing => {
2972                return Err(Fail::new(
2973                    Code::MissingTargetEntry,
2974                    profiles.missing_entry(Role::Destination, &d.profile),
2975                ));
2976            }
2977            Entry::Unknown => {
2978                return Err(format!("destination profile `{}` isn't in profiles.yml", d.profile).into());
2979            }
2980        };
2981        let kind = out.kind.clone();
2982        let connection = render_connection(out)?;
2983        let files = &self.outs[oi].files;
2984        let targets: Vec<DeliveryFile> = files
2985            .iter()
2986            .map(|(f, _)| {
2987                let name = f.file_name().unwrap().to_string_lossy().to_string();
2988                let r = d.path.as_deref().map(|r| {
2989                    if files.len() == 1 {
2990                        r.to_string()
2991                    } else {
2992                        match r.rfind(['/', '\\']) {
2993                            Some(i) => format!("{}{name}", &r[..=i]),
2994                            None => name.clone(),
2995                        }
2996                    }
2997                });
2998                DeliveryFile {
2999                    local_path: f.to_string_lossy().to_string(),
3000                    remote_path: r,
3001                }
3002            })
3003            .collect();
3004        // Bindings running at once never deliver to the same file.
3005        for t in &targets {
3006            if let Some(r) = &t.remote_path
3007                && let Some(other) = self.store.claim(&d.profile, r, &self.label())
3008            {
3009                return Err(Fail::new(
3010                    Code::DeliveryFailed,
3011                    format!(
3012                        "`{other}` delivers to `{r}` on destination `{}` in this run too; Bindings running at once (`threads:`) need different paths (put the Set or a var in the path)",
3013                        d.profile
3014                    ),
3015                ));
3016            }
3017        }
3018        let mut locations = Vec::new();
3019        if kind == LOCAL_TYPE {
3020            let errors = crate::options::local_option_errors(&d.options);
3021            if !errors.is_empty() {
3022                return Err(format!("destination `{}`: {}", d.profile, errors.join("; ")).into());
3023            }
3024            // `if_exists`, `atomic` (default true: written as `.<name>.dre-part`, then renamed)
3025            // and `temp_dir`.
3026            let (rules, _) = dre_protocol::delivery::Rules::from_settings(
3027                dre_protocol::delivery::Rules {
3028                    retries: 0,
3029                    ..Default::default()
3030                },
3031                &JsonMap::new(),
3032                &d.options,
3033                &[],
3034            )?;
3035            for (i, f) in targets.iter().enumerate() {
3036                let t = Instant::now();
3037                let r = f
3038                    .remote_path
3039                    .as_ref()
3040                    .ok_or("the local destination needs `output.destination.path`")?;
3041                let dst = self.project.root.join(r).to_string_lossy().to_string();
3042                let delivered = dre_protocol::delivery::deliver(
3043                    &mut dre_protocol::delivery::LocalStore,
3044                    Path::new(&f.local_path),
3045                    &dst,
3046                    &rules,
3047                )
3048                .map_err(|e| {
3049                    let message = format!("delivery to {dst} failed: {e}; the output is still in target/");
3050                    if e.exists {
3051                        // `if_exists: error`, coded as a plugin's would be.
3052                        let code = crate::codes::ErrorCode::Plugin {
3053                            code: "local/file-exists".into(),
3054                            kind: crate::codes::Kind::Delivery,
3055                        };
3056                        Fail::new(code, message)
3057                    } else {
3058                        Fail::new(Code::DeliveryFailed, message)
3059                    }
3060                })?;
3061                let loc = delivered.path;
3062                self.ui.step(Level::Debug, "Delivered", &loc, Some(t.elapsed()));
3063                self.outs[oi].files[i].1.get_or_insert_with(|| loc.clone());
3064                locations.push(loc);
3065            }
3066            return Ok(Some(locations.join(", ")));
3067        }
3068        let failed = |e: dre_protocol::host::HostError| {
3069            Fail::from_plugin(
3070                &e,
3071                format!("delivery through `{kind}` failed: {e}; the output is still in target/"),
3072            )
3073        };
3074        let plugin = find_plugin(self.project, PluginKind::Destination, &kind)
3075            .map_err(|e| Fail::new(Code::DeliveryFailed, e.to_string()))?;
3076        let mut p = plugin
3077            .start(self.ui.plugin_log(), Some(&self.project.root))
3078            .map_err(|e| e.to_string())?;
3079        let is_message = self.b.outputs[oi].is_message();
3080        // A message goes to a destination that takes messages as a message; to any other, as
3081        // its `.md` file.
3082        if is_message
3083            && p.has(CAP_MESSAGE)
3084            && let Some((title, text)) = self.outs[oi].message.clone()
3085        {
3086            let t = Instant::now();
3087            let message = dre_protocol::msg::Message {
3088                title,
3089                text,
3090                html: None,
3091                path: targets[0].local_path.clone(),
3092            };
3093            let attach: Vec<DeliveryFile> = d
3094                .attach
3095                .iter()
3096                .filter_map(|name| {
3097                    self.b
3098                        .outputs
3099                        .iter()
3100                        .position(|o| o.name.as_deref() == Some(name))
3101                })
3102                .flat_map(|j| self.outs[j].files.iter())
3103                .map(|(f, _)| DeliveryFile {
3104                    local_path: f.to_string_lossy().to_string(),
3105                    remote_path: None,
3106                })
3107                .collect();
3108            let loc = p
3109                .deliver_message(&message, &attach, connection, d.options.clone())
3110                .map_err(failed)?;
3111            self.attempts = p.last_attempts();
3112            self.ui.step(Level::Debug, "Delivered", &loc, Some(t.elapsed()));
3113            self.outs[oi].files[0].1.get_or_insert_with(|| loc.clone());
3114            let _ = p.close();
3115            return Ok(Some(loc));
3116        }
3117        if !is_message && p.has(CAP_MESSAGE_ONLY) {
3118            let _ = p.close();
3119            return Err(format!(
3120                "`{kind}` only takes messages, but this output is `{}`; deliver the file elsewhere and link it from a message",
3121                self.b.outputs[oi].format
3122            ).into());
3123        }
3124        let batches: Vec<Vec<usize>> = if targets.len() > 1 && p.has(CAP_MULTI_FILE) {
3125            vec![(0..targets.len()).collect()]
3126        } else {
3127            (0..targets.len()).map(|i| vec![i]).collect()
3128        };
3129        for batch in batches {
3130            let t = Instant::now();
3131            let files: Vec<DeliveryFile> = batch.iter().map(|&i| targets[i].clone()).collect();
3132            let loc = p
3133                .deliver_files(&files, connection.clone(), d.options.clone())
3134                .map_err(failed)?;
3135            self.attempts = self.attempts.max(p.last_attempts());
3136            self.ui.step(Level::Debug, "Delivered", &loc, Some(t.elapsed()));
3137            for i in batch {
3138                self.outs[oi].files[i].1.get_or_insert_with(|| loc.clone());
3139            }
3140            locations.push(loc);
3141        }
3142        let _ = p.close();
3143        Ok(Some(locations.join(", ")))
3144    }
3145
3146    fn snapshot(&self) -> Json {
3147        let sets: Vec<Json> = self
3148            .produced
3149            .iter()
3150            .map(|p| {
3151                json!({
3152                    "name": p.name,
3153                    "columns": p.schema.fields().iter().map(|f| json!({
3154                        "name": f.name(),
3155                        "type": f.data_type().to_string(),
3156                        "type_shape": crate::schema::type_shape(f.data_type()),
3157                    })).collect::<Vec<_>>(),
3158                })
3159            })
3160            .collect();
3161        json!({ "result_sets": sets })
3162    }
3163
3164    fn schema_drift(&mut self) -> Vec<String> {
3165        let path = self.schema_dir.join("last_success.json");
3166        let mut current = self.snapshot();
3167        let Ok(prev) = std::fs::read_to_string(&path) else {
3168            crate::schema::redact(&mut current);
3169            self.protected_snapshot = Some(current);
3170            return Vec::new();
3171        };
3172        let Ok(mut prev) = serde_json::from_str::<Json>(&prev) else {
3173            crate::schema::redact(&mut current);
3174            self.protected_snapshot = Some(current);
3175            return Vec::new();
3176        };
3177        let original = prev.clone();
3178        crate::schema::redact(&mut prev);
3179        crate::schema::redact(&mut current);
3180        if crate::secrets::enabled() {
3181            crate::schema::align_protection(&mut prev, &mut current);
3182        }
3183        if prev != original {
3184            let protected =
3185                serde_json::to_string_pretty(&prev).expect("serializing a serde_json::Value cannot fail");
3186            if let Err(error) = crate::fs::write_atomic(&path, protected.as_bytes()) {
3187                self.ui.warn(&format!(
3188                    "can't protect the existing schema snapshot at {}: {error}; continuing without rewriting it",
3189                    path.display()
3190                ));
3191            }
3192        }
3193        let drift = crate::schema::drift(&prev, &current);
3194        self.protected_snapshot = Some(current);
3195        drift
3196    }
3197
3198    fn write_snapshot(&self) -> std::io::Result<()> {
3199        std::fs::create_dir_all(&self.schema_dir)?;
3200        let snapshot = self.protected_snapshot.clone().unwrap_or_else(|| {
3201            let mut snapshot = self.snapshot();
3202            crate::schema::redact(&mut snapshot);
3203            snapshot
3204        });
3205        crate::fs::write_atomic(
3206            &self.schema_dir.join("last_success.json"),
3207            serde_json::to_string_pretty(&snapshot)?.as_bytes(),
3208        )
3209    }
3210
3211    fn write_results(&self, status: &Status, error: Option<&Fail>) -> std::io::Result<()> {
3212        std::fs::create_dir_all(&self.run_dir)?;
3213        if !self.held.is_empty() {
3214            crate::release::write_held(&self.run_dir, &self.run_id, &self.held)?;
3215        }
3216        // `outputs` lists every file (as before 0.3); `output_results` has one entry per output.
3217        let mut outputs: Vec<run_results::OutputFile> = Vec::new();
3218        let mut output_results: Vec<run_results::OutputResult> = Vec::new();
3219        for (o, run) in self.b.outputs.iter().zip(&self.outs) {
3220            let files: Vec<run_results::OutputFileRef> = run
3221                .files
3222                .iter()
3223                .map(|(f, delivered)| run_results::OutputFileRef {
3224                    path: record_path(self.project, f),
3225                    size: std::fs::metadata(f).map(|m| m.len()).unwrap_or(0),
3226                    delivered_to: delivered.clone(),
3227                })
3228                .collect();
3229            outputs.extend(files.iter().map(|f| run_results::OutputFile {
3230                path: f.path.clone(),
3231                size: f.size,
3232                delivered_to: f.delivered_to.clone(),
3233                output: o.name.clone(),
3234            }));
3235            let queries: Vec<String> = self
3236                .b
3237                .queries
3238                .iter()
3239                .filter(|q| q.tab && o.feeds(&q.query))
3240                .map(|q| q.query.clone())
3241                .collect();
3242            let status = match (run.status, status) {
3243                (Some(s), _) => s,
3244                (None, Status::Error) => OutputStatus::Failed,
3245                (None, _) => OutputStatus::Kept,
3246            }
3247            .as_str();
3248            output_results.push(run_results::OutputResult {
3249                name: o.name.clone(),
3250                format: o.format.clone(),
3251                queries,
3252                status,
3253                error: run.error.clone(),
3254                files,
3255                delivery: run.delivery_note.clone(),
3256                deliveries: run.deliveries.clone(),
3257                when: run.when,
3258                reason: run.reason.clone(),
3259                no_data: run.no_data.clone(),
3260                dropped: run.dropped.clone(),
3261                for_each: run.for_each.clone(),
3262                message: run.message.clone().map(|(title, text)| run_results::Message {
3263                    title: crate::secrets::mask_message_title(&title).into_owned(),
3264                    text: crate::secrets::mask_markdown(&text).into_owned(),
3265                }),
3266            });
3267        }
3268        // In delivery order: file outputs, then messages.
3269        let order = (0..self.outs.len())
3270            .filter(|&i| !self.b.outputs[i].is_message())
3271            .chain((0..self.outs.len()).filter(|&i| self.b.outputs[i].is_message()));
3272        let deliveries: Vec<Delivery> = order
3273            .flat_map(|i| self.outs[i].deliveries.iter().cloned())
3274            .collect();
3275        let notes: Vec<&str> = self
3276            .outs
3277            .iter()
3278            .filter_map(|o| o.delivery_note.as_deref())
3279            .collect();
3280        let delivery = (!notes.is_empty()).then(|| notes.join("; "));
3281        let results = RunResults {
3282            schema_version: run_results::SCHEMA_VERSION,
3283            run_id: self.run_id.clone(),
3284            report: self.report.name.clone(),
3285            set: self.b.set.clone(),
3286            binding: self.b.dir_name().to_string(),
3287            managed: self.report.managed,
3288            profile: self.parsed.inherited.clone(),
3289            connections: self
3290                .parsed
3291                .connections()
3292                .into_iter()
3293                .map(str::to_string)
3294                .collect(),
3295            target: self.target.clone(),
3296            schedule: self.opts.schedule.clone(),
3297            schedule_vars: self.schedule_vars.clone(),
3298            vars: self.rendered_vars.clone(),
3299            run_date: self.date.to_string(),
3300            scheduled_at: self.opts.scheduled_at.map(rfc3339),
3301            timezone: self.calendar.tz.name().to_string(),
3302            params: self.opts.params(self.date),
3303            status: *status,
3304            error: error.map(|e| e.message.clone()),
3305            error_code: error.map(|e| e.code.clone()),
3306            error_kind: error.map(|e| e.code.kind()),
3307            preview: self.opts.preview.is_some(),
3308            row_limit: self.opts.preview,
3309            started_at: self.started_at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
3310            duration_ms: self.started.elapsed().as_millis() as u64,
3311            result_sets: self
3312                .produced
3313                .iter()
3314                .map(|p| run_results::ResultSet {
3315                    name: p.name.clone(),
3316                    query: p.query.clone(),
3317                    connection: p.connection.clone(),
3318                    rows: p.rows,
3319                    columns: p.schema.fields().iter().map(|f| f.name().clone()).collect(),
3320                })
3321                .collect(),
3322            outputs,
3323            output_results,
3324            delivery,
3325            deliveries,
3326            schema_drift: self.drift.clone(),
3327            target_path: self.project.target_dir.clone(),
3328            settings: self.project.settings.clone(),
3329            manifest_checksum: self.opts.manifest_checksum.clone(),
3330            retry_of: self.retry.as_ref().map(|r| r.original.clone()),
3331            release_of: None,
3332            released_by: None,
3333            released: Vec::new(),
3334        };
3335        self.store.write_results(&self.run_dir, &results)
3336    }
3337
3338    /// `dre validate --live`: execute temp creates, `check` everything else, each on its
3339    /// connection; then check declared source columns against the database.
3340    fn live_check(&mut self, statements: &[Statement]) -> Result<(), Fail> {
3341        let pool = self.pool.clone().expect("pool");
3342        let mut failures = Vec::new();
3343        let mut unexecuted_setup: BTreeMap<String, (PathBuf, usize)> = BTreeMap::new();
3344        let mut uncheckable: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3345        let sql_log = self.ui.sql_log();
3346        for st in statements {
3347            if uncheckable.contains(&st.connection) {
3348                continue;
3349            }
3350            let session = pool.session(&st.connection)?;
3351            let mut s = session.lock().unwrap();
3352            if !s.get()?.has(dre_protocol::CAP_CHECK) {
3353                let kind = pool.kind(&st.connection).unwrap_or_default();
3354                self.ui.warn(&format!(
3355                    "  not checkable: the `{kind}` source plugin (connection `{}`) can't check statements without running them",
3356                    st.connection
3357                ));
3358                uncheckable.insert(st.connection.clone());
3359                continue;
3360            }
3361            let verb = if st.kind == StatementKind::TempCreate {
3362                ""
3363            } else {
3364                " (check)"
3365            };
3366            sql_log(
3367                &format!("{}:{}{verb}", st.file.display(), st.line),
3368                &protected_sql(&st.text, st.sensitive),
3369            );
3370            if st.kind == StatementKind::TempCreate {
3371                let result = {
3372                    let (process, _log_scope) = s.statement(st.sensitive)?;
3373                    process.execute(&st.text, None, |_, _| Ok(()))
3374                };
3375                if let Err(e) = result {
3376                    failures.push(format!(
3377                        "{}:{}: {}",
3378                        st.file.display(),
3379                        st.line,
3380                        masked_source_error(&e, st.sensitive)
3381                    ));
3382                }
3383                continue;
3384            }
3385            let result = {
3386                let (process, _log_scope) = s.statement(st.sensitive)?;
3387                process.check(&st.text)
3388            };
3389            if let Err(e) = result {
3390                let mut msg = format!(
3391                    "{}:{}: {}",
3392                    st.file.display(),
3393                    st.line,
3394                    masked_source_error(&e, st.sensitive)
3395                );
3396                if let Some((f, l)) = unexecuted_setup.get(&st.connection) {
3397                    msg.push_str(&format!(
3398                        " (may be a false positive: the setup statement at {}:{l} wasn't executed during the check)",
3399                        f.display()
3400                    ));
3401                }
3402                failures.push(msg);
3403            }
3404            if st.kind == StatementKind::Other {
3405                unexecuted_setup.insert(st.connection.clone(), (st.file.clone(), st.line));
3406            }
3407        }
3408        self.check_source_columns(&pool, &mut failures);
3409        if failures.is_empty() {
3410            Ok(())
3411        } else {
3412            Err(Fail::new(Code::QueryFailed, failures.join("\n    ")))
3413        }
3414    }
3415
3416    /// Declared source `columns` against the real table, once per source table, connection and
3417    /// target in this process: each must exist, and match its `data_type` (loosely) where given.
3418    fn check_source_columns(&mut self, pool: &Arc<Pool>, failures: &mut Vec<String>) {
3419        /// `(project, source.table, connection, target)`.
3420        type Checked = std::collections::BTreeSet<(PathBuf, String, String, String)>;
3421        static CHECKED: std::sync::LazyLock<Mutex<Checked>> = std::sync::LazyLock::new(Mutex::default);
3422        for q in self.parsed.queries.clone() {
3423            let Some(conn) = q.connection.clone() else {
3424                continue;
3425            };
3426            for key in &q.sources {
3427                let Some(r) = self.parsed.sources.get(key).cloned() else {
3428                    continue;
3429                };
3430                let Some(declared) = self
3431                    .project
3432                    .sources
3433                    .get(&r.source)
3434                    .and_then(|s| s.table(&r.table))
3435                    .map(|t| t.columns.clone())
3436                    .filter(|c| !c.is_empty())
3437                else {
3438                    continue;
3439                };
3440                let id = (
3441                    self.project.root.clone(),
3442                    key.clone(),
3443                    conn.clone(),
3444                    self.project.profiles.target_of(Role::Connection, &conn),
3445                );
3446                if !CHECKED.lock().unwrap().insert(id) {
3447                    continue;
3448                }
3449                let quote = if r.quoting.any() {
3450                    let kind = pool.kind(&conn).unwrap_or_default();
3451                    match pool.connections.identifier_quote(&kind) {
3452                        Ok(q) => q,
3453                        Err(e) => {
3454                            failures.push(format!("source `{key}`: can't quote its name: {e}"));
3455                            continue;
3456                        }
3457                    }
3458                } else {
3459                    None
3460                };
3461                let rel = r.relation(quote.as_deref());
3462                let runner = PoolRunner {
3463                    pool: pool.clone(),
3464                    default: Some(conn.clone()),
3465                    log: self.ui.sql_log(),
3466                };
3467                let actual = match runner.columns(&format!("select * from {rel} where 1=0"), None) {
3468                    Ok(c) => c,
3469                    Err(e) => {
3470                        failures.push(format!("source `{key}` ({rel} on connection `{conn}`): {e}"));
3471                        continue;
3472                    }
3473                };
3474                for c in &declared {
3475                    let Some(a) = actual.iter().find(|a| a.name.eq_ignore_ascii_case(&c.name)) else {
3476                        failures.push(format!(
3477                            "source `{key}`: declared column `{}` isn't in {rel} on connection `{conn}` (it has: {})",
3478                            c.name,
3479                            actual.iter().map(|a| a.name.as_str()).collect::<Vec<_>>().join(", ")
3480                        ));
3481                        continue;
3482                    };
3483                    let Some(dt) = &c.data_type else { continue };
3484                    match crate::coltypes::matches(dt, &a.data_type) {
3485                        Some(true) => {}
3486                        Some(false) => failures.push(format!(
3487                            "source `{key}`: column `{}` is declared `{dt}`, but {rel} on connection `{conn}` returns {}",
3488                            c.name, a.data_type
3489                        )),
3490                        None => self.ui.warn(&format!(
3491                            "  source `{key}`: column `{}`'s `data_type: {dt}` isn't a type DRE can compare, so only its presence was checked",
3492                            c.name
3493                        )),
3494                    }
3495                }
3496            }
3497        }
3498    }
3499}
3500
3501/// The source session, started on first use (rendering may need it for `run_query()`).
3502struct Session {
3503    path: Result<crate::plugins::Located, String>,
3504    connection: Result<JsonMap<String, Json>, String>,
3505    cwd: PathBuf,
3506    log: LogSink,
3507    /// Whether plugin diagnostics for the active statement must be hidden rather than exactly
3508    /// masked. Connection diagnostics always use exact-value masking.
3509    opaque_log: Arc<AtomicBool>,
3510    /// Unmanaged reports run read-only unless they create temp objects.
3511    unmanaged: bool,
3512    read_only: bool,
3513    /// A lookup was loaded into a temp table, so the session must stay open as it is.
3514    loaded: bool,
3515    proc_: Option<PluginProcess>,
3516}
3517
3518impl Session {
3519    fn new(
3520        path: Result<crate::plugins::Located, String>,
3521        connection: Result<JsonMap<String, Json>, String>,
3522        cwd: PathBuf,
3523        log: LogSink,
3524        unmanaged: bool,
3525    ) -> Session {
3526        let opaque_log = Arc::new(AtomicBool::new(false));
3527        let protect = Arc::clone(&opaque_log);
3528        let downstream = log;
3529        let log: LogSink = Arc::new(move |plugin, line| {
3530            let line = crate::secrets::mask_source_error(line, protect.load(Ordering::SeqCst));
3531            downstream(plugin, &line);
3532        });
3533        Session {
3534            path,
3535            connection,
3536            cwd,
3537            log,
3538            opaque_log,
3539            unmanaged,
3540            read_only: unmanaged,
3541            loaded: false,
3542            proc_: None,
3543        }
3544    }
3545
3546    fn want_read_only(&mut self, ro: bool) {
3547        if self.read_only != ro {
3548            // Reopen if a session was already started in the other mode.
3549            self.close();
3550            self.read_only = ro;
3551        }
3552    }
3553
3554    fn get(&mut self) -> Result<&mut PluginProcess, String> {
3555        if self.proc_.is_none() {
3556            let plugin = self.path.clone()?;
3557            let connection = self.connection.clone()?;
3558            let mut p = plugin
3559                .start(self.log.clone(), Some(&self.cwd))
3560                .map_err(|e| masked_source_error(&e, false))?;
3561            let ro = self.unmanaged && self.read_only && p.has(CAP_READ_ONLY);
3562            p.open(connection, ro).map_err(|e| {
3563                let protected = masked_source_error(&e, false);
3564                format!("can't open the source connection: {protected}")
3565            })?;
3566            self.proc_ = Some(p);
3567        }
3568        Ok(self.proc_.as_mut().unwrap())
3569    }
3570
3571    fn statement(&mut self, sensitive: bool) -> Result<(&mut PluginProcess, OpaqueLogScope), String> {
3572        let opaque_log = Arc::clone(&self.opaque_log);
3573        let process = self.get()?;
3574        let scope = OpaqueLogScope::new(opaque_log, sensitive);
3575        Ok((process, scope))
3576    }
3577
3578    fn close(&mut self) {
3579        if let Some(p) = self.proc_.take() {
3580            let _ = p.close();
3581        }
3582    }
3583}
3584
3585struct OpaqueLogScope(Arc<AtomicBool>);
3586
3587impl OpaqueLogScope {
3588    fn new(active: Arc<AtomicBool>, sensitive: bool) -> Self {
3589        active.store(sensitive, Ordering::SeqCst);
3590        Self(active)
3591    }
3592}
3593
3594impl Drop for OpaqueLogScope {
3595    fn drop(&mut self) {
3596        self.0.store(false, Ordering::SeqCst);
3597    }
3598}
3599
3600/// What a run logs, and records, for a destination whose entry is `deliver: false`.
3601fn nowhere_note(profile: &str, target: &str) -> String {
3602    format!(
3603        "destination `{profile}`: `{target}` delivers nowhere (`deliver: false`); output stays in target/"
3604    )
3605}
3606
3607/// The Binding's connections: each one's session, opened when a query first needs it and held
3608/// until the report ends.
3609struct Pool {
3610    connections: Arc<ProfileConnections>,
3611    cwd: PathBuf,
3612    log: LogSink,
3613    unmanaged: bool,
3614    sessions: Mutex<BTreeMap<String, Arc<Mutex<Session>>>>,
3615}
3616
3617impl Pool {
3618    fn new(connections: Arc<ProfileConnections>, cwd: PathBuf, log: LogSink, unmanaged: bool) -> Pool {
3619        Pool {
3620            connections,
3621            cwd,
3622            log,
3623            unmanaged,
3624            sessions: Mutex::default(),
3625        }
3626    }
3627
3628    /// A connection's settings for this run.
3629    fn target(&self, name: &str) -> Result<ProfileTarget, String> {
3630        let profiles = &self.connections.profiles;
3631        match profiles.entry(Role::Connection, name) {
3632            Entry::Use(o) => Ok(o.clone()),
3633            Entry::Unknown => Err(format!(
3634                "connection `{name}` isn't under `{}:` in {}",
3635                profiles.section_key(Role::Connection),
3636                profiles.path.display()
3637            )),
3638            // A connection never has `deliver: false`.
3639            Entry::Missing | Entry::Nowhere => Err(profiles.missing_entry(Role::Connection, name)),
3640        }
3641    }
3642
3643    /// A connection's plugin type.
3644    fn kind(&self, name: &str) -> Result<String, String> {
3645        Ok(self.target(name)?.kind)
3646    }
3647
3648    fn session(&self, name: &str) -> Result<Arc<Mutex<Session>>, String> {
3649        if let Some(s) = self.sessions.lock().unwrap().get(name) {
3650            return Ok(s.clone());
3651        }
3652        let output = self.target(name)?;
3653        let id = crate::project::PluginId::new(PluginKind::Source, output.kind.clone());
3654        let path = crate::plugins::locate_in(&self.connections.root, &self.connections.plugins, &id)
3655            .map_err(String::from);
3656        let session = Arc::new(Mutex::new(Session::new(
3657            path,
3658            render_connection(&output),
3659            self.cwd.clone(),
3660            self.log.clone(),
3661            self.unmanaged,
3662        )));
3663        self.sessions
3664            .lock()
3665            .unwrap()
3666            .insert(name.to_string(), session.clone());
3667        Ok(session)
3668    }
3669
3670    fn close_all(&self) {
3671        for s in self.sessions.lock().unwrap().values() {
3672            if let Ok(mut s) = s.lock() {
3673                s.close();
3674            }
3675        }
3676    }
3677}
3678
3679/// `run_query()`, `columns()` and lookups for one renderer: on the named connection's session,
3680/// else on the renderer's own (`default`).
3681struct PoolRunner {
3682    pool: Arc<Pool>,
3683    default: Option<String>,
3684    log: LogSink,
3685}
3686
3687impl PoolRunner {
3688    fn session(&self, profile: Option<&str>) -> Result<Arc<Mutex<Session>>, String> {
3689        let name = profile.or(self.default.as_deref()).ok_or(
3690            "no connection here: pass `profile=`, or give the report a `profile:` (this isn't a query's SQL)",
3691        )?;
3692        self.pool.session(name)
3693    }
3694}
3695
3696impl QueryRunner for PoolRunner {
3697    fn run_query(&self, sql: &str, max_rows: u64, profile: Option<&str>) -> Result<QueryRows, String> {
3698        let session = self.session(profile)?;
3699        let mut s = session.lock().map_err(|_| "session lock poisoned".to_string())?;
3700        let sensitive = crate::secrets::contains_secret(sql);
3701        // An unmanaged report may only read, and run_query() runs before the file's own
3702        // statements are checked, so it gets the same rule up front.
3703        if s.unmanaged
3704            && let Some(bad) = sqlsplit::split(sql)
3705                .into_iter()
3706                .find(|st| sqlsplit::classify(&st.text) != StatementKind::Read)
3707        {
3708            return Err(format!(
3709                "run_query() in an unmanaged report may only read, but got `{}`; give the report a YAML to declare it",
3710                masked_sql_summary(&bad.text, 60, sensitive)
3711            ));
3712        }
3713        (self.log)("run_query()", &protected_sql(sql, sensitive));
3714        let (p, _log_scope) = s.statement(sensitive)?;
3715        let mut out = QueryRows::default();
3716        let mut too_many = false;
3717        let exec = p
3718            .execute(sql, Some(max_rows + 1), |schema, batch| {
3719                if out.columns.is_empty() {
3720                    out.columns = schema.fields().iter().map(|f| f.name().clone()).collect();
3721                }
3722                if out.rows.len() as u64 + batch.num_rows() as u64 > max_rows {
3723                    too_many = true;
3724                    return Ok(());
3725                }
3726                out.rows.extend(crate::values::batch_rows(&batch));
3727                Ok(())
3728            })
3729            .map_err(|e| format!("run_query() failed: {}", masked_source_error(&e, sensitive)))?;
3730        if too_many {
3731            return Err(format!(
3732                "run_query() returned more than {max_rows} rows; it's meant for small lookups — raise the cap with `run_query(sql, max_rows=N)` or `run_query_max_rows` in dre_project.yml"
3733            ));
3734        }
3735        if let Execution::Result { schema, .. } = exec
3736            && out.columns.is_empty()
3737        {
3738            out.columns = schema.fields().iter().map(|f| f.name().clone()).collect();
3739        }
3740        Ok(out)
3741    }
3742
3743    fn columns(&self, sql: &str, profile: Option<&str>) -> Result<Vec<Column>, String> {
3744        let session = self.session(profile)?;
3745        let mut s = session.lock().map_err(|_| "session lock poisoned".to_string())?;
3746        let sensitive = crate::secrets::contains_secret(sql);
3747        (self.log)("columns()", &protected_sql(sql, sensitive));
3748        let (p, _log_scope) = s.statement(sensitive)?;
3749        let mut schema: Option<SchemaRef> = None;
3750        let exec = p
3751            .execute(sql, Some(1), |sch, _| {
3752                schema.get_or_insert_with(|| sch.clone());
3753                Ok(())
3754            })
3755            .map_err(|e| masked_source_error(&e, sensitive))?;
3756        if let Execution::Result { schema: sch, .. } = exec {
3757            schema.get_or_insert(sch);
3758        }
3759        let schema = schema.ok_or("the query returned no result set")?;
3760        Ok(schema
3761            .fields()
3762            .iter()
3763            .map(|f| Column {
3764                name: f.name().clone(),
3765                data_type: f.data_type().to_string(),
3766            })
3767            .collect())
3768    }
3769
3770    fn load(&self, name: &str, table: &Table) -> Result<Option<(String, Option<String>)>, String> {
3771        let session = self.session(None)?;
3772        let mut s = session.lock().map_err(|_| "session lock poisoned".to_string())?;
3773        // A temp table is allowed even in an unmanaged report; it needs a writable session.
3774        s.want_read_only(false);
3775        if !s.get()?.has(CAP_LOAD) {
3776            return Ok(None);
3777        }
3778        let batch = table.to_batch()?;
3779        let schema = batch.schema();
3780        (self.log)(
3781            &format!("lookup `{name}`"),
3782            &format!(
3783                "-- {} rows loaded through the plugin's `load` request",
3784                batch.num_rows()
3785            ),
3786        );
3787        let r = s.get()?.load(name, &schema, [batch]).map_err(|e| e.to_string())?;
3788        s.loaded = true;
3789        Ok(Some(r))
3790    }
3791}
3792
3793/// Render every string inside a destination option value.
3794fn render_json(renderer: &Renderer, file: &Path, v: &Json) -> Result<Json, RenderError> {
3795    Ok(match v {
3796        Json::String(s) => Json::String(renderer.render(file, s)?),
3797        Json::Array(a) => Json::Array(
3798            a.iter()
3799                .map(|v| render_json(renderer, file, v))
3800                .collect::<Result<_, _>>()?,
3801        ),
3802        Json::Object(o) => Json::Object(
3803            o.iter()
3804                .map(|(k, v)| Ok((k.clone(), render_json(renderer, file, v)?)))
3805                .collect::<Result<_, RenderError>>()?,
3806        ),
3807        other => other.clone(),
3808    })
3809}
3810
3811/// Render `env_var()` (and only that) inside a profile output's string fields.
3812pub(crate) fn render_connection(output: &ProfileTarget) -> Result<JsonMap<String, Json>, String> {
3813    let mut env = minijinja::Environment::new();
3814    env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
3815    env.add_function("env_var", |name: String, default: Option<String>| -> Result<String, minijinja::Error> {
3816        std::env::var(&name).ok().or(default).ok_or_else(|| {
3817            minijinja::Error::new(
3818                minijinja::ErrorKind::UndefinedError,
3819                format!("`env_var('{name}')`: environment variable `{name}` is not set and no default is given"),
3820            )
3821        })
3822    });
3823    fn walk(env: &minijinja::Environment<'_>, v: &Json) -> Result<Json, String> {
3824        Ok(match v {
3825            Json::String(s) if crate::preflight::is_templated(s) => Json::String(
3826                env.render_str(s, ())
3827                    .map_err(|e| format!("profiles.yml: {}", e.detail().unwrap_or("render error")))?,
3828            ),
3829            Json::Array(a) => Json::Array(a.iter().map(|x| walk(env, x)).collect::<Result<_, _>>()?),
3830            Json::Object(o) => Json::Object(
3831                o.iter()
3832                    .map(|(k, x)| Ok((k.clone(), walk(env, x)?)))
3833                    .collect::<Result<_, String>>()?,
3834            ),
3835            other => other.clone(),
3836        })
3837    }
3838    match walk(&env, &Json::Object(output.fields.clone()))? {
3839        Json::Object(o) => Ok(o),
3840        _ => unreachable!(),
3841    }
3842}
3843
3844/// Locate the plugin for a declared type among the project's packages, honouring `dre.lock`
3845/// pins and declared constraints.
3846pub fn find_plugin(
3847    project: &Project,
3848    kind: PluginKind,
3849    name: &str,
3850) -> Result<crate::plugins::Located, crate::plugins::LocateError> {
3851    crate::plugins::locate(project, &crate::project::PluginId::new(kind, name))
3852}
3853
3854/// `connection`, `destination` and `profile()` from `profiles.yml`, for the run's target. A
3855/// field is secret when the plugin's `describe` says so; when the plugin can't be asked, when its
3856/// name looks like one; and always when its value comes from a `DRE_SECRET_*` variable.
3857struct ProfileConnections {
3858    /// Each profile's entry for the run comes from here.
3859    profiles: Profiles,
3860    root: PathBuf,
3861    plugins: Vec<crate::project::PluginRequirement>,
3862    log: LogSink,
3863}
3864
3865/// What `describe` said, by `(project, role, type)`, `None` when it couldn't be asked: each
3866/// plugin is asked once per process, not once per Binding.
3867static DESCRIBED: std::sync::LazyLock<Mutex<BTreeMap<DescribeKey, Option<Described>>>> =
3868    std::sync::LazyLock::new(Mutex::default);
3869
3870/// `(project root, role, plugin type)`.
3871type DescribeKey = (PathBuf, Role, String);
3872
3873#[derive(Clone)]
3874struct Described {
3875    secrets: Vec<String>,
3876    identifier_quote: Option<String>,
3877    string_literal: Option<String>,
3878    capabilities: Vec<String>,
3879    /// A message destination's length limit.
3880    message_limit: Option<u64>,
3881}
3882
3883const SECRET_NAME_WORDS: &[&str] = &["password", "secret", "token", "key", "credential"];
3884
3885impl ProfileConnections {
3886    fn view(&self, role: Role, name: &str) -> Result<Connection, String> {
3887        let target = self.profiles.target_of(role, name);
3888        let out: ProfileTarget = match self.profiles.entry(role, name) {
3889            Entry::Use(o) => o.clone(),
3890            Entry::Nowhere => {
3891                return Err(format!(
3892                    "destination `{name}`: `{target}` delivers nowhere (`deliver: false`), so it has no settings"
3893                ));
3894            }
3895            Entry::Missing => return Err(self.profiles.missing_entry(role, name)),
3896            Entry::Unknown => {
3897                return Err(format!(
3898                    "no {} profile `{name}` in {}",
3899                    role.as_str(),
3900                    self.profiles.path.display()
3901                ));
3902            }
3903        };
3904        let mut secrets: Vec<String> = out
3905            .fields
3906            .iter()
3907            .filter(|(_, v)| v.as_str().is_some_and(|s| s.contains(crate::secrets::PREFIX)))
3908            .map(|(k, _)| k.clone())
3909            .collect();
3910        match self.described(role, &out.kind) {
3911            Some(d) => secrets.extend(d.secrets),
3912            None => secrets.extend(
3913                out.fields
3914                    .keys()
3915                    .filter(|k| {
3916                        let k = k.to_lowercase();
3917                        SECRET_NAME_WORDS.iter().any(|w| k.contains(w))
3918                    })
3919                    .cloned(),
3920            ),
3921        }
3922        crate::secrets::register_secret_fields(&out.fields, &secrets);
3923        let fields = render_connection(&out)?;
3924        Ok(Connection {
3925            profile: name.to_string(),
3926            target,
3927            kind: out.kind.clone(),
3928            fields,
3929            secrets,
3930        })
3931    }
3932
3933    /// The plugin's `describe` reply, asked once per plugin type.
3934    fn described(&self, role: Role, kind: &str) -> Option<Described> {
3935        let key = (self.root.clone(), role, kind.to_string());
3936        if let Some(v) = DESCRIBED.lock().unwrap().get(&key) {
3937            return v.clone();
3938        }
3939        let plugin_kind = match role {
3940            Role::Connection => PluginKind::Source,
3941            Role::Destination => PluginKind::Destination,
3942        };
3943        let asked = (|| {
3944            if kind == LOCAL_TYPE {
3945                return Some(Described {
3946                    secrets: Vec::new(),
3947                    identifier_quote: None,
3948                    string_literal: None,
3949                    capabilities: Vec::new(),
3950                    message_limit: None,
3951                });
3952            }
3953            let id = crate::project::PluginId::new(plugin_kind, kind);
3954            let plugin = crate::plugins::locate_in(&self.root, &self.plugins, &id).ok()?;
3955            let mut p = plugin.start(self.log.clone(), Some(&self.root)).ok()?;
3956            let capabilities = p.info().capabilities.clone();
3957            let d = p.description().ok();
3958            let _ = p.close();
3959            let d = d?;
3960            Some(Described {
3961                secrets: d
3962                    .connection_fields
3963                    .into_iter()
3964                    .filter(|f| f.secret)
3965                    .map(|f| f.name)
3966                    .collect(),
3967                identifier_quote: d.identifier_quote,
3968                string_literal: d.string_literal,
3969                capabilities,
3970                message_limit: d.message_limit,
3971            })
3972        })();
3973        DESCRIBED.lock().unwrap().insert(key, asked.clone());
3974        asked
3975    }
3976}
3977
3978impl Connections for ProfileConnections {
3979    fn profile(&self, name: &str, role: Option<&str>) -> Result<Connection, String> {
3980        let role = match role {
3981            Some("connection") => Role::Connection,
3982            Some(_) => Role::Destination,
3983            None => {
3984                let c = self.profiles.get(Role::Connection, name).is_some();
3985                let d = self.profiles.get(Role::Destination, name).is_some();
3986                match (c, d) {
3987                    (true, true) => {
3988                        return Err(format!(
3989                            "`{name}` is both a connection and a destination profile; say which with `profile('{name}', role='connection')` or `role='destination'`"
3990                        ));
3991                    }
3992                    (true, false) => Role::Connection,
3993                    _ => Role::Destination,
3994                }
3995            }
3996        };
3997        self.view(role, name)
3998    }
3999
4000    fn identifier_quote(&self, kind: &str) -> Result<Option<String>, String> {
4001        match self.described(Role::Connection, kind) {
4002            Some(d) => Ok(d.identifier_quote),
4003            None => Err(format!(
4004                "the `{kind}` source plugin couldn't be asked for its identifier quote character"
4005            )),
4006        }
4007    }
4008
4009    fn string_literal(&self, kind: &str) -> Result<Option<String>, String> {
4010        Ok(self
4011            .described(Role::Connection, kind)
4012            .and_then(|d| d.string_literal))
4013    }
4014}
4015
4016fn check_sheet_name(n: &str) -> Result<(), String> {
4017    if n.is_empty() || n.chars().count() > 31 {
4018        return Err(format!("sheet name `{n}` must be 1 to 31 characters long"));
4019    }
4020    if let Some(c) = n.chars().find(|c| "[]:*?/\\".contains(*c)) {
4021        return Err(format!(
4022            "sheet name `{n}` contains `{c}`, which Excel doesn't allow in sheet names"
4023        ));
4024    }
4025    if n.starts_with('\'') || n.ends_with('\'') {
4026        return Err(format!("sheet name `{n}` can't start or end with an apostrophe"));
4027    }
4028    if n.eq_ignore_ascii_case("history") {
4029        return Err("`History` is reserved by Excel and can't be a sheet name".into());
4030    }
4031    Ok(())
4032}
4033
4034fn is_single_table(format: &str) -> bool {
4035    matches!(format, "csv" | "delimited" | "fixed_width" | "parquet")
4036}
4037
4038fn extension(format: &str) -> &str {
4039    match format {
4040        "delimited" | "fixed_width" => "txt",
4041        crate::project::MESSAGE_FORMAT => "md",
4042        f => f,
4043    }
4044}
4045
4046fn split_ext(name: &str) -> (&str, &str) {
4047    match name.rfind('.') {
4048        Some(i) if i > 0 => (&name[..i], &name[i..]),
4049        _ => (name, ""),
4050    }
4051}
4052
4053fn rel(root: &Path, p: &Path) -> PathBuf {
4054    crate::slash(p.strip_prefix(root).unwrap_or(p))
4055}
4056
4057/// An instant as DRE records it: RFC 3339 in UTC, to the second.
4058pub fn rfc3339(t: chrono::DateTime<chrono::Utc>) -> String {
4059    t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
4060}
4061
4062/// How a generated file is recorded in `run_results.json`: relative to the project root when
4063/// it's inside it, else relative to the target path, so moving the target folder doesn't
4064/// change what's recorded.
4065fn record_path(project: &Project, p: &Path) -> PathBuf {
4066    match p.strip_prefix(&project.root) {
4067        Ok(r) => crate::slash(r),
4068        Err(_) => rel(&project.target_dir, p),
4069    }
4070}
4071
4072impl BindingRun<'_> {
4073    /// What this Binding produced, for its end-of-run line.
4074    fn summary_line(&self) -> String {
4075        let rows: u64 = self.produced.iter().map(|p| p.rows).sum();
4076        let mut parts = Vec::new();
4077        if !self.produced.is_empty() {
4078            let n = self.produced.len();
4079            parts.push(format!(
4080                "{n} result set{}, {} row{}",
4081                if n == 1 { "" } else { "s" },
4082                thousands(rows),
4083                if rows == 1 { "" } else { "s" }
4084            ));
4085        }
4086        let names: Vec<String> = self
4087            .files()
4088            .map(|(f, _)| f.file_name().unwrap_or_default().to_string_lossy().to_string())
4089            .collect();
4090        if !names.is_empty() {
4091            parts.push(format!("→ {}", names.join(", ")));
4092        }
4093        // Every destination's location, not just the first one each file reached.
4094        let delivered: Vec<&str> = self
4095            .outs
4096            .iter()
4097            .flat_map(|o| o.deliveries.iter())
4098            .filter_map(|d| d.location.as_deref())
4099            .collect();
4100        let notes: Vec<&str> = self
4101            .outs
4102            .iter()
4103            .filter(|o| !o.dests.is_empty())
4104            .filter_map(|o| o.delivery_note.as_deref())
4105            .collect();
4106        if !delivered.is_empty() {
4107            parts.push(format!("→ {}", delivered.join(", ")));
4108        } else if !notes.is_empty() && self.opts.preview.is_none() {
4109            parts.push(format!("({})", notes.join("; ")));
4110        }
4111        parts.join(" ")
4112    }
4113
4114    /// Every file the Binding's outputs wrote, and where each was first delivered.
4115    fn files(&self) -> impl Iterator<Item = &(PathBuf, Option<String>)> {
4116        self.outs.iter().flat_map(|o| o.files.iter())
4117    }
4118}
4119
4120/// `1234567` → `1,234,567`.
4121pub fn thousands(n: u64) -> String {
4122    let s = n.to_string();
4123    let mut out = String::new();
4124    for (i, c) in s.chars().enumerate() {
4125        if i > 0 && (s.len() - i).is_multiple_of(3) {
4126            out.push(',');
4127        }
4128        out.push(c);
4129    }
4130    out
4131}
4132
4133fn human_bytes(n: u64) -> String {
4134    match n {
4135        n if n < 1024 => format!("{n} B"),
4136        n if n < 1024 * 1024 => format!("{:.1} KB", n as f64 / 1024.0),
4137        n => format!("{:.1} MB", n as f64 / (1024.0 * 1024.0)),
4138    }
4139}
4140
4141/// `dre clean`: remove the target folder `t`.
4142pub fn clean(t: &Path) -> std::io::Result<bool> {
4143    if t.exists() {
4144        std::fs::remove_dir_all(t)?;
4145        Ok(true)
4146    } else {
4147        Ok(false)
4148    }
4149}