Skip to main content

dre_core/
run.rs

1//! The run path: turn a resolved project into delivered files.
2//!
3//! Per Binding: render → split → (unmanaged check) → execute on one session → format into
4//! `target/run/` → schema-drift check → deliver → snapshot → `run_results.json`.
5
6use std::collections::BTreeMap;
7use std::fs::File;
8use std::path::{Path, PathBuf};
9use std::sync::{Arc, Mutex};
10use std::time::{Duration, Instant};
11
12use arrow::array::RecordBatch;
13use arrow::datatypes::SchemaRef;
14use arrow::ipc::reader::FileReader;
15use arrow::ipc::writer::FileWriter;
16use chrono::NaiveDate;
17use dre_protocol::host::{Execution, LogSink, PluginProcess};
18use dre_protocol::msg::{ColumnOptions, DeliveryFile, ResultSetMeta};
19use dre_protocol::{CAP_LOAD, CAP_MULTI_FILE, CAP_READ_ONLY, CAP_SESSIONS};
20use serde::Serialize;
21use serde_json::{Map as JsonMap, Value as Json, json};
22
23use crate::dates::Calendar;
24use crate::lookups::Table;
25use crate::profiles::{BUILTIN_LOCAL, LOCAL_TYPE, ProfileTarget, Profiles, Role};
26use crate::project::{Binding, PluginKind, Project, QueryEntry, Report};
27use crate::render::{
28    Column, Connection, Connections, QueryRows, QueryRunner, RenderError, Renderer, RendererConfig,
29    RunContext,
30};
31use crate::selector;
32use crate::sqlsplit::{self, StatementKind};
33
34/// What the caller asked for.
35#[derive(Debug, Clone, Default)]
36pub struct RunOptions {
37    pub selector: Option<String>,
38    /// `--set <name>` or `--set all`.
39    pub set: Option<String>,
40    pub target: Option<String>,
41    pub profile: Option<String>,
42    pub vars: BTreeMap<String, String>,
43    pub output_name: Option<String>,
44    pub output_path: Option<String>,
45    pub dry_run: bool,
46    /// `--preview [N]`: row limit per query.
47    pub preview: Option<u64>,
48    pub accept_schema_change: bool,
49    /// Whether a person is at a terminal to answer prompts.
50    pub interactive: bool,
51    /// `run.date` (`DRE_RUN_DATE`).
52    pub date: Option<NaiveDate>,
53    /// The instant this run was scheduled for (`DRE_RUN_AT`): `run.now` and `run.scheduled_at`,
54    /// and `run.date` in the run's timezone unless `date` is set.
55    pub scheduled_at: Option<chrono::DateTime<chrono::Utc>>,
56    /// `validate --live`: check statements instead of executing them.
57    pub live_check: bool,
58    /// `--schedule <name>`: run exactly the Bindings that schedule targets, with its vars.
59    pub schedule: Option<String>,
60    /// `--timezone` or `DRE_TIMEZONE`: above every configured `timezone:`.
61    pub timezone: Option<String>,
62    /// SHA-256 of the manifest this command wrote, for `run_results.json`.
63    pub manifest_checksum: Option<String>,
64}
65
66impl RunOptions {
67    /// What the run was asked to do, for `run_results.json` and the log.
68    pub fn params(&self, date: NaiveDate) -> Json {
69        json!({
70            "selector": self.selector,
71            "set": self.set,
72            "schedule": self.schedule,
73            "target": self.target,
74            "profile": self.profile,
75            "vars": self.vars,
76            "run_date": date.to_string(),
77            "scheduled_at": self.scheduled_at.map(rfc3339),
78            "timezone": self.timezone,
79            "output_name": self.output_name,
80            "output_path": self.output_path,
81            "dry_run": self.dry_run,
82            "preview": self.preview,
83            "accept_schema_change": self.accept_schema_change,
84        })
85    }
86}
87
88/// How much a message matters: `Info` is shown by default, `Debug` with `-v`.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
90pub enum Level {
91    Debug,
92    Info,
93}
94
95/// How the run reports progress. The engine emits structured events; the CLI decides how
96/// they look (colour, progress bar, JSON, log file).
97pub trait Ui {
98    /// The number of Bindings about to run, once Sets are resolved.
99    fn plan(&mut self, _bindings: usize) {}
100    fn binding_start(&mut self, _report: &str, _set: Option<&str>) {}
101    /// Right after `binding_start`: the schedule it runs under (if any) and every var it uses.
102    fn binding_vars(
103        &mut self,
104        _schedule: Option<&str>,
105        _schedule_vars: Option<&JsonMap<String, Json>>,
106        _vars: &JsonMap<String, Json>,
107    ) {
108    }
109    /// One step inside the current Binding: a short verb, a detail, and how long it took.
110    fn step(&mut self, level: Level, verb: &str, detail: &str, elapsed: Option<Duration>);
111    fn warn(&mut self, msg: &str);
112    fn binding_end(&mut self, _outcome: &BindingOutcome) {}
113    /// A Binding was compiled (`--dry-run`, `dre compile`, `dre validate`): what it would do.
114    fn compiled(&mut self, _plan: &BindingPlan) {}
115    /// Ask which Set to run; `None` means "all".
116    fn choose_set(&mut self, report: &str, sets: &[String]) -> Result<Option<String>, String>;
117    /// Where plugin stderr goes.
118    fn plugin_log(&self) -> LogSink;
119    /// Where the full text of every statement sent to a source goes: `(label, sql)`.
120    fn sql_log(&self) -> LogSink {
121        Arc::new(|_, _| {})
122    }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
126#[serde(rename_all = "snake_case")]
127pub enum Status {
128    Success,
129    Error,
130    DryRun,
131    Checked,
132}
133
134#[derive(Debug, Clone, Serialize)]
135pub struct BindingOutcome {
136    pub report: String,
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub set: Option<String>,
139    pub binding: String,
140    pub status: Status,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub error: Option<String>,
143    pub files: Vec<PathBuf>,
144    /// One line describing what the Binding produced (result sets, rows, outputs, delivery).
145    pub summary: String,
146    /// The schedule this ran under (`--schedule`), and the vars it layered in.
147    pub schedule: Option<String>,
148    pub schedule_vars: Option<JsonMap<String, Json>>,
149    /// Every var the Binding rendered with: its own, the schedule's, then `--var`.
150    pub vars: JsonMap<String, Json>,
151    /// The run's timezone (IANA name); empty when the Binding never started.
152    #[serde(skip_serializing_if = "String::is_empty")]
153    pub timezone: String,
154    #[serde(skip)]
155    pub elapsed: Duration,
156}
157
158/// What a compiled Binding would use and produce if it ran.
159#[derive(Debug, Clone, Serialize)]
160pub struct BindingPlan {
161    pub report: String,
162    pub set: Option<String>,
163    /// Rendered SQL files, relative to the project.
164    pub compiled: Vec<PathBuf>,
165    pub profile: String,
166    pub source_type: String,
167    pub target: String,
168    pub format: String,
169    /// The file written under `target/run/` (a single-table format writes one per result set
170    /// when there are several).
171    pub output: PathBuf,
172    pub destinations: Vec<PlannedDestination>,
173    pub schedules: Vec<String>,
174}
175
176#[derive(Debug, Clone, Serialize)]
177pub struct PlannedDestination {
178    pub profile: String,
179    /// The destination type for the active target, when it has one.
180    pub kind: Option<String>,
181    pub target: Option<String>,
182    pub path: Option<String>,
183    /// False when the profile has no target for this run: the output stays in `target/`.
184    pub delivers: bool,
185}
186
187#[derive(Debug, Default)]
188pub struct RunSummary {
189    pub outcomes: Vec<BindingOutcome>,
190    /// Selection failed before anything ran.
191    pub error: Option<String>,
192}
193
194impl RunSummary {
195    pub fn failed(&self) -> bool {
196        self.error.is_some() || self.outcomes.iter().any(|o| o.status == Status::Error)
197    }
198}
199
200pub fn run(project: &Project, opts: &RunOptions, ui: &mut dyn Ui) -> RunSummary {
201    let mut summary = RunSummary::default();
202    if let Err(e) = crate::target::ensure(&project.target_dir) {
203        summary.error = Some(format!(
204            "can't write to the target path {} (from {}): {e}",
205            project.target_dir.display(),
206            project.target_source
207        ));
208        return summary;
209    }
210    if let Some(name) = &opts.schedule {
211        if !project.schedules.iter().any(|e| &e.name == name) {
212            summary.error = Some(unknown_schedule(project, name));
213            return summary;
214        }
215        let planned = match schedule_bindings(project, name, opts) {
216            Ok(p) => p,
217            Err(e) => {
218                summary.error = Some(e);
219                return summary;
220            }
221        };
222        ui.plan(planned.len());
223        for (report, b) in &planned {
224            ui.binding_start(&report.name, b.set.as_deref());
225            let mut r = BindingRun::new(project, report, b, opts, ui);
226            let outcome = r.run();
227            ui.binding_end(&outcome);
228            summary.outcomes.push(outcome);
229        }
230        return summary;
231    }
232    let reports: Vec<&Report> = match &opts.selector {
233        None => project.reports.iter().collect(),
234        Some(s) => match selector::resolve(project, s) {
235            Ok(r) if r.is_empty() => {
236                summary.error = Some(format!("selector `{s}` matches no report"));
237                return summary;
238            }
239            Ok(r) => r,
240            Err(e) => {
241                summary.error = Some(e.to_string());
242                return summary;
243            }
244        },
245    };
246    // Resolve every report's Bindings first (prompts happen here), so progress has a total.
247    let mut planned: Vec<(&Report, Binding)> = Vec::new();
248    for report in reports {
249        match choose_bindings(project, report, opts, ui) {
250            Ok(bs) => planned.extend(bs.into_iter().map(|b| (report, b))),
251            Err(e) => {
252                let outcome = BindingOutcome {
253                    report: report.name.clone(),
254                    set: None,
255                    binding: "-".into(),
256                    status: Status::Error,
257                    error: Some(e),
258                    files: Vec::new(),
259                    summary: String::new(),
260                    schedule: opts.schedule.clone(),
261                    schedule_vars: None,
262                    vars: JsonMap::new(),
263                    timezone: String::new(),
264                    elapsed: Duration::ZERO,
265                };
266                ui.binding_end(&outcome);
267                summary.outcomes.push(outcome);
268            }
269        }
270    }
271    ui.plan(planned.len());
272    for (report, b) in &planned {
273        ui.binding_start(&report.name, b.set.as_deref());
274        let mut r = BindingRun::new(project, report, b, opts, ui);
275        let outcome = r.run();
276        ui.binding_end(&outcome);
277        summary.outcomes.push(outcome);
278    }
279    summary
280}
281
282/// The Bindings `--schedule <name>` runs: all of them, or those a selector and/or `--set` pick.
283/// Picking something the schedule doesn't run is an error naming what it does run.
284fn schedule_bindings<'a>(
285    project: &'a Project,
286    name: &str,
287    opts: &RunOptions,
288) -> Result<Vec<(&'a Report, Binding)>, String> {
289    let reports: Option<Vec<&str>> = match &opts.selector {
290        None => None,
291        Some(s) => Some(
292            selector::resolve(project, s)
293                .map_err(|e| e.to_string())?
294                .into_iter()
295                .map(|r| r.name.as_str())
296                .collect(),
297        ),
298    };
299    let set = opts.set.as_deref().filter(|s| *s != "all");
300    let all: Vec<(&Report, &Binding)> = project
301        .reports
302        .iter()
303        .flat_map(|r| {
304            r.bindings
305                .iter()
306                .filter(|b| b.schedules.iter().any(|s| s == name))
307                .map(move |b| (r, b))
308        })
309        .collect();
310    let picked: Vec<(&Report, Binding)> = all
311        .iter()
312        .filter(|(r, _)| reports.as_ref().is_none_or(|rs| rs.contains(&r.name.as_str())))
313        .filter(|(_, b)| set.is_none() || b.set.as_deref() == set)
314        .map(|(r, b)| {
315            let mut b = (*b).clone();
316            if let Some(p) = &opts.profile {
317                b.profile = Some(p.clone());
318            }
319            (*r, b)
320        })
321        .collect();
322    if picked.is_empty() && (reports.is_some() || set.is_some()) {
323        let mut asked = Vec::new();
324        if let Some(s) = &opts.selector {
325            asked.push(format!("`{s}`"));
326        }
327        if let Some(s) = set {
328            asked.push(format!("Set `{s}`"));
329        }
330        let runs: Vec<String> = all
331            .iter()
332            .map(|(r, b)| match &b.set {
333                Some(s) => format!("{}/{s}", r.name),
334                None => r.name.clone(),
335            })
336            .collect();
337        return Err(format!(
338            "schedule `{name}` doesn't run {}; it runs: {}",
339            asked.join(" with "),
340            if runs.is_empty() {
341                "nothing".to_string()
342            } else {
343                runs.join(", ")
344            }
345        ));
346    }
347    Ok(picked)
348}
349
350/// The usage error for `--schedule` with a name that isn't declared.
351pub fn unknown_schedule(project: &Project, name: &str) -> String {
352    let names: Vec<&str> = project.schedules.iter().map(|e| e.name.as_str()).collect();
353    if names.is_empty() {
354        format!("no schedule `{name}`: this project's schedules.yml declares none")
355    } else {
356        format!("no schedule `{name}`; valid names: {}", names.join(", "))
357    }
358}
359
360/// Which Bindings of a report run, following ADR 0009's Set rules.
361pub fn choose_bindings(
362    project: &Project,
363    report: &Report,
364    opts: &RunOptions,
365    ui: &mut dyn Ui,
366) -> Result<Vec<Binding>, String> {
367    let mut out: Vec<Binding> = match opts.set.as_deref() {
368        Some("all") => report.bindings.clone(),
369        Some(name) => match report.binding(name) {
370            Some(b) => vec![b.clone()],
371            None => vec![ad_hoc(project, report, name)],
372        },
373        None if !report.has_sets => report.bindings.clone(),
374        None => {
375            let names: Vec<String> = report.bindings.iter().filter_map(|b| b.set.clone()).collect();
376            let pick = report
377                .default_set
378                .clone()
379                .or_else(|| project.default_set.clone().filter(|d| names.contains(d)))
380                .or_else(|| (names.len() == 1).then(|| names[0].clone()));
381            match pick {
382                Some(p) => report.binding(&p).cloned().into_iter().collect(),
383                None if opts.interactive => match ui.choose_set(&report.name, &names)? {
384                    Some(p) => report.binding(&p).cloned().into_iter().collect(),
385                    None => report.bindings.clone(),
386                },
387                None => {
388                    return Err(format!(
389                        "report `{}` has several Sets ({}) and no `default_set`; declare `default_set:` or pass `--set <name>` or `--set all`",
390                        report.name,
391                        names.join(", ")
392                    ));
393                }
394            }
395        }
396    };
397    if let Some(p) = &opts.profile {
398        for b in &mut out {
399            b.profile = Some(p.clone());
400        }
401    }
402    Ok(out)
403}
404
405/// A Set that isn't declared on the report: start from the report itself, then apply the
406/// `sets.yml` entry of that name if there is one. `--profile`/`--var` complete it.
407fn ad_hoc(project: &Project, report: &Report, name: &str) -> Binding {
408    let mut b = report.base.clone();
409    b.set = Some(name.to_string());
410    if let Some(reg) = project.sets.get(name) {
411        if let Some(p) = &reg.profile {
412            b.profile = Some(p.clone());
413        }
414        b.vars.extend(reg.vars.clone());
415    }
416    b
417}
418
419/// The tab one query produced: its last statement's result set, spooled to disk.
420struct Produced {
421    query: String,
422    schema: SchemaRef,
423    rows: u64,
424    spool: PathBuf,
425    name: String,
426    anchor: Option<String>,
427    header: Option<bool>,
428    columns: BTreeMap<String, ColumnOptions>,
429}
430
431struct Statement {
432    query: String,
433    file: PathBuf,
434    line: usize,
435    text: String,
436    kind: StatementKind,
437    /// Whether this statement's result is its query's tab: the file's last statement, when the
438    /// query has `tab: true`.
439    tab: bool,
440}
441
442struct BindingRun<'a> {
443    project: &'a Project,
444    report: &'a Report,
445    b: &'a Binding,
446    /// The Binding's vars with the schedule's layered on (`--var` stays separate, on top).
447    vars: JsonMap<String, Json>,
448    /// Everything `var()` sees, `--var` included: what's recorded.
449    rendered_vars: JsonMap<String, Json>,
450    schedule_vars: Option<JsonMap<String, Json>>,
451    opts: &'a RunOptions,
452    /// `run.date`: `DRE_RUN_DATE`, else today in the run's timezone.
453    date: NaiveDate,
454    calendar: Calendar,
455    ui: &'a mut dyn Ui,
456    compiled_dir: PathBuf,
457    run_dir: PathBuf,
458    schema_dir: PathBuf,
459    target: String,
460    /// The source plugin type, once the profile resolves.
461    source_type: String,
462    started: Instant,
463    started_at: chrono::DateTime<chrono::Utc>,
464    produced: Vec<Produced>,
465    files: Vec<(PathBuf, Option<String>)>,
466    /// The Binding's destinations with paths and options rendered.
467    dests: Vec<RenderedDest>,
468    /// One record per destination: `run_results.json`'s `deliveries`.
469    deliveries: Vec<Json>,
470    delivery_note: Option<String>,
471    drift: Vec<String>,
472}
473
474/// A destination entry after rendering.
475struct RenderedDest {
476    profile: String,
477    path: Option<String>,
478    options: JsonMap<String, Json>,
479}
480
481type Fail = String;
482
483impl<'a> BindingRun<'a> {
484    fn new(
485        project: &'a Project,
486        report: &'a Report,
487        b: &'a Binding,
488        opts: &'a RunOptions,
489        ui: &'a mut dyn Ui,
490    ) -> Self {
491        let t = &project.target_dir;
492        let rel = Path::new(&report.name).join(b.dir_name());
493        let schedule = opts
494            .schedule
495            .as_ref()
496            .and_then(|n| project.schedules.iter().find(|e| &e.name == n));
497        let schedule_vars = schedule.map(|e| e.vars.clone());
498        // Names were checked when the project and the command line were read.
499        let tz = opts
500            .timezone
501            .as_ref()
502            .or(schedule.and_then(|e| e.timezone.as_ref()))
503            .or(report.timezone.as_ref())
504            .and_then(|t| crate::dates::parse_tz(t).ok())
505            .unwrap_or(chrono_tz::Tz::UTC);
506        let calendar = Calendar {
507            tz,
508            week_start: project.week_start,
509            numbering: project.week_numbering,
510        };
511        let date = opts
512            .date
513            .or(opts
514                .scheduled_at
515                .map(|t| t.with_timezone(&calendar.tz).date_naive()))
516            .unwrap_or_else(|| calendar.today());
517        // Binding vars, then the schedule's, then `--var` on top.
518        let mut vars = b.vars.clone();
519        vars.extend(schedule_vars.clone().unwrap_or_default());
520        let rendered_vars = {
521            let mut v = vars.clone();
522            v.extend(
523                opts.vars
524                    .iter()
525                    .map(|(k, x)| (k.clone(), Json::String(x.clone()))),
526            );
527            v
528        };
529        ui.binding_vars(opts.schedule.as_deref(), schedule_vars.as_ref(), &rendered_vars);
530        BindingRun {
531            project,
532            report,
533            b,
534            vars,
535            rendered_vars,
536            schedule_vars,
537            opts,
538            date,
539            calendar,
540            ui,
541            compiled_dir: t.join("compiled").join(&rel),
542            run_dir: t.join("run").join(&rel),
543            schema_dir: t.join("schema").join(&rel),
544            target: String::new(),
545            source_type: String::new(),
546            started: Instant::now(),
547            started_at: chrono::Utc::now(),
548            produced: Vec::new(),
549            files: Vec::new(),
550            dests: Vec::new(),
551            deliveries: Vec::new(),
552            delivery_note: None,
553            drift: Vec::new(),
554        }
555    }
556
557    /// What `target` and `profile()` read for this Binding.
558    fn connections(&self, source: Option<&str>) -> Arc<dyn Connections> {
559        Arc::new(ProfileConnections {
560            profiles: self.project.profiles.clone(),
561            source: source.map(str::to_string),
562            target: self.opts.target.clone(),
563            root: self.project.root.clone(),
564            plugins: self.project.plugins.clone(),
565            log: self.ui.plugin_log(),
566        })
567    }
568
569    fn outcome(&self, status: Status, error: Option<String>) -> BindingOutcome {
570        BindingOutcome {
571            report: self.report.name.clone(),
572            set: self.b.set.clone(),
573            binding: self.b.dir_name().to_string(),
574            status,
575            error,
576            files: self.files.iter().map(|(p, _)| p.clone()).collect(),
577            summary: self.summary_line(),
578            schedule: self.opts.schedule.clone(),
579            schedule_vars: self.schedule_vars.clone(),
580            vars: self.rendered_vars.clone(),
581            timezone: self.calendar.tz.name().to_string(),
582            elapsed: self.started.elapsed(),
583        }
584    }
585
586    fn run(&mut self) -> BindingOutcome {
587        let dry = self.opts.dry_run || self.opts.live_check;
588        if !dry {
589            let _ = std::fs::remove_dir_all(&self.run_dir);
590        }
591        let result = self.run_inner();
592        let status = match (&result, dry, self.opts.live_check) {
593            (Err(_), _, _) => Status::Error,
594            (Ok(()), _, true) => Status::Checked,
595            (Ok(()), true, _) => Status::DryRun,
596            (Ok(()), false, _) => Status::Success,
597        };
598        let err = result.err();
599        if !dry && let Err(e) = self.write_results(&status, err.as_deref()) {
600            self.ui.warn(&format!("can't write run_results.json: {e}"));
601        }
602        // Spools are scratch space.
603        let _ = std::fs::remove_dir_all(self.run_dir.join(".spool"));
604        self.outcome(status, err)
605    }
606
607    fn run_inner(&mut self) -> Result<(), Fail> {
608        let profile_name = self
609            .b
610            .profile
611            .clone()
612            .ok_or("no source profile resolves for this Binding")?;
613        let profiles = &self.project.profiles;
614        let profile = profiles
615            .get(Role::Source, &profile_name)
616            .ok_or_else(|| format!("source profile `{profile_name}` isn't in profiles.yml"))?;
617        let target = self.opts.target.clone().unwrap_or_else(|| profile.target.clone());
618        let (_, output) = profiles
619            .target(Role::Source, &profile_name, Some(&target))
620            .ok_or_else(|| {
621                format!(
622                    "source profile `{profile_name}` has no `{target}` target (it has: {})",
623                    profile.targets.keys().cloned().collect::<Vec<_>>().join(", ")
624                )
625            })?;
626        self.target = target.clone();
627        self.source_type = output.kind.clone();
628        // Found and opened only when something needs the database, so compiling a report whose
629        // templates don't query it works without the plugin or credentials.
630        let source_path = find_plugin(self.project, PluginKind::Source, &output.kind).map_err(String::from);
631        let connection = render_connection(output);
632
633        let session = Arc::new(Mutex::new(Session::new(
634            source_path,
635            connection,
636            self.project.root.clone(),
637            self.ui.plugin_log(),
638            !self.report.managed,
639        )));
640
641        if !self.report.managed {
642            self.ui.warn(&format!(
643                "`{}` is an unmanaged report ({}): for quick tests only — add a YAML to make it a managed report",
644                self.report.name,
645                self.report.file.display()
646            ));
647        }
648
649        // 1. Render.
650        let renderer = Renderer::new(RendererConfig {
651            root: &self.project.root,
652            macros: &self.project.macros,
653            context: RunContext {
654                report: self.report.name.clone(),
655                set: self.b.set.clone(),
656                target: target.clone(),
657                profile: profile_name.clone(),
658                source_type: output.kind.clone(),
659                schedule: self.opts.schedule.clone(),
660                date: self.date,
661                now: self.opts.scheduled_at.unwrap_or(self.started_at),
662                scheduled_at: self.opts.scheduled_at,
663                calendar: self.calendar,
664            },
665            vars: self.vars.clone(),
666            cli_vars: self.opts.vars.clone(),
667            runner: Some(Arc::new(SessionRunner(session.clone(), self.ui.sql_log()))),
668            connections: Some(self.connections(Some(&profile_name))),
669            run_query_max_rows: self.project.run_query_max_rows,
670            sql: self.project.sql.clone(),
671            lookups: self.project.lookups.clone(),
672            lookup_inline_max_rows: self.project.lookup_inline_max_rows,
673            packages: self.project.packages.clone(),
674            project_name: self.project.name.clone(),
675            dispatch: self.project.dispatch.clone(),
676        })
677        .map_err(|e| e.to_string())?;
678        std::fs::create_dir_all(&self.compiled_dir).map_err(|e| e.to_string())?;
679        // A real run of a managed report renders each query just before running it, so a
680        // template can look at what earlier queries made (`columns()` of a temp table).
681        // Compiling, checking and unmanaged reports render everything first.
682        let interleave = !self.opts.dry_run && !self.opts.live_check && self.report.managed;
683        let mut statements = Vec::new();
684        // Reported after the unmanaged-report check, which matters more.
685        let mut two_tabs: Option<String> = None;
686        if interleave {
687            self.dests = self.render_destinations(&renderer)?;
688            if self.b.queries.len() > 1 {
689                self.check_sessions(&session, self.b.queries.len(), &output.kind)?;
690            }
691            std::fs::create_dir_all(self.run_dir.join(".spool")).map_err(|e| e.to_string())?;
692            let mut i = 0;
693            for q in &self.b.queries {
694                let sts = self.render_query(&renderer, q, &mut two_tabs)?;
695                for w in renderer.take_warnings() {
696                    self.ui.warn(&w);
697                }
698                if let Some(e) = two_tabs.take() {
699                    return Err(e);
700                }
701                if i == 0 && sts.len() > 1 {
702                    self.check_sessions(&session, sts.len(), &output.kind)?;
703                }
704                for st in &sts {
705                    self.execute_statement(&session, i, st)?;
706                    i += 1;
707                }
708            }
709        } else {
710            for q in &self.b.queries {
711                statements.extend(self.render_query(&renderer, q, &mut two_tabs)?);
712            }
713            self.dests = self.render_destinations(&renderer)?;
714            for w in renderer.take_warnings() {
715                self.ui.warn(&w);
716            }
717
718            // 3. Unmanaged: every rendered statement must only read (or create temp objects).
719            if !self.report.managed {
720                if let Some(bad) = statements.iter().find(|s| !s.kind.is_read_only_safe()) {
721                    return Err(format!(
722                        "{}:{}: 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",
723                        bad.file.display(),
724                        bad.line,
725                        self.report.name,
726                        dre_protocol::util::summarize(&bad.text, 60)
727                    ));
728                }
729                let creates_temp = statements.iter().any(|s| s.kind == StatementKind::TempCreate)
730                    || session.lock().unwrap().loaded;
731                session.lock().unwrap().want_read_only(!creates_temp);
732            }
733            if let Some(e) = two_tabs {
734                return Err(e);
735            }
736
737            if self.opts.dry_run {
738                let plan = self.plan(&profile_name, &output.kind);
739                self.ui.compiled(&plan);
740                return Ok(());
741            }
742
743            // The one-session guarantee.
744            if statements.len() > 1 {
745                self.check_sessions(&session, statements.len(), &output.kind)?;
746            }
747
748            if self.opts.live_check {
749                return self.live_check(&session, &statements, &output.kind);
750            }
751
752            // 4. Execute in order on one session.
753            std::fs::create_dir_all(self.run_dir.join(".spool")).map_err(|e| e.to_string())?;
754            for (i, st) in statements.iter().enumerate() {
755                self.execute_statement(&session, i, st)?;
756            }
757        }
758        if let Ok(mut s) = session.lock() {
759            s.close();
760        }
761
762        self.name_result_sets()?;
763
764        // 5. Format into target/run/.
765        let filename = self.file_names();
766        self.format(&filename)?;
767
768        // Schema drift, before delivery.
769        if self.opts.preview.is_none() {
770            self.drift = self.schema_drift();
771            if !self.drift.is_empty() {
772                if self.opts.accept_schema_change {
773                    self.ui.warn(&format!(
774                        "  schema changed since the last successful run (accepted): {}",
775                        self.drift.join("; ")
776                    ));
777                } else {
778                    return Err(format!(
779                        "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",
780                        self.drift.join("; "),
781                        rel(&self.project.root, &self.run_dir).display()
782                    ));
783                }
784            }
785        }
786
787        // 6. Deliver.
788        if self.opts.preview.is_some() {
789            self.delivery_note = Some("preview: not delivered".into());
790            let dir = rel(&self.project.root, &self.run_dir);
791            self.ui.step(
792                Level::Info,
793                "Preview",
794                &format!("not delivered; output stays in {}", dir.display()),
795                None,
796            );
797        } else {
798            self.deliver()?;
799        }
800
801        // 7. Snapshot the schema for the next drift check.
802        if self.opts.preview.is_none() {
803            self.write_snapshot()
804                .map_err(|e| format!("can't write the schema snapshot: {e}"))?;
805        }
806        Ok(())
807    }
808
809    /// Render one query file into its statements, writing it to `target/compiled/`.
810    fn render_query(
811        &mut self,
812        renderer: &Renderer,
813        q: &QueryEntry,
814        two_tabs: &mut Option<String>,
815    ) -> Result<Vec<Statement>, Fail> {
816        let mut out = Vec::new();
817        let src = std::fs::read_to_string(self.project.root.join(&q.path))
818            .map_err(|e| format!("{}: {e}", q.path.display()))?;
819        let sql = renderer
820            .render(&q.path, &src)
821            .map_err(|e: RenderError| e.to_string())?;
822        std::fs::write(
823            self.compiled_dir.join(format!("{}.sql", q.query)),
824            crate::secrets::mask(&sql).as_bytes(),
825        )
826        .map_err(|e| e.to_string())?;
827        self.ui
828            .step(Level::Debug, "Rendered", &q.path.display().to_string(), None);
829        // 2. Split. One file makes at most one tab, from its last statement, so any earlier
830        // SELECT would be lost: that's an error, not a guess.
831        let parts = sqlsplit::split(&sql);
832        if q.tab && parts.is_empty() {
833            return Err(format!(
834                "{}: `{}` makes a tab but has no statements; add a query, or `tab: false` if it's meant to be empty",
835                q.path.display(),
836                q.query
837            ));
838        }
839        let n = parts.len();
840        for (i, st) in parts.into_iter().enumerate() {
841            let body = sqlsplit::strip_leading_comments(&st.text);
842            let line = st.line + st.text[..st.text.len() - body.len()].matches('\n').count();
843            let kind = sqlsplit::classify(&st.text);
844            let last = i + 1 == n;
845            if q.tab && !last && kind == StatementKind::Read && two_tabs.is_none() {
846                *two_tabs = Some(format!(
847                    "{}:{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:`",
848                    q.path.display(),
849                    q.query
850                ));
851            }
852            out.push(Statement {
853                query: q.query.clone(),
854                file: q.path.clone(),
855                line,
856                kind,
857                text: st.text,
858                tab: q.tab && last,
859            });
860        }
861        Ok(out)
862    }
863
864    /// Fail unless the source can hold one session across `n` statements.
865    fn check_sessions(&self, session: &Arc<Mutex<Session>>, n: usize, kind: &str) -> Result<(), Fail> {
866        let mut s = session.lock().unwrap();
867        if !s.get()?.has(CAP_SESSIONS) {
868            return Err(format!(
869                "this Binding runs {n} statements, but the `{kind}` source plugin can't hold one session across them; nothing was run"
870            ));
871        }
872        Ok(())
873    }
874
875    /// Run statement `i`, spooling its result if it makes a tab.
876    fn execute_statement(
877        &mut self,
878        session: &Arc<Mutex<Session>>,
879        i: usize,
880        st: &Statement,
881    ) -> Result<(), Fail> {
882        let sql_log = self.ui.sql_log();
883        sql_log(&format!("{}:{}", st.file.display(), st.line), &st.text);
884        let spool_path = self.run_dir.join(".spool").join(format!("{i}.arrow"));
885        let mut writer: Option<FileWriter<File>> = None;
886        let t = Instant::now();
887        let exec = {
888            let mut s = session.lock().unwrap();
889            let p = s.get()?;
890            p.execute(&st.text, self.opts.preview, |schema, batch| {
891                if writer.is_none() {
892                    let f = File::create(&spool_path).map_err(|e| e.to_string())?;
893                    writer = Some(FileWriter::try_new(f, schema).map_err(|e| e.to_string())?);
894                }
895                writer.as_mut().unwrap().write(&batch).map_err(|e| e.to_string())
896            })
897            .map_err(|e| format!("{}:{}: {e}", st.file.display(), st.line))?
898        };
899        let what = match &exec {
900            Execution::Result { rows, .. } => {
901                format!("{} row{}", thousands(*rows), if *rows == 1 { "" } else { "s" })
902            }
903            Execution::NoResult {
904                rows_affected: Some(n),
905            } => format!("no result set ({n} affected)"),
906            Execution::NoResult { .. } => "no result set".to_string(),
907        };
908        let at = format!("{}:{}", st.file.display(), st.line);
909        self.ui.step(
910            Level::Debug,
911            "Executed",
912            &format!("{at}  {what}"),
913            Some(t.elapsed()),
914        );
915        // The YAML decides the tabs; the result only has to match it. A tab with no rows
916        // still gets its column names.
917        let (schema, rows) = match (st.tab, exec) {
918            (true, Execution::Result { schema, rows }) => (schema, rows),
919            (true, Execution::NoResult { .. }) => {
920                return Err(format!(
921                    "{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",
922                    st.query
923                ));
924            }
925            (false, _) => {
926                drop(writer);
927                let _ = std::fs::remove_file(&spool_path);
928                return Ok(());
929            }
930        };
931        {
932            let mut w = match writer {
933                Some(w) => w,
934                None => FileWriter::try_new(File::create(&spool_path).map_err(|e| e.to_string())?, &schema)
935                    .map_err(|e| e.to_string())?,
936            };
937            w.finish().map_err(|e| e.to_string())?;
938            self.produced.push(Produced {
939                query: st.query.clone(),
940                schema,
941                rows,
942                spool: spool_path,
943                name: String::new(),
944                anchor: None,
945                header: None,
946                columns: Default::default(),
947            });
948        }
949        Ok(())
950    }
951
952    fn render_destinations(&self, renderer: &Renderer) -> Result<Vec<RenderedDest>, Fail> {
953        self.b
954            .output
955            .destinations
956            .iter()
957            .map(|d| {
958                let path = match &d.path {
959                    Some(p) => Some(
960                        renderer
961                            .render(&self.report.file, p)
962                            .map_err(|e| format!("output path: {e}"))?,
963                    ),
964                    None => None,
965                };
966                let mut options = JsonMap::new();
967                for (k, v) in &d.options {
968                    let v = render_json(renderer, &self.report.file, v)
969                        .map_err(|e| format!("destination `{}` option `{k}`: {e}", d.profile))?;
970                    options.insert(k.clone(), v);
971                }
972                Ok(RenderedDest {
973                    profile: d.profile.clone(),
974                    path,
975                    options,
976                })
977            })
978            .collect()
979    }
980
981    /// Tab names: `tab_name`, else the query's basename.
982    fn name_result_sets(&mut self) -> Result<(), Fail> {
983        let entries: BTreeMap<&str, &QueryEntry> =
984            self.b.queries.iter().map(|q| (q.query.as_str(), q)).collect();
985        for p in &mut self.produced {
986            let q = entries[p.query.as_str()];
987            p.name = q.tab_name.clone().unwrap_or_else(|| q.query.clone());
988            p.anchor = q.anchor.clone();
989            p.header = q.header;
990            p.columns = q.columns.clone();
991        }
992        if self.b.output.format == "xlsx" {
993            let mut seen: BTreeMap<String, String> = BTreeMap::new();
994            for p in &self.produced {
995                check_sheet_name(&p.name)?;
996                if let Some(prev) = seen.insert(p.name.to_lowercase(), p.query.clone()) {
997                    return Err(format!(
998                        "sheet name `{}` is used twice (by `{prev}` and `{}`); Excel sheet names must be unique",
999                        p.name, p.query
1000                    ));
1001                }
1002            }
1003        }
1004        Ok(())
1005    }
1006
1007    /// What a real run of this Binding would use and produce, for `dre compile` and `validate`.
1008    fn plan(&mut self, profile: &str, source_type: &str) -> BindingPlan {
1009        let file = self.file_names();
1010        let compiled = self
1011            .b
1012            .queries
1013            .iter()
1014            .map(|q| {
1015                rel(
1016                    &self.project.root,
1017                    &self.compiled_dir.join(format!("{}.sql", q.query)),
1018                )
1019            })
1020            .collect();
1021        let destinations = self
1022            .dests
1023            .iter()
1024            .map(|d| {
1025                let resolved = self.dest_output(&d.profile);
1026                PlannedDestination {
1027                    profile: d.profile.clone(),
1028                    kind: resolved.as_ref().map(|(_, o)| o.kind.clone()),
1029                    target: resolved.map(|(t, _)| t).or_else(|| self.dest_target(&d.profile)),
1030                    path: d.path.clone(),
1031                    delivers: self.dest_output(&d.profile).is_some(),
1032                }
1033            })
1034            .collect();
1035        BindingPlan {
1036            report: self.report.name.clone(),
1037            set: self.b.set.clone(),
1038            compiled,
1039            profile: profile.to_string(),
1040            source_type: source_type.to_string(),
1041            target: self.target.clone(),
1042            format: self.b.output.format.clone(),
1043            output: rel(&self.project.root, &self.run_dir.join(file)),
1044            destinations,
1045            schedules: self.b.schedules.clone(),
1046        }
1047    }
1048
1049    /// Apply `--output-path`/`--output-name` to every destination that has a path, and return
1050    /// the local file name: `--output-name`, else the first destination path's file name, else
1051    /// `<report>.<ext>`.
1052    fn file_names(&mut self) -> String {
1053        for d in self.dests.iter_mut().filter(|d| d.path.is_some()) {
1054            if let Some(p) = &self.opts.output_path {
1055                d.path = Some(p.clone());
1056            }
1057            if let (Some(n), Some(r)) = (&self.opts.output_name, d.path.as_mut()) {
1058                *r = match r.rfind(['/', '\\']) {
1059                    Some(i) => format!("{}{n}", &r[..=i]),
1060                    None => n.clone(),
1061                };
1062            }
1063        }
1064        let ext = match &self.b.output.extension {
1065            Some(e) => e.as_str(),
1066            None => extension(&self.b.output.format),
1067        };
1068        let from_remote = self
1069            .dests
1070            .iter()
1071            .find_map(|d| d.path.as_deref())
1072            .and_then(|r| r.rsplit(['/', '\\']).next())
1073            .filter(|s| !s.is_empty())
1074            .map(str::to_string);
1075        self.opts
1076            .output_name
1077            .clone()
1078            .or(from_remote)
1079            .unwrap_or_else(|| match ext {
1080                "" => self.report.name.clone(),
1081                ext => format!("{}.{ext}", self.report.name),
1082            })
1083    }
1084
1085    fn format(&mut self, filename: &str) -> Result<(), Fail> {
1086        std::fs::create_dir_all(&self.run_dir).map_err(|e| e.to_string())?;
1087        if self.produced.is_empty() {
1088            self.ui
1089                .warn("  no query makes a tab (every query has `tab: false`); no output file was written");
1090            return Ok(());
1091        }
1092        let out = &self.b.output;
1093        let plugin = find_plugin(self.project, PluginKind::Format, &out.format)?;
1094        let mut p = plugin
1095            .start(self.ui.plugin_log(), Some(&self.project.root))
1096            .map_err(|e| e.to_string())?;
1097        let multi = out.format == "xlsx" || out.template.is_some() || !is_single_table(&out.format);
1098        let groups: Vec<(String, Vec<usize>)> = if multi || self.produced.len() == 1 {
1099            vec![(filename.to_string(), (0..self.produced.len()).collect())]
1100        } else {
1101            let (stem, ext) = split_ext(filename);
1102            let mut g = Vec::new();
1103            for (i, r) in self.produced.iter().enumerate() {
1104                if r.name.contains(['/', '\\']) {
1105                    return Err(format!("`{}` can't be used in a file name", r.name));
1106                }
1107                g.push((format!("{stem}_{}{ext}", r.name), vec![i]));
1108            }
1109            g
1110        };
1111        let template = match &out.template {
1112            Some(t) => Some(self.template_payload(t)?),
1113            None => None,
1114        };
1115        for (name, idxs) in groups {
1116            let t = Instant::now();
1117            let path = self.run_dir.join(&name);
1118            let metas: Vec<ResultSetMeta> = idxs
1119                .iter()
1120                .map(|&i| {
1121                    let r = &self.produced[i];
1122                    ResultSetMeta {
1123                        name: r.name.clone(),
1124                        query: r.query.clone(),
1125                        result_index: 1,
1126                        anchor: r.anchor.clone(),
1127                        header: r.header,
1128                        columns: r.columns.clone(),
1129                    }
1130                })
1131                .collect();
1132            p.write_begin(
1133                &path.to_string_lossy(),
1134                &out.format,
1135                out.options.clone(),
1136                metas,
1137                template.clone(),
1138            )
1139            .map_err(|e| e.to_string())?;
1140            for &i in &idxs {
1141                let r = &self.produced[i];
1142                let reader = FileReader::try_new(File::open(&r.spool).map_err(|e| e.to_string())?, None)
1143                    .map_err(|e| e.to_string())?;
1144                let schema = r.schema.clone();
1145                // Stream batch by batch so memory stays flat.
1146                let mut any = false;
1147                for b in reader {
1148                    let b = b.map_err(|e| e.to_string())?;
1149                    any = true;
1150                    p.send_batch(&b)
1151                        .map_err(|e| format!("{} format: {e}", out.format))?;
1152                }
1153                if !any {
1154                    p.send_batch(&RecordBatch::new_empty(schema))
1155                        .map_err(|e| format!("{} format: {e}", out.format))?;
1156                }
1157                p.send(&dre_protocol::msg::Request::ResultSetEnd {})
1158                    .map_err(|e| format!("{} format: {e}", out.format))?;
1159            }
1160            let (files, warnings) = p
1161                .write_finish_with_warnings()
1162                .map_err(|e| format!("{} format: {e}", out.format))?;
1163            for w in warnings {
1164                self.ui.warn(&format!("  {} format: {w}", out.format));
1165            }
1166            for f in files {
1167                let f = PathBuf::from(f);
1168                let size = std::fs::metadata(&f).map(|m| m.len()).unwrap_or(0);
1169                let shown = rel(&self.project.root, &f);
1170                self.ui.step(
1171                    Level::Debug,
1172                    "Wrote",
1173                    &format!("{} ({})", shown.display(), human_bytes(size)),
1174                    Some(t.elapsed()),
1175                );
1176                self.files.push((f, None));
1177            }
1178        }
1179        let _ = p.close();
1180        Ok(())
1181    }
1182
1183    fn template_payload(&self, t: &crate::project::Template) -> Result<Json, Fail> {
1184        let root = &self.project.root;
1185        let file = [root.join(&t.file), root.join("templates").join(&t.file)]
1186            .into_iter()
1187            .find(|p| p.is_file())
1188            .ok_or_else(|| format!("template file `{}` doesn't exist", t.file))?;
1189        let renderer = Renderer::new(RendererConfig {
1190            root,
1191            macros: &self.project.macros,
1192            context: RunContext {
1193                report: self.report.name.clone(),
1194                set: self.b.set.clone(),
1195                target: self.target.clone(),
1196                profile: self.b.profile.clone().unwrap_or_default(),
1197                source_type: self.source_type.clone(),
1198                schedule: self.opts.schedule.clone(),
1199                date: self.date,
1200                now: self.opts.scheduled_at.unwrap_or(self.started_at),
1201                scheduled_at: self.opts.scheduled_at,
1202                calendar: self.calendar,
1203            },
1204            vars: self.vars.clone(),
1205            cli_vars: self.opts.vars.clone(),
1206            runner: None,
1207            connections: Some(self.connections(self.b.profile.as_deref())),
1208            run_query_max_rows: self.project.run_query_max_rows,
1209            sql: self.project.sql.clone(),
1210            lookups: self.project.lookups.clone(),
1211            lookup_inline_max_rows: self.project.lookup_inline_max_rows,
1212            packages: self.project.packages.clone(),
1213            project_name: self.project.name.clone(),
1214            dispatch: self.project.dispatch.clone(),
1215        })
1216        .map_err(|e| e.to_string())?;
1217        let mut values = JsonMap::new();
1218        for b in &t.bindings {
1219            if let (Some(cell), Some(v)) = (&b.cell, &b.value) {
1220                let rendered = renderer
1221                    .render(&self.report.file, v)
1222                    .map_err(|e| format!("template value: {e}"))?;
1223                values.insert(format!("{}!{}", b.sheet, cell), Json::String(rendered));
1224            }
1225        }
1226        Ok(json!({"file": file.to_string_lossy(), "bindings": t.bindings, "values": values}))
1227    }
1228
1229    /// Deliver to every destination in order. A failure is recorded and the rest are still
1230    /// attempted; the Binding fails if any did.
1231    fn deliver(&mut self) -> Result<(), Fail> {
1232        if self.dests.is_empty() {
1233            self.delivery_note = Some("no destination declared; output stays in target/".into());
1234            let dir = rel(&self.project.root, &self.run_dir);
1235            self.ui.step(
1236                Level::Debug,
1237                "Kept",
1238                &format!("no destination declared: output stays in {}", dir.display()),
1239                None,
1240            );
1241            return Ok(());
1242        }
1243        if self.files.is_empty() {
1244            return Ok(());
1245        }
1246        let dests = std::mem::take(&mut self.dests);
1247        let mut failures = Vec::new();
1248        let mut skipped = Vec::new();
1249        for d in &dests {
1250            // The type of the output used, or for a skipped entry the profile's default one.
1251            let kind = self
1252                .dest_output(&d.profile)
1253                .map(|(_, o)| o.kind.clone())
1254                .or_else(|| {
1255                    let p = self.project.profiles.get(Role::Destination, &d.profile)?;
1256                    p.targets
1257                        .get(&p.target)
1258                        .or(p.targets.values().next())
1259                        .map(|o| o.kind.clone())
1260                });
1261            let (status, location, error) = match self.deliver_one(d) {
1262                Ok(Some(loc)) => ("delivered", Some(loc), None),
1263                Ok(None) => {
1264                    skipped.push(self.skip_note(&d.profile));
1265                    ("skipped", None, None)
1266                }
1267                Err(e) => {
1268                    failures.push(e.clone());
1269                    ("failed", None, Some(e))
1270                }
1271            };
1272            let mut record = json!({"profile": d.profile, "type": kind, "status": status});
1273            if let Some(l) = location {
1274                record["location"] = json!(l);
1275            }
1276            if let Some(e) = error {
1277                record["error"] = json!(e);
1278            }
1279            self.deliveries.push(record);
1280        }
1281        self.dests = dests;
1282        if self.files.iter().all(|(_, d)| d.is_none()) && !skipped.is_empty() {
1283            self.delivery_note = Some(skipped.join("; "));
1284        }
1285        match failures.len() {
1286            0 => Ok(()),
1287            1 if self.dests.len() == 1 => Err(failures.remove(0)),
1288            n => Err(format!(
1289                "{n} of {} destinations failed: {}",
1290                self.dests.len(),
1291                failures.join("; ")
1292            )),
1293        }
1294    }
1295
1296    /// The destination profile's settings for the active target.
1297    fn dest_output(&self, profile: &str) -> Option<(String, &ProfileTarget)> {
1298        if self.project.profiles.is_builtin_local(profile) {
1299            return Some((self.target.clone(), &BUILTIN_LOCAL));
1300        }
1301        let dtarget = self.dest_target(profile)?;
1302        self.project
1303            .profiles
1304            .target(Role::Destination, profile, Some(&dtarget))
1305            .map(|(_, o)| (dtarget, o))
1306    }
1307
1308    /// The target a destination profile delivers for: `--target`, else the profile's own.
1309    fn dest_target(&self, profile: &str) -> Option<String> {
1310        let p = self.project.profiles.get(Role::Destination, profile)?;
1311        Some(self.opts.target.clone().unwrap_or_else(|| p.target.clone()))
1312    }
1313
1314    fn skip_note(&self, profile: &str) -> String {
1315        let dtarget = self.dest_target(profile).unwrap_or_default();
1316        format!(
1317            "destination profile `{profile}` has no `{dtarget}` target: not delivered, output stays in target/"
1318        )
1319    }
1320
1321    /// Deliver every file to one destination. `Ok(None)`: its profile has no output for the
1322    /// active target, so nothing was sent.
1323    fn deliver_one(&mut self, d: &RenderedDest) -> Result<Option<String>, Fail> {
1324        if self.project.profiles.get(Role::Destination, &d.profile).is_none()
1325            && !self.project.profiles.is_builtin_local(&d.profile)
1326        {
1327            return Err(format!(
1328                "destination profile `{}` isn't in profiles.yml",
1329                d.profile
1330            ));
1331        }
1332        let Some((_, out)) = self.dest_output(&d.profile) else {
1333            let note = self.skip_note(&d.profile);
1334            self.ui.step(Level::Info, "Kept", &note, None);
1335            return Ok(None);
1336        };
1337        let kind = out.kind.clone();
1338        let connection = render_connection(out)?;
1339        let targets: Vec<DeliveryFile> = self
1340            .files
1341            .iter()
1342            .map(|(f, _)| {
1343                let name = f.file_name().unwrap().to_string_lossy().to_string();
1344                let r = d.path.as_deref().map(|r| {
1345                    if self.files.len() == 1 {
1346                        r.to_string()
1347                    } else {
1348                        match r.rfind(['/', '\\']) {
1349                            Some(i) => format!("{}{name}", &r[..=i]),
1350                            None => name.clone(),
1351                        }
1352                    }
1353                });
1354                DeliveryFile {
1355                    local_path: f.to_string_lossy().to_string(),
1356                    remote_path: r,
1357                }
1358            })
1359            .collect();
1360        let mut locations = Vec::new();
1361        if kind == LOCAL_TYPE {
1362            if let Some(k) = d.options.keys().next() {
1363                return Err(format!(
1364                    "the local destination takes no options, but `{}` has `{k}`; check the key's spelling",
1365                    d.profile
1366                ));
1367            }
1368            for (i, f) in targets.iter().enumerate() {
1369                let t = Instant::now();
1370                let r = f
1371                    .remote_path
1372                    .as_ref()
1373                    .ok_or("the local destination needs `output.destination.path`")?;
1374                let dst = self.project.root.join(r);
1375                if let Some(parent) = dst.parent() {
1376                    std::fs::create_dir_all(parent)
1377                        .map_err(|e| format!("can't create {}: {e}", parent.display()))?;
1378                }
1379                std::fs::copy(&f.local_path, &dst).map_err(|e| {
1380                    format!(
1381                        "delivery to {} failed: {e}; the output is still in target/",
1382                        dst.display()
1383                    )
1384                })?;
1385                let loc = dst.to_string_lossy().to_string();
1386                self.ui.step(Level::Debug, "Delivered", &loc, Some(t.elapsed()));
1387                self.files[i].1.get_or_insert_with(|| loc.clone());
1388                locations.push(loc);
1389            }
1390            return Ok(Some(locations.join(", ")));
1391        }
1392        let failed = |e: dre_protocol::host::HostError| {
1393            format!("delivery through `{kind}` failed: {e}; the output is still in target/")
1394        };
1395        let plugin = find_plugin(self.project, PluginKind::Destination, &kind)?;
1396        let mut p = plugin
1397            .start(self.ui.plugin_log(), Some(&self.project.root))
1398            .map_err(|e| e.to_string())?;
1399        let batches: Vec<Vec<usize>> = if targets.len() > 1 && p.has(CAP_MULTI_FILE) {
1400            vec![(0..targets.len()).collect()]
1401        } else {
1402            (0..targets.len()).map(|i| vec![i]).collect()
1403        };
1404        for batch in batches {
1405            let t = Instant::now();
1406            let files: Vec<DeliveryFile> = batch.iter().map(|&i| targets[i].clone()).collect();
1407            let loc = p
1408                .deliver_files(&files, connection.clone(), d.options.clone())
1409                .map_err(failed)?;
1410            self.ui.step(Level::Debug, "Delivered", &loc, Some(t.elapsed()));
1411            for i in batch {
1412                self.files[i].1.get_or_insert_with(|| loc.clone());
1413            }
1414            locations.push(loc);
1415        }
1416        let _ = p.close();
1417        Ok(Some(locations.join(", ")))
1418    }
1419
1420    fn snapshot(&self) -> Json {
1421        let sets: Vec<Json> = self
1422            .produced
1423            .iter()
1424            .map(|p| {
1425                json!({
1426                    "name": p.name,
1427                    "columns": p.schema.fields().iter().map(|f| json!({"name": f.name(), "type": f.data_type().to_string()})).collect::<Vec<_>>(),
1428                })
1429            })
1430            .collect();
1431        json!({ "result_sets": sets })
1432    }
1433
1434    fn schema_drift(&self) -> Vec<String> {
1435        let path = self.schema_dir.join("last_success.json");
1436        let Ok(prev) = std::fs::read_to_string(&path) else {
1437            return Vec::new();
1438        };
1439        let Ok(prev) = serde_json::from_str::<Json>(&prev) else {
1440            return Vec::new();
1441        };
1442        let cols = |v: &Json| -> BTreeMap<String, Vec<(String, String)>> {
1443            v["result_sets"]
1444                .as_array()
1445                .into_iter()
1446                .flatten()
1447                .map(|s| {
1448                    let c = s["columns"]
1449                        .as_array()
1450                        .into_iter()
1451                        .flatten()
1452                        .map(|c| {
1453                            (
1454                                c["name"].as_str().unwrap_or("").to_string(),
1455                                c["type"].as_str().unwrap_or("").to_string(),
1456                            )
1457                        })
1458                        .collect();
1459                    (s["name"].as_str().unwrap_or("").to_string(), c)
1460                })
1461                .collect()
1462        };
1463        let (old, new) = (cols(&prev), cols(&self.snapshot()));
1464        let mut drift = Vec::new();
1465        for (name, oc) in &old {
1466            let Some(nc) = new.get(name) else {
1467                drift.push(format!("result set `{name}` is gone"));
1468                continue;
1469            };
1470            let om: BTreeMap<_, _> = oc.iter().cloned().collect();
1471            let nm: BTreeMap<_, _> = nc.iter().cloned().collect();
1472            for (c, t) in &om {
1473                match nm.get(c) {
1474                    None => drift.push(format!("`{name}`: column `{c}` removed")),
1475                    Some(t2) if t2 != t => {
1476                        drift.push(format!("`{name}`: column `{c}` changed type from {t} to {t2}"))
1477                    }
1478                    _ => {}
1479                }
1480            }
1481            for c in nm.keys().filter(|c| !om.contains_key(*c)) {
1482                drift.push(format!("`{name}`: column `{c}` added"));
1483            }
1484        }
1485        for name in new.keys().filter(|n| !old.contains_key(*n)) {
1486            drift.push(format!("result set `{name}` is new"));
1487        }
1488        drift
1489    }
1490
1491    fn write_snapshot(&self) -> std::io::Result<()> {
1492        std::fs::create_dir_all(&self.schema_dir)?;
1493        std::fs::write(
1494            self.schema_dir.join("last_success.json"),
1495            serde_json::to_string_pretty(&self.snapshot())?,
1496        )
1497    }
1498
1499    fn write_results(&self, status: &Status, error: Option<&str>) -> std::io::Result<()> {
1500        std::fs::create_dir_all(&self.run_dir)?;
1501        let outputs: Vec<Json> = self
1502            .files
1503            .iter()
1504            .map(|(f, delivered)| {
1505                json!({
1506                    "path": record_path(self.project, f),
1507                    "size": std::fs::metadata(f).map(|m| m.len()).unwrap_or(0),
1508                    "delivered_to": delivered,
1509                })
1510            })
1511            .collect();
1512        let results = json!({
1513            "report": self.report.name,
1514            "set": self.b.set,
1515            "binding": self.b.dir_name(),
1516            "managed": self.report.managed,
1517            "profile": self.b.profile,
1518            "target": if self.target.is_empty() { Json::Null } else { json!(self.target) },
1519            "schedule": self.opts.schedule,
1520            "schedule_vars": self.schedule_vars,
1521            "vars": self.rendered_vars,
1522            "run_date": self.date.to_string(),
1523            "scheduled_at": self.opts.scheduled_at.map(rfc3339),
1524            "timezone": self.calendar.tz.name(),
1525            "params": self.opts.params(self.date),
1526            "status": status,
1527            "error": error,
1528            "preview": self.opts.preview.is_some(),
1529            "row_limit": self.opts.preview,
1530            "started_at": self.started_at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1531            "duration_ms": self.started.elapsed().as_millis() as u64,
1532            "result_sets": self.produced.iter().map(|p| json!({
1533                "name": p.name,
1534                "query": p.query,
1535                "rows": p.rows,
1536                "columns": p.schema.fields().iter().map(|f| f.name().clone()).collect::<Vec<_>>(),
1537            })).collect::<Vec<_>>(),
1538            "outputs": outputs,
1539            "delivery": self.delivery_note,
1540            "deliveries": self.deliveries,
1541            "schema_drift": self.drift,
1542            "target_path": self.project.target_dir,
1543            "manifest_checksum": self.opts.manifest_checksum,
1544        });
1545        std::fs::write(
1546            self.run_dir.join("run_results.json"),
1547            (crate::secrets::to_json_pretty(&results)? + "\n").as_bytes(),
1548        )
1549    }
1550
1551    /// `dre validate --live`: execute temp creates, `check` everything else.
1552    fn live_check(
1553        &mut self,
1554        session: &Arc<Mutex<Session>>,
1555        statements: &[Statement],
1556        kind: &str,
1557    ) -> Result<(), Fail> {
1558        let mut s = session.lock().unwrap();
1559        let p = s.get()?;
1560        if !p.has(dre_protocol::CAP_CHECK) {
1561            self.ui.warn(&format!(
1562                "  not checkable: the `{kind}` source plugin can't check statements without running them"
1563            ));
1564            return Ok(());
1565        }
1566        let mut failures = Vec::new();
1567        let mut unexecuted_setup: Option<(PathBuf, usize)> = None;
1568        let sql_log = self.ui.sql_log();
1569        for st in statements {
1570            let verb = if st.kind == StatementKind::TempCreate {
1571                ""
1572            } else {
1573                " (check)"
1574            };
1575            sql_log(&format!("{}:{}{verb}", st.file.display(), st.line), &st.text);
1576            if st.kind == StatementKind::TempCreate {
1577                if let Err(e) = p.execute(&st.text, None, |_, _| Ok(())) {
1578                    failures.push(format!("{}:{}: {e}", st.file.display(), st.line));
1579                }
1580                continue;
1581            }
1582            match p.check(&st.text) {
1583                Ok(()) => {}
1584                Err(e) => {
1585                    let mut msg = format!("{}:{}: {e}", st.file.display(), st.line);
1586                    if let Some((f, l)) = &unexecuted_setup {
1587                        msg.push_str(&format!(
1588                            " (may be a false positive: the setup statement at {}:{l} wasn't executed during the check)",
1589                            f.display()
1590                        ));
1591                    }
1592                    failures.push(msg);
1593                }
1594            }
1595            if st.kind == StatementKind::Other {
1596                unexecuted_setup = Some((st.file.clone(), st.line));
1597            }
1598        }
1599        if failures.is_empty() {
1600            Ok(())
1601        } else {
1602            Err(failures.join("\n    "))
1603        }
1604    }
1605}
1606
1607/// The source session, started on first use (rendering may need it for `run_query()`).
1608struct Session {
1609    path: Result<crate::plugins::Located, String>,
1610    connection: Result<JsonMap<String, Json>, String>,
1611    cwd: PathBuf,
1612    log: LogSink,
1613    /// Unmanaged reports run read-only unless they create temp objects.
1614    unmanaged: bool,
1615    read_only: bool,
1616    /// A lookup was loaded into a temp table, so the session must stay open as it is.
1617    loaded: bool,
1618    proc_: Option<PluginProcess>,
1619}
1620
1621impl Session {
1622    fn new(
1623        path: Result<crate::plugins::Located, String>,
1624        connection: Result<JsonMap<String, Json>, String>,
1625        cwd: PathBuf,
1626        log: LogSink,
1627        unmanaged: bool,
1628    ) -> Session {
1629        Session {
1630            path,
1631            connection,
1632            cwd,
1633            log,
1634            unmanaged,
1635            read_only: unmanaged,
1636            loaded: false,
1637            proc_: None,
1638        }
1639    }
1640
1641    fn want_read_only(&mut self, ro: bool) {
1642        if self.read_only != ro {
1643            // Reopen if a session was already started in the other mode.
1644            self.close();
1645            self.read_only = ro;
1646        }
1647    }
1648
1649    fn get(&mut self) -> Result<&mut PluginProcess, String> {
1650        if self.proc_.is_none() {
1651            let plugin = self.path.clone()?;
1652            let connection = self.connection.clone()?;
1653            let mut p = plugin
1654                .start(self.log.clone(), Some(&self.cwd))
1655                .map_err(|e| e.to_string())?;
1656            let ro = self.unmanaged && self.read_only && p.has(CAP_READ_ONLY);
1657            p.open(connection, ro)
1658                .map_err(|e| format!("can't open the source connection: {e}"))?;
1659            self.proc_ = Some(p);
1660        }
1661        Ok(self.proc_.as_mut().unwrap())
1662    }
1663
1664    fn close(&mut self) {
1665        if let Some(p) = self.proc_.take() {
1666            let _ = p.close();
1667        }
1668    }
1669}
1670
1671struct SessionRunner(Arc<Mutex<Session>>, LogSink);
1672
1673impl QueryRunner for SessionRunner {
1674    fn run_query(&self, sql: &str, max_rows: u64) -> Result<QueryRows, String> {
1675        let mut s = self.0.lock().map_err(|_| "session lock poisoned".to_string())?;
1676        // An unmanaged report may only read, and run_query() runs before the file's own
1677        // statements are checked, so it gets the same rule up front.
1678        if s.unmanaged
1679            && let Some(bad) = sqlsplit::split(sql)
1680                .into_iter()
1681                .find(|st| sqlsplit::classify(&st.text) != StatementKind::Read)
1682        {
1683            return Err(format!(
1684                "run_query() in an unmanaged report may only read, but got `{}`; give the report a YAML to declare it",
1685                dre_protocol::util::summarize(&bad.text, 60)
1686            ));
1687        }
1688        (self.1)("run_query()", sql);
1689        let p = s.get()?;
1690        let mut out = QueryRows::default();
1691        let mut too_many = false;
1692        let exec = p
1693            .execute(sql, Some(max_rows + 1), |schema, batch| {
1694                if out.columns.is_empty() {
1695                    out.columns = schema.fields().iter().map(|f| f.name().clone()).collect();
1696                }
1697                if out.rows.len() as u64 + batch.num_rows() as u64 > max_rows {
1698                    too_many = true;
1699                    return Ok(());
1700                }
1701                out.rows.extend(crate::values::batch_rows(&batch));
1702                Ok(())
1703            })
1704            .map_err(|e| format!("run_query() failed: {e}"))?;
1705        if too_many {
1706            return Err(format!(
1707                "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"
1708            ));
1709        }
1710        if let Execution::Result { schema, .. } = exec
1711            && out.columns.is_empty()
1712        {
1713            out.columns = schema.fields().iter().map(|f| f.name().clone()).collect();
1714        }
1715        Ok(out)
1716    }
1717
1718    fn columns(&self, sql: &str) -> Result<Vec<Column>, String> {
1719        let mut s = self.0.lock().map_err(|_| "session lock poisoned".to_string())?;
1720        (self.1)("columns()", sql);
1721        let p = s.get()?;
1722        let mut schema: Option<SchemaRef> = None;
1723        let exec = p
1724            .execute(sql, Some(1), |sch, _| {
1725                schema.get_or_insert_with(|| sch.clone());
1726                Ok(())
1727            })
1728            .map_err(|e| e.to_string())?;
1729        if let Execution::Result { schema: sch, .. } = exec {
1730            schema.get_or_insert(sch);
1731        }
1732        let schema = schema.ok_or("the query returned no result set")?;
1733        Ok(schema
1734            .fields()
1735            .iter()
1736            .map(|f| Column {
1737                name: f.name().clone(),
1738                data_type: f.data_type().to_string(),
1739            })
1740            .collect())
1741    }
1742
1743    fn load(&self, name: &str, table: &Table) -> Result<Option<(String, Option<String>)>, String> {
1744        let mut s = self.0.lock().map_err(|_| "session lock poisoned".to_string())?;
1745        // A temp table is allowed even in an unmanaged report; it needs a writable session.
1746        s.want_read_only(false);
1747        if !s.get()?.has(CAP_LOAD) {
1748            return Ok(None);
1749        }
1750        let batch = table.to_batch()?;
1751        let schema = batch.schema();
1752        (self.1)(
1753            &format!("lookup `{name}`"),
1754            &format!(
1755                "-- {} rows loaded through the plugin's `load` request",
1756                batch.num_rows()
1757            ),
1758        );
1759        let r = s.get()?.load(name, &schema, [batch]).map_err(|e| e.to_string())?;
1760        s.loaded = true;
1761        Ok(Some(r))
1762    }
1763}
1764
1765/// Render every string inside a destination option value.
1766fn render_json(renderer: &Renderer, file: &Path, v: &Json) -> Result<Json, RenderError> {
1767    Ok(match v {
1768        Json::String(s) => Json::String(renderer.render(file, s)?),
1769        Json::Array(a) => Json::Array(
1770            a.iter()
1771                .map(|v| render_json(renderer, file, v))
1772                .collect::<Result<_, _>>()?,
1773        ),
1774        Json::Object(o) => Json::Object(
1775            o.iter()
1776                .map(|(k, v)| Ok((k.clone(), render_json(renderer, file, v)?)))
1777                .collect::<Result<_, RenderError>>()?,
1778        ),
1779        other => other.clone(),
1780    })
1781}
1782
1783/// Render `env_var()` (and only that) inside a profile output's string fields.
1784fn render_connection(output: &ProfileTarget) -> Result<JsonMap<String, Json>, String> {
1785    let mut env = minijinja::Environment::new();
1786    env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
1787    env.add_function("env_var", |name: String, default: Option<String>| -> Result<String, minijinja::Error> {
1788        std::env::var(&name).ok().or(default).ok_or_else(|| {
1789            minijinja::Error::new(
1790                minijinja::ErrorKind::UndefinedError,
1791                format!("`env_var('{name}')`: environment variable `{name}` is not set and no default is given"),
1792            )
1793        })
1794    });
1795    fn walk(env: &minijinja::Environment<'_>, v: &Json) -> Result<Json, String> {
1796        Ok(match v {
1797            Json::String(s) if crate::preflight::is_templated(s) => Json::String(
1798                env.render_str(s, ())
1799                    .map_err(|e| format!("profiles.yml: {}", e.detail().unwrap_or("render error")))?,
1800            ),
1801            Json::Array(a) => Json::Array(a.iter().map(|x| walk(env, x)).collect::<Result<_, _>>()?),
1802            Json::Object(o) => Json::Object(
1803                o.iter()
1804                    .map(|(k, x)| Ok((k.clone(), walk(env, x)?)))
1805                    .collect::<Result<_, String>>()?,
1806            ),
1807            other => other.clone(),
1808        })
1809    }
1810    match walk(&env, &Json::Object(output.fields.clone()))? {
1811        Json::Object(o) => Ok(o),
1812        _ => unreachable!(),
1813    }
1814}
1815
1816/// Locate the plugin for a declared type among the project's packages, honouring `dre.lock`
1817/// pins and declared constraints.
1818pub fn find_plugin(
1819    project: &Project,
1820    kind: PluginKind,
1821    name: &str,
1822) -> Result<crate::plugins::Located, crate::plugins::LocateError> {
1823    crate::plugins::locate(project, &crate::project::PluginId::new(kind, name))
1824}
1825
1826/// `target` and `profile()` from `profiles.yml`. A field is secret when the plugin's `describe`
1827/// says so; when the plugin can't be asked, when its name looks like one; and always when its
1828/// value comes from a `DRE_SECRET_*` variable.
1829struct ProfileConnections {
1830    profiles: Profiles,
1831    /// The Binding's source profile.
1832    source: Option<String>,
1833    /// `--target`.
1834    target: Option<String>,
1835    root: PathBuf,
1836    plugins: Vec<crate::project::PluginRequirement>,
1837    log: LogSink,
1838}
1839
1840/// Secret field names by `(project, role, type)`, `None` when `describe` couldn't be asked:
1841/// each plugin is asked once per process, not once per Binding.
1842static DESCRIBED: std::sync::LazyLock<Mutex<BTreeMap<DescribeKey, SecretFields>>> =
1843    std::sync::LazyLock::new(Mutex::default);
1844
1845/// `(project root, role, plugin type)`.
1846type DescribeKey = (PathBuf, Role, String);
1847
1848type SecretFields = Option<Vec<String>>;
1849
1850const SECRET_NAME_WORDS: &[&str] = &["password", "secret", "token", "key", "credential"];
1851
1852impl ProfileConnections {
1853    fn view(&self, role: Role, name: &str) -> Result<Connection, String> {
1854        let (target, out): (String, ProfileTarget) = match self.profiles.get(role, name) {
1855            Some(p) => {
1856                let t = self
1857                    .target
1858                    .as_ref()
1859                    .filter(|t| p.targets.contains_key(t.as_str()))
1860                    .unwrap_or(&p.target);
1861                let o = p
1862                    .targets
1863                    .get(t)
1864                    .ok_or_else(|| format!("profile `{name}` has no `{t}` target"))?;
1865                (t.clone(), o.clone())
1866            }
1867            None if role == Role::Destination && self.profiles.is_builtin_local(name) => {
1868                ("default".into(), BUILTIN_LOCAL.clone())
1869            }
1870            None => {
1871                return Err(format!(
1872                    "no {} profile `{name}` in {}",
1873                    role.as_str(),
1874                    self.profiles.path.display()
1875                ));
1876            }
1877        };
1878        let mut secrets: Vec<String> = out
1879            .fields
1880            .iter()
1881            .filter(|(_, v)| v.as_str().is_some_and(|s| s.contains(crate::secrets::PREFIX)))
1882            .map(|(k, _)| k.clone())
1883            .collect();
1884        match self.described(role, &out.kind) {
1885            Some(named) => secrets.extend(named),
1886            None => secrets.extend(
1887                out.fields
1888                    .keys()
1889                    .filter(|k| {
1890                        let k = k.to_lowercase();
1891                        SECRET_NAME_WORDS.iter().any(|w| k.contains(w))
1892                    })
1893                    .cloned(),
1894            ),
1895        }
1896        let fields = render_connection(&out)?;
1897        Ok(Connection {
1898            profile: name.to_string(),
1899            target,
1900            kind: out.kind.clone(),
1901            fields,
1902            secrets,
1903        })
1904    }
1905
1906    /// The fields the plugin marks secret, asked once per plugin type.
1907    fn described(&self, role: Role, kind: &str) -> Option<Vec<String>> {
1908        let key = (self.root.clone(), role, kind.to_string());
1909        if let Some(v) = DESCRIBED.lock().unwrap().get(&key) {
1910            return v.clone();
1911        }
1912        let plugin_kind = match role {
1913            Role::Source => PluginKind::Source,
1914            Role::Destination => PluginKind::Destination,
1915        };
1916        let asked = (|| {
1917            if kind == LOCAL_TYPE {
1918                return Some(Vec::new());
1919            }
1920            let id = crate::project::PluginId::new(plugin_kind, kind);
1921            let plugin = crate::plugins::locate_in(&self.root, &self.plugins, &id).ok()?;
1922            let mut p = plugin.start(self.log.clone(), Some(&self.root)).ok()?;
1923            let fields = p.describe().ok();
1924            let _ = p.close();
1925            Some(fields?.into_iter().filter(|f| f.secret).map(|f| f.name).collect())
1926        })();
1927        DESCRIBED.lock().unwrap().insert(key, asked.clone());
1928        asked
1929    }
1930}
1931
1932impl Connections for ProfileConnections {
1933    fn source(&self) -> Result<Connection, String> {
1934        let name = self
1935            .source
1936            .as_deref()
1937            .ok_or("no source profile resolves for this Binding")?;
1938        self.view(Role::Source, name)
1939    }
1940
1941    fn profile(&self, name: &str, role: Option<&str>) -> Result<Connection, String> {
1942        let role = match role {
1943            Some("source") => Role::Source,
1944            Some(_) => Role::Destination,
1945            None => {
1946                let s = self.profiles.get(Role::Source, name).is_some();
1947                let d = self.profiles.get(Role::Destination, name).is_some();
1948                match (s, d) {
1949                    (true, true) => {
1950                        return Err(format!(
1951                            "`{name}` is both a source and a destination profile; say which with `profile('{name}', role='source')` or `role='destination'`"
1952                        ));
1953                    }
1954                    (true, false) => Role::Source,
1955                    _ => Role::Destination,
1956                }
1957            }
1958        };
1959        self.view(role, name)
1960    }
1961}
1962
1963fn check_sheet_name(n: &str) -> Result<(), String> {
1964    if n.is_empty() || n.chars().count() > 31 {
1965        return Err(format!("sheet name `{n}` must be 1 to 31 characters long"));
1966    }
1967    if let Some(c) = n.chars().find(|c| "[]:*?/\\".contains(*c)) {
1968        return Err(format!(
1969            "sheet name `{n}` contains `{c}`, which Excel doesn't allow in sheet names"
1970        ));
1971    }
1972    if n.starts_with('\'') || n.ends_with('\'') {
1973        return Err(format!("sheet name `{n}` can't start or end with an apostrophe"));
1974    }
1975    if n.eq_ignore_ascii_case("history") {
1976        return Err("`History` is reserved by Excel and can't be a sheet name".into());
1977    }
1978    Ok(())
1979}
1980
1981fn is_single_table(format: &str) -> bool {
1982    matches!(format, "csv" | "delimited" | "fixed_width" | "parquet")
1983}
1984
1985fn extension(format: &str) -> &str {
1986    match format {
1987        "delimited" | "fixed_width" => "txt",
1988        f => f,
1989    }
1990}
1991
1992fn split_ext(name: &str) -> (&str, &str) {
1993    match name.rfind('.') {
1994        Some(i) if i > 0 => (&name[..i], &name[i..]),
1995        _ => (name, ""),
1996    }
1997}
1998
1999fn rel(root: &Path, p: &Path) -> PathBuf {
2000    crate::slash(p.strip_prefix(root).unwrap_or(p))
2001}
2002
2003/// An instant as DRE records it: RFC 3339 in UTC, to the second.
2004pub fn rfc3339(t: chrono::DateTime<chrono::Utc>) -> String {
2005    t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
2006}
2007
2008/// How a generated file is recorded in `run_results.json`: relative to the project root when
2009/// it's inside it, else relative to the target path, so moving the target folder doesn't
2010/// change what's recorded.
2011fn record_path(project: &Project, p: &Path) -> PathBuf {
2012    match p.strip_prefix(&project.root) {
2013        Ok(r) => crate::slash(r),
2014        Err(_) => rel(&project.target_dir, p),
2015    }
2016}
2017
2018impl BindingRun<'_> {
2019    /// What this Binding produced, for its end-of-run line.
2020    fn summary_line(&self) -> String {
2021        let rows: u64 = self.produced.iter().map(|p| p.rows).sum();
2022        let mut parts = Vec::new();
2023        if !self.produced.is_empty() {
2024            let n = self.produced.len();
2025            parts.push(format!(
2026                "{n} result set{}, {} row{}",
2027                if n == 1 { "" } else { "s" },
2028                thousands(rows),
2029                if rows == 1 { "" } else { "s" }
2030            ));
2031        }
2032        let names: Vec<String> = self
2033            .files
2034            .iter()
2035            .map(|(f, _)| f.file_name().unwrap_or_default().to_string_lossy().to_string())
2036            .collect();
2037        if !names.is_empty() {
2038            parts.push(format!("→ {}", names.join(", ")));
2039        }
2040        // Every destination's location, not just the first one each file reached.
2041        let delivered: Vec<&str> = self
2042            .deliveries
2043            .iter()
2044            .filter_map(|d| d.get("location").and_then(Json::as_str))
2045            .collect();
2046        if !delivered.is_empty() {
2047            parts.push(format!("→ {}", delivered.join(", ")));
2048        } else if let Some(n) = &self.delivery_note
2049            && self.opts.preview.is_none()
2050            && !self.b.output.destinations.is_empty()
2051        {
2052            parts.push(format!("({n})"));
2053        }
2054        parts.join(" ")
2055    }
2056}
2057
2058/// `1234567` → `1,234,567`.
2059pub fn thousands(n: u64) -> String {
2060    let s = n.to_string();
2061    let mut out = String::new();
2062    for (i, c) in s.chars().enumerate() {
2063        if i > 0 && (s.len() - i).is_multiple_of(3) {
2064            out.push(',');
2065        }
2066        out.push(c);
2067    }
2068    out
2069}
2070
2071fn human_bytes(n: u64) -> String {
2072    match n {
2073        n if n < 1024 => format!("{n} B"),
2074        n if n < 1024 * 1024 => format!("{:.1} KB", n as f64 / 1024.0),
2075        n => format!("{:.1} MB", n as f64 / (1024.0 * 1024.0)),
2076    }
2077}
2078
2079/// `dre clean`: remove the target folder `t`.
2080pub fn clean(t: &Path) -> std::io::Result<bool> {
2081    if t.exists() {
2082        std::fs::remove_dir_all(t)?;
2083        Ok(true)
2084    } else {
2085        Ok(false)
2086    }
2087}