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