Skip to main content

dre_core/
project.rs

1//! Loading a DRE project directory into a resolved, validated project.
2//!
3//! Every YAML file in the project is parsed and classified by its shape, not its name. Report
4//! config merges by report name from anywhere, then resolves with one precedence rule:
5//! Binding > report YAML > folder config (deepest wins) > project defaults > built-in defaults.
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::path::{Path, PathBuf};
9use std::rc::Rc;
10
11use serde::Serialize;
12use serde_json::Value;
13use serde_json::{Map as JsonMap, Value as Json};
14
15/// A map as read from YAML (or merged from several files).
16type Mapping = JsonMap<String, Json>;
17
18use crate::codes::Code;
19use crate::dates::{WeekNumbering, WeekStart};
20use crate::diag::Diagnostics;
21use crate::lookups::{self, DEFAULT_INLINE_MAX_ROWS, LOOKUPS_DIR, Lookup};
22use crate::packages::{self, DispatchOrder, Package};
23
24/// Names DRE puts in every Jinja context; a package can't take one.
25const RESERVED_NAMES: &[&str] = &[
26    "run",
27    "var",
28    "env_var",
29    "run_query",
30    "columns",
31    "ref",
32    "lookup",
33    "dispatch",
34    "target",
35    "profile",
36    "date",
37    "datetime",
38    "date_range",
39    "month_of",
40    "quarter_of",
41    "year_of",
42    "week_of",
43    "period",
44    "raise_error",
45    "source",
46    "connection",
47    "destination",
48];
49use crate::config;
50use crate::config::de::{self, Loose};
51use crate::config::project::ProjectFile;
52use crate::config::report::{Output as OutputConfig, QueryItem, ReportFile, SetEntry, SetItem};
53use crate::profiles::{LOCAL_TYPE, Profiles, Role};
54use crate::yaml::YamlFile;
55use crate::{constraints, options, preflight, schedule, selector, sqlsplit};
56
57pub const PROJECT_FILE: &str = "dre_project.yml";
58pub const REPORTS_DIR: &str = "reports";
59pub const MACROS_DIR: &str = "macros";
60pub const TARGET_DIR: &str = "target";
61pub const LOGS_DIR: &str = "logs";
62pub use crate::plugins::DEPS_DIR;
63pub const DEFAULT_RUN_QUERY_MAX_ROWS: u64 = 10_000;
64
65pub const REPORT_KEYS: &[&str] = &[
66    "name",
67    "tags",
68    "queries",
69    "output",
70    "profile",
71    "sets",
72    "default_set",
73    "schedule",
74    "vars",
75    "timezone",
76    "locale",
77];
78/// Declares the project's plugin packages, in any project YAML file.
79const PLUGINS_KEY: &str = "plugins";
80pub const PLUGIN_KEYS: &[&str] = &[PLUGINS_KEY];
81/// Where plugins were declared before packages; now an error pointing at `plugins:`. (`sources:`
82/// is the dbt-style table declarations now; a list of plugin names there gets the same hint.)
83const OLD_PLUGIN_KEYS: &[&str] = &["destinations", "formats"];
84/// dbt-style source declarations, in any project YAML file.
85pub const SOURCES_KEY: &str = "sources";
86pub const PROJECT_KEYS: &[&str] = &[
87    "name",
88    "default_profile",
89    "default_output",
90    "format_options",
91    "default_set",
92    "vars",
93    "run_query_max_rows",
94    "lookup_inline_max_rows",
95    "flags",
96    "dispatch",
97    "mask_secrets",
98    "timezone",
99    "locale",
100    "week_start",
101    "week_numbering",
102    "reports",
103    SOURCES_KEY,
104    crate::target::KEY,
105];
106/// Keys of a schedules.yml entry besides its timing (`schedule::SCHEDULE_KEYS`).
107pub const SCHEDULE_ENTRY_KEYS: &[&str] = &[
108    "name", "select", "report", "set", "vars", "timezone", "enabled", "timing",
109];
110/// The timings file's name. Other YAML files of timings are recognised by their shape.
111pub const TIMINGS_FILE: &str = "timings.yml";
112pub const FOLDER_CONFIG_KEYS: &[&str] = &[
113    "+tags",
114    "+output",
115    "+profile",
116    "+schedule",
117    "+vars",
118    "+timezone",
119    "+locale",
120];
121pub const SET_ENTRY_KEYS: &[&str] = &[
122    "name",
123    "profile",
124    "vars",
125    "exclude",
126    "queries",
127    "tab_names",
128    "output",
129    "schedule",
130    "locale",
131];
132pub const QUERY_ENTRY_KEYS: &[&str] = &[
133    "query", "profile", "tab", "tab_name", "anchor", "header", "columns", "autofit", "style",
134];
135/// The shared output keys that don't belong to one format: they survive a layer changing `format`.
136const FORMAT_INDEPENDENT_KEYS: &[&str] = &["name", "queries", "when", "destination", "template"];
137/// The keys of a destination entry core reads; every other key is the plugin's.
138pub const DESTINATION_KEYS: &[&str] = &["profile", "path", "attach"];
139pub const OUTPUT_SHARED_KEYS: &[&str] = &[
140    "name",
141    "format",
142    "queries",
143    "when",
144    "destination",
145    "template",
146    "extension",
147];
148/// Keys of one `output.template.bindings` entry.
149pub const TEMPLATE_BINDING_KEYS: &[&str] = &[
150    "sheet",
151    "query",
152    "result_index",
153    "anchor",
154    "header",
155    "columns",
156    "cell",
157    "value",
158    "column",
159];
160/// Keys of a `plugins:` entry written as a map.
161pub const PLUGIN_ENTRY_KEYS: &[&str] = &["name", "version", "github", "local", "registry"];
162
163// ---------------------------------------------------------------------------------------------
164// The resolved project: the stable contract every later consumer uses.
165// ---------------------------------------------------------------------------------------------
166
167#[derive(Debug, Clone, Serialize)]
168pub struct Project {
169    pub name: String,
170    #[serde(skip)]
171    pub root: PathBuf,
172    /// The folder DRE writes its generated files to (compiled SQL, run outputs, schema
173    /// snapshots, `run_results.json`, the manifest): `target/` in the root by default.
174    #[serde(skip)]
175    pub target_dir: PathBuf,
176    /// Where the target path was set, for messages.
177    #[serde(skip)]
178    pub target_source: crate::target::Source,
179    /// The run's target (environment): `--target`, `DRE_TARGET`, else `dev`; `target.name` in
180    /// templates. Each profile's entry is [`crate::profiles::Profiles::target_of`].
181    #[serde(skip)]
182    pub target_name: String,
183    /// Where `target_name` came from.
184    #[serde(skip)]
185    pub target_from: crate::profiles::TargetSource,
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub default_profile: Option<String>,
188    /// Its line in dre_project.yml, for messages.
189    #[serde(skip)]
190    pub default_profile_line: Option<usize>,
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub default_set: Option<String>,
193    pub vars: JsonMap<String, Json>,
194    pub run_query_max_rows: u64,
195    /// How many runs of each Binding to keep in `target/run/` (the current one always stays).
196    #[serde(skip)]
197    pub keep_runs: usize,
198    /// `flags: run_timeout`: how long a run may take (off when unset).
199    #[serde(skip)]
200    pub run_timeout: Option<std::time::Duration>,
201    /// `format_options:`: per format, defaults under every output of that format.
202    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
203    pub format_options: BTreeMap<String, JsonMap<String, Json>>,
204    pub reports: Vec<Report>,
205    pub sets: BTreeMap<String, SetDef>,
206    /// The declared plugin packages.
207    pub plugins: Vec<PluginRequirement>,
208    /// Every plugin the project uses, checked against the declared packages once they're
209    /// installed ([`crate::plugins::check_uses`]).
210    #[serde(skip)]
211    pub plugin_uses: Vec<PluginUse>,
212    /// Some package's declarations conflict, so it's missing from `plugins`.
213    #[serde(skip)]
214    pub plugins_incomplete: bool,
215    #[serde(skip_serializing_if = "Vec::is_empty")]
216    pub schedules: Vec<ScheduleEntry>,
217    /// Named timings from timings.yml, which schedules use with `timing:`.
218    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
219    pub timings: BTreeMap<String, Timing>,
220    /// Macro files under `macros/`, relative to the root.
221    pub macros: Vec<PathBuf>,
222    /// Macro packages, each called through its name (`{{ dre_utils.x() }}`).
223    pub packages: Vec<Package>,
224    /// Mask `DRE_SECRET_*` values in logs and records (default true).
225    pub mask_secrets: bool,
226    /// The project's default `timezone:` (IANA name); runs default to UTC without one.
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub timezone: Option<String>,
229    /// The project's `locale:` for the number filters; `en` without one.
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub locale: Option<String>,
232    /// `week_start:` and `week_numbering:`, for the calendar functions.
233    #[serde(skip)]
234    pub week_start: WeekStart,
235    #[serde(skip)]
236    pub week_numbering: WeekNumbering,
237    /// `dispatch:` search orders, by macro namespace.
238    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
239    pub dispatch: DispatchOrder,
240    /// Every uniquely named `.sql` file under `reports/`, by basename: what `ref()` resolves.
241    #[serde(skip)]
242    pub sql: BTreeMap<String, PathBuf>,
243    /// Lookups under `lookups/`, which `ref()` also resolves.
244    #[serde(skip)]
245    pub lookups: BTreeMap<String, Lookup>,
246    /// A lookup with more rows than this is loaded into a temp table rather than inlined.
247    pub lookup_inline_max_rows: u64,
248    /// Every folder under `reports/`, as path segments.
249    #[serde(skip)]
250    pub folders: Vec<Vec<String>>,
251    /// The project's own files, relative to the root with forward slashes, sorted: every YAML
252    /// file (not a root `profiles.yml`) and everything under `reports/`, `macros/` and
253    /// `lookups/`.
254    #[serde(skip)]
255    pub files: Vec<PathBuf>,
256    /// Templates the load's parse pass couldn't render, by report: they make the report invalid
257    /// in the manifest. Its own run, compile or validate reports them.
258    #[serde(skip)]
259    pub parse_errors: BTreeMap<String, Vec<crate::Diagnostic>>,
260    /// What the load's parse pass rendered with: the target, `--var`, the run date and timezone.
261    #[serde(skip)]
262    pub inputs: crate::parse::Inputs,
263    /// Declared sources (dbt's `sources:`), by name.
264    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
265    pub sources: BTreeMap<String, SourceDef>,
266    #[serde(skip)]
267    pub profiles: Profiles,
268    /// Every setting and where its value came from.
269    #[serde(skip)]
270    pub settings: crate::settings::Settings,
271}
272
273impl Project {
274    pub fn report(&self, name: &str) -> Option<&Report> {
275        self.reports.iter().find(|r| r.name == name)
276    }
277
278    /// The run's target and where it came from.
279    pub fn run_target(&self) -> crate::profiles::RunTarget {
280        crate::profiles::RunTarget {
281            name: self.target_name.clone(),
282            from: self.target_from,
283        }
284    }
285}
286
287#[derive(Debug, Clone, Serialize)]
288pub struct Report {
289    pub name: String,
290    pub managed: bool,
291    /// The defining YAML (managed) or `.sql` (unmanaged), relative to the root.
292    pub file: PathBuf,
293    /// Folder under `reports/` holding `file`, as path segments.
294    pub folder: Vec<String>,
295    pub tags: Vec<String>,
296    pub queries: Vec<QueryEntry>,
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub default_set: Option<String>,
299    /// The report's `timezone:`, else its folder config's `+timezone`, else the project's.
300    #[serde(skip_serializing_if = "Option::is_none")]
301    pub timezone: Option<String>,
302    /// Whether the report declares `sets:` (a Binding per Set) or runs as a single Binding.
303    pub has_sets: bool,
304    pub bindings: Vec<Binding>,
305    /// Report-level resolution with no Set applied: the starting point for an ad hoc `--set`.
306    #[serde(skip)]
307    pub base: Binding,
308}
309
310impl Report {
311    pub fn binding(&self, set: &str) -> Option<&Binding> {
312        self.bindings.iter().find(|b| b.set.as_deref() == Some(set))
313    }
314}
315
316#[derive(Debug, Clone, PartialEq, Serialize)]
317pub struct QueryEntry {
318    pub query: String,
319    /// The `.sql` file, relative to the root.
320    pub path: PathBuf,
321    /// The query's own connection (`profile:` on its entry), as written: it may hold Jinja.
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub profile: Option<String>,
324    /// Whether this query's result becomes a tab (a sheet, or a file for single-table formats).
325    /// One .sql file makes at most one tab: its last statement's result. `tab: false` runs the
326    /// file only for its effects (temp views, `SET`s) and discards any result.
327    #[serde(skip_serializing_if = "is_true")]
328    pub tab: bool,
329    #[serde(skip_serializing_if = "Option::is_none")]
330    pub tab_name: Option<String>,
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub anchor: Option<String>,
333    #[serde(skip_serializing_if = "Option::is_none")]
334    pub header: Option<bool>,
335    /// xlsx: per result column, how to show it (`{format: "#,##0.00"}`).
336    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
337    pub columns: BTreeMap<String, ColumnOptions>,
338    /// xlsx: size this tab's columns from their content, over the output's `autofit`.
339    #[serde(skip_serializing_if = "Option::is_none")]
340    pub autofit: Option<bool>,
341    /// xlsx: how this tab looks, over the output's `style`.
342    #[serde(skip_serializing_if = "Option::is_none")]
343    pub style: Option<dre_protocol::style::SheetStyle>,
344}
345
346fn is_true(b: &bool) -> bool {
347    *b
348}
349
350/// The message for a `tab_name` list: one .sql file makes one tab.
351fn one_tab_per_file(name: &str) -> String {
352    format!(
353        "`tab_name` of `{name}` is a list, but one .sql file makes one tab; put each tab's query in its own .sql file and list each in `queries:`"
354    )
355}
356
357/// A Report paired with a Set (or the report's single default Binding): everything a run needs.
358#[derive(Debug, Clone, Default, Serialize)]
359pub struct Binding {
360    /// The Set name; `None` for a report without `sets:`.
361    pub set: Option<String>,
362    /// The inherited connection (Set, report, folder `+profile`, `default_profile`), as
363    /// written: it may hold Jinja. A query's own `profile:` or a source's overrides it.
364    #[serde(skip_serializing_if = "Option::is_none")]
365    pub profile: Option<String>,
366    /// Fully merged vars: project < folders < report < Set registry < inline Binding.
367    pub vars: JsonMap<String, Json>,
368    pub queries: Vec<QueryEntry>,
369    /// The `locale:` for the number filters: Set, report, folder `+locale`, project; `None`: `en`.
370    #[serde(skip_serializing_if = "Option::is_none")]
371    pub locale: Option<String>,
372    /// The Binding's outputs, in declared order (`output:` as a list, or one map). Each formats
373    /// its own subset of `queries` from the same run.
374    pub outputs: Vec<Output>,
375    /// Names of every `schedules.yml` entry that runs this Binding.
376    #[serde(skip_serializing_if = "Vec::is_empty")]
377    pub schedules: Vec<String>,
378    /// The parse pass for the load's inputs: each query's connection and sources. `None` until
379    /// the project is loaded, and for an ad hoc Binding.
380    #[serde(skip)]
381    pub parsed: Option<std::sync::Arc<crate::parse::ParsedBinding>>,
382    /// Where the inherited `profile` is written, for messages about its value.
383    #[serde(skip)]
384    pub profile_at: Option<ProfileAt>,
385}
386
387/// Where a `profile:` value is written: its file, line and key, as messages name it.
388#[derive(Debug, Clone, PartialEq, Eq)]
389pub struct ProfileAt {
390    pub file: PathBuf,
391    pub line: Option<usize>,
392    /// E.g. "`default_profile`", "`+profile` of folder `finance`".
393    pub key: String,
394}
395
396impl Binding {
397    /// Directory name for this Binding under `target/`.
398    pub fn dir_name(&self) -> &str {
399        self.set.as_deref().unwrap_or("default")
400    }
401
402    /// Every output's destinations, in order: the order of [`crate::parse::ParsedBinding::destinations`].
403    pub fn destinations(&self) -> impl Iterator<Item = &Destination> {
404        self.outputs.iter().flat_map(|o| o.destinations.iter())
405    }
406
407    /// The output named `name`.
408    pub fn output(&self, name: &str) -> Option<&Output> {
409        self.outputs.iter().find(|o| o.name.as_deref() == Some(name))
410    }
411}
412
413/// The built-in format that renders query results into a short headline.
414pub const MESSAGE_FORMAT: &str = "message";
415
416#[derive(Debug, Clone, Default, PartialEq, Serialize)]
417pub struct Output {
418    /// `name:`, so other outputs can refer to it; the default file name when set.
419    #[serde(skip_serializing_if = "Option::is_none")]
420    pub name: Option<String>,
421    pub format: String,
422    /// `queries:`: which of the Binding's queries this output formats; `None`: all of them.
423    #[serde(skip_serializing_if = "Option::is_none")]
424    pub queries: Option<Vec<String>>,
425    /// `when:`: a Jinja expression; the output is skipped when it's false.
426    #[serde(skip_serializing_if = "Option::is_none")]
427    pub when: Option<String>,
428    /// Format options: every key except the shared ones ([`OUTPUT_SHARED_KEYS`]).
429    pub options: JsonMap<String, Json>,
430    /// Where the output is delivered, in order. Empty: it stays in `target/`.
431    #[serde(skip_serializing_if = "Vec::is_empty")]
432    pub destinations: Vec<Destination>,
433    #[serde(skip_serializing_if = "Option::is_none")]
434    pub template: Option<Template>,
435    /// `extension:` replaces the format's file extension in the default file name
436    /// (`<report>.<extension>`); `Some("")` means no extension. `None`: the format's own.
437    #[serde(skip_serializing_if = "Option::is_none")]
438    pub extension: Option<String>,
439}
440
441impl Output {
442    /// Whether this output formats `query`'s result.
443    pub fn feeds(&self, query: &str) -> bool {
444        self.queries.as_ref().is_none_or(|q| q.iter().any(|n| n == query))
445    }
446
447    pub fn is_message(&self) -> bool {
448        self.format == MESSAGE_FORMAT
449    }
450
451    /// How messages name it: "output `x`", or "output 2" when unnamed.
452    pub fn label(&self, index: usize) -> String {
453        output_label(self.name.as_deref(), index)
454    }
455}
456
457#[derive(Debug, Clone, PartialEq, Serialize)]
458pub struct Destination {
459    pub profile: String,
460    #[serde(skip_serializing_if = "Option::is_none")]
461    pub path: Option<String>,
462    /// Plugin options: every key other than `profile`, `path` and `attach`, passed to the plugin
463    /// after rendering.
464    #[serde(skip_serializing_if = "JsonMap::is_empty")]
465    pub options: JsonMap<String, Json>,
466    /// `attach:` on a message output's entry: other outputs whose files go with the message.
467    #[serde(skip_serializing_if = "Vec::is_empty")]
468    pub attach: Vec<String>,
469}
470
471#[derive(Debug, Clone, PartialEq, Serialize)]
472pub struct Template {
473    pub file: String,
474    pub bindings: Vec<TemplateBinding>,
475}
476
477#[derive(Debug, Clone, PartialEq, Serialize)]
478pub struct TemplateBinding {
479    pub sheet: String,
480    #[serde(skip_serializing_if = "Option::is_none")]
481    pub query: Option<String>,
482    #[serde(skip_serializing_if = "Option::is_none")]
483    pub result_index: Option<usize>,
484    #[serde(skip_serializing_if = "Option::is_none")]
485    pub anchor: Option<String>,
486    #[serde(skip_serializing_if = "Option::is_none")]
487    pub header: Option<bool>,
488    #[serde(skip_serializing_if = "Option::is_none")]
489    pub columns: Option<Vec<String>>,
490    #[serde(skip_serializing_if = "Option::is_none")]
491    pub cell: Option<String>,
492    #[serde(skip_serializing_if = "Option::is_none")]
493    pub value: Option<String>,
494    #[serde(skip_serializing_if = "Option::is_none")]
495    pub column: Option<String>,
496}
497
498#[derive(Debug, Clone, Serialize)]
499pub struct SetDef {
500    #[serde(skip_serializing_if = "Option::is_none")]
501    pub profile: Option<String>,
502    pub vars: JsonMap<String, Json>,
503    #[serde(skip_serializing_if = "Option::is_none")]
504    pub locale: Option<String>,
505    /// Where it's declared, for messages.
506    #[serde(skip)]
507    pub file: PathBuf,
508    #[serde(skip)]
509    pub line: Option<usize>,
510}
511
512/// A dbt-style source: tables in one schema of one system.
513#[derive(Debug, Clone, Serialize)]
514pub struct SourceDef {
515    pub name: String,
516    #[serde(skip_serializing_if = "Option::is_none")]
517    pub description: Option<String>,
518    /// As written; `database`, `schema`, `profile` and table `identifier`s may hold Jinja.
519    #[serde(skip_serializing_if = "Option::is_none")]
520    pub database: Option<String>,
521    #[serde(skip_serializing_if = "Option::is_none")]
522    pub schema: Option<String>,
523    /// DRE's addition: the connection the source lives on.
524    #[serde(skip_serializing_if = "Option::is_none")]
525    pub profile: Option<String>,
526    pub quoting: QuotingDef,
527    pub tags: Vec<String>,
528    pub meta: JsonMap<String, Json>,
529    pub tables: Vec<SourceTableDef>,
530    /// Where it's declared, for messages.
531    #[serde(skip)]
532    pub file: PathBuf,
533    #[serde(skip)]
534    pub line: Option<usize>,
535}
536
537impl SourceDef {
538    pub fn table(&self, name: &str) -> Option<&SourceTableDef> {
539        self.tables.iter().find(|t| t.name == name)
540    }
541}
542
543#[derive(Debug, Clone, Serialize)]
544pub struct SourceTableDef {
545    pub name: String,
546    #[serde(skip_serializing_if = "Option::is_none")]
547    pub identifier: Option<String>,
548    #[serde(skip_serializing_if = "Option::is_none")]
549    pub description: Option<String>,
550    /// The table's own `quoting`, over the source's.
551    pub quoting: QuotingDef,
552    pub tags: Vec<String>,
553    pub meta: JsonMap<String, Json>,
554    pub columns: Vec<SourceColumn>,
555}
556
557#[derive(Debug, Clone, Serialize)]
558pub struct SourceColumn {
559    pub name: String,
560    #[serde(skip_serializing_if = "Option::is_none")]
561    pub description: Option<String>,
562    #[serde(skip_serializing_if = "Option::is_none")]
563    pub data_type: Option<String>,
564}
565
566/// `quoting:` as written: an unset part inherits (table from source), else isn't quoted.
567#[derive(Debug, Clone, Copy, Default, Serialize)]
568pub struct QuotingDef {
569    #[serde(skip_serializing_if = "Option::is_none")]
570    pub database: Option<bool>,
571    #[serde(skip_serializing_if = "Option::is_none")]
572    pub schema: Option<bool>,
573    #[serde(skip_serializing_if = "Option::is_none")]
574    pub identifier: Option<bool>,
575}
576
577impl QuotingDef {
578    /// `self` over `under`, unset parts false.
579    pub fn over(&self, under: &QuotingDef) -> crate::render::Quoting {
580        crate::render::Quoting {
581            database: self.database.or(under.database).unwrap_or(false),
582            schema: self.schema.or(under.schema).unwrap_or(false),
583            identifier: self.identifier.or(under.identifier).unwrap_or(false),
584        }
585    }
586}
587
588/// Source keys DRE reads.
589pub const SOURCE_KEYS: &[&str] = &[
590    "name",
591    "description",
592    "database",
593    "schema",
594    "quoting",
595    "tags",
596    "meta",
597    "tables",
598    "profile",
599];
600pub const SOURCE_TABLE_KEYS: &[&str] = &[
601    "name",
602    "identifier",
603    "description",
604    "quoting",
605    "tags",
606    "meta",
607    "columns",
608];
609pub const SOURCE_COLUMN_KEYS: &[&str] = &["name", "description", "data_type"];
610/// dbt source keys DRE accepts but doesn't use yet.
611pub const DBT_SOURCE_KEYS: &[&str] = &[
612    "loader",
613    "loaded_at_field",
614    "loaded_at_query",
615    "config",
616    "overrides",
617    "freshness",
618    "docs",
619];
620pub const DBT_SOURCE_TABLE_KEYS: &[&str] = &[
621    "loaded_at_field",
622    "loaded_at_query",
623    "tests",
624    "data_tests",
625    "freshness",
626    "external",
627    "config",
628    "docs",
629];
630pub const DBT_SOURCE_COLUMN_KEYS: &[&str] = &[
631    "meta",
632    "tags",
633    "quote",
634    "tests",
635    "data_tests",
636    "constraints",
637    "config",
638    "docs",
639    "granularity",
640];
641
642/// A plugin's kind; the same type the protocol uses.
643pub use dre_protocol::Kind as PluginKind;
644pub use dre_protocol::PluginId;
645pub use dre_protocol::msg::ColumnOptions;
646
647/// A declared plugin package.
648#[derive(Debug, Clone, Serialize)]
649pub struct PluginRequirement {
650    /// The package's name.
651    pub name: String,
652    /// The intersection of every declared constraint.
653    pub version: String,
654    /// Files declaring it, relative to the root.
655    pub declared_in: Vec<PathBuf>,
656    /// Where it's installed from.
657    #[serde(skip_serializing_if = "PluginSource::is_default")]
658    pub source: PluginSource,
659}
660
661/// A plugin the project uses: a profile's `type:` or an output's `format:`.
662#[derive(Debug, Clone, Serialize)]
663pub struct PluginUse {
664    pub plugin: PluginId,
665    pub file: Option<PathBuf>,
666    pub line: Option<usize>,
667    /// What uses it, for messages: "`type: s3` used by destination profile `reports`".
668    pub what: String,
669}
670
671/// Where a plugin comes from: `dependencies.yml`'s `registry:`, `github:` or `local:`.
672#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
673#[serde(rename_all = "snake_case")]
674pub enum PluginSource {
675    /// The default registry (`DRE_REGISTRY_URL`, else DRE's own).
676    #[default]
677    Default,
678    /// Another registry index: a URL or a file path.
679    Registry(String),
680    /// The GitHub Releases of `owner/repo`.
681    Github(String),
682    /// An executable on disk, relative to the project root. Used in place, never installed.
683    Local(String),
684}
685
686impl PluginSource {
687    pub fn is_default(&self) -> bool {
688        *self == PluginSource::Default
689    }
690
691    /// How `dre.lock` records it; `None` for the default registry.
692    pub fn lock_key(&self) -> Option<String> {
693        match self {
694            PluginSource::Default => None,
695            PluginSource::Registry(u) => Some(format!("registry:{u}")),
696            PluginSource::Github(r) => Some(format!("github:{r}")),
697            PluginSource::Local(p) => Some(format!("local:{p}")),
698        }
699    }
700}
701
702impl PluginRequirement {
703    pub fn req(&self) -> semver::VersionReq {
704        semver::VersionReq::parse(&self.version).unwrap_or(semver::VersionReq::STAR)
705    }
706}
707
708#[derive(Debug, Clone, Serialize)]
709pub struct ScheduleEntry {
710    pub name: String,
711    #[serde(skip_serializing_if = "Option::is_none")]
712    pub select: Option<String>,
713    #[serde(skip_serializing_if = "Option::is_none")]
714    pub report: Option<String>,
715    #[serde(skip_serializing_if = "Option::is_none")]
716    pub set: Option<String>,
717    /// The resolved timing: `cron`/`every`/`rrule`, `starting`, `at`, `except`, `also`.
718    pub schedule: JsonMap<String, Json>,
719    /// The `timings.yml` entry the timing comes from, when it's a shared one.
720    #[serde(skip_serializing_if = "Option::is_none")]
721    pub timing: Option<String>,
722    /// `enabled: false` pauses the schedule: it keeps its name but never fires.
723    pub enabled: bool,
724    /// Layered into `var()` when run with `--schedule <name>`, above the Binding's own vars.
725    #[serde(skip_serializing_if = "JsonMap::is_empty")]
726    pub vars: JsonMap<String, Json>,
727    /// The run's timezone under `--schedule <name>`, above the report's.
728    #[serde(skip_serializing_if = "Option::is_none")]
729    pub timezone: Option<String>,
730    /// Line in its schedules.yml, for messages.
731    #[serde(skip)]
732    pub location: (PathBuf, Option<usize>),
733}
734
735/// A timings.yml entry: a timing any schedule can use by name.
736#[derive(Debug, Clone, Serialize)]
737pub struct Timing {
738    /// `cron`/`every`/`rrule`, `starting`, `at`, `except`, `also`.
739    pub schedule: JsonMap<String, Json>,
740    #[serde(skip_serializing_if = "Option::is_none")]
741    pub timezone: Option<String>,
742    /// Line in its timings.yml, for messages.
743    #[serde(skip)]
744    pub location: (PathBuf, Option<usize>),
745}
746
747// ---------------------------------------------------------------------------------------------
748// Loading
749// ---------------------------------------------------------------------------------------------
750
751#[derive(Debug, Clone, Default)]
752pub struct LoadOptions {
753    /// `--profiles-dir`; falls back to `DRE_PROFILES_DIR`, the project directory, then `~/.dre`.
754    pub profiles_dir: Option<PathBuf>,
755    /// `--target`, above `DRE_TARGET`: the run's target, and every profile's entry.
756    pub target: Option<String>,
757    /// `DRE_RUN_DATE`, `DRE_RUN_AT` and `--timezone`/`DRE_TIMEZONE`: the parse pass renders
758    /// `run.*` as the run will.
759    pub date: Option<chrono::NaiveDate>,
760    pub scheduled_at: Option<chrono::DateTime<chrono::Utc>>,
761    pub timezone: Option<String>,
762    /// `--var name=value`, the top of every `var()` chain.
763    pub vars: BTreeMap<String, String>,
764    /// `--target-path`, above `DRE_TARGET_PATH` and `target_path:`.
765    pub target_path: Option<String>,
766    /// The settings the command line resolved (the run's timezone, date and scheduled
767    /// instant); the load adds the project's.
768    pub settings: crate::settings::Settings,
769}
770
771/// Parse and validate the project at `root`. Returns the resolved project when it could be
772/// built at all, plus every diagnostic found along the way.
773pub fn load(root: &Path, opts: &LoadOptions) -> (Option<Project>, Diagnostics) {
774    let mut l = Loader {
775        root: root.to_path_buf(),
776        target: crate::target::TargetPath::default_for(root),
777        target_inside: Some(PathBuf::from(TARGET_DIR)),
778        opts: opts.clone(),
779        diags: Diagnostics::default(),
780        format_options: Mapping::new(),
781    };
782    let project = l.run();
783    (project, l.diags)
784}
785
786struct Loader {
787    root: PathBuf,
788    /// The resolved target folder.
789    target: crate::target::TargetPath,
790    /// The target folder relative to the root, when it's inside the project: discovery skips it.
791    target_inside: Option<PathBuf>,
792    opts: LoadOptions,
793    diags: Diagnostics,
794    /// `format_options:` from the project file: per format, defaults under every output of it.
795    format_options: Mapping,
796}
797
798/// One report YAML fragment, before merging.
799struct Fragment {
800    file: Rc<YamlFile>,
801    name: String,
802    explicit_name: bool,
803    folder: Vec<String>,
804    map: Mapping,
805    /// The keys DRE reads with typed config.
806    typed: ReportFile,
807}
808
809/// The line of a top-level key in a YAML file.
810fn key_line(yf: &YamlFile, key: &str) -> Option<usize> {
811    yf.node.entry(key).map(|(k, _)| k.line).filter(|l| *l > 0)
812}
813
814/// Folder-level config from `reports:` in `dre_project.yml`.
815#[derive(Default, Clone)]
816struct FolderCfg {
817    tags: Vec<String>,
818    output: Option<Mapping>,
819    profile: Option<(String, Option<usize>)>,
820    vars: Option<Mapping>,
821    timezone: Option<String>,
822    locale: Option<String>,
823}
824
825struct Discovered {
826    yaml: Vec<PathBuf>,
827    /// `.sql` under `reports/`, relative paths.
828    sql: Vec<PathBuf>,
829    macros: Vec<PathBuf>,
830    /// Files under `lookups/`, relative paths.
831    lookups: Vec<PathBuf>,
832    folders: Vec<Vec<String>>,
833    /// Every project source file, relative: see [`Project::sources`].
834    sources: Vec<PathBuf>,
835}
836
837/// Where a report-level value came from, for error messages.
838struct Located<T> {
839    value: T,
840    file: Rc<YamlFile>,
841}
842
843impl Loader {
844    fn rel(&self, p: &Path) -> PathBuf {
845        crate::slash(p.strip_prefix(&self.root).unwrap_or(p))
846    }
847
848    fn run(&mut self) -> Option<Project> {
849        let pfile = self.root.join(PROJECT_FILE);
850        if !pfile.is_file() {
851            self.diags.error(
852                Code::ProjectFileMissing,
853                Some(PathBuf::from(PROJECT_FILE)),
854                None,
855                format!(
856                    "no {PROJECT_FILE} found in {}",
857                    crate::slash(&self.root).display()
858                ),
859            );
860            return None;
861        }
862        let pyaml = Rc::new(YamlFile::load(
863            &pfile,
864            PathBuf::from(PROJECT_FILE),
865            &mut self.diags,
866        )?);
867        let mut pfile_typed = match de::from_node::<Loose<ProjectFile>>(&pyaml.node) {
868            Ok(Loose::Ok(p)) => p,
869            Ok(Loose::Bad(_)) => {
870                self.diags.error(
871                    Code::InvalidProject,
872                    Some(pyaml.display.clone()),
873                    None,
874                    format!("{PROJECT_FILE} must be a map"),
875                );
876                return None;
877            }
878            Err(e) => {
879                self.diags.error(
880                    Code::InvalidProject,
881                    Some(pyaml.display.clone()),
882                    e.line,
883                    e.message,
884                );
885                return None;
886            }
887        };
888        let target_line = pfile_typed.target_path.as_ref().and_then(de::Located::line);
889        let project_target = match &pfile_typed.target_path {
890            None => None,
891            Some(de::Located {
892                value: Loose::Ok(t), ..
893            }) => Some(t.as_str()),
894            Some(_) => {
895                self.diags.error(
896                    Code::InvalidField,
897                    Some(PathBuf::from(PROJECT_FILE)),
898                    target_line,
899                    "`target_path` must be a path",
900                );
901                return None;
902            }
903        };
904        match crate::target::resolve(&self.root, self.opts.target_path.as_deref(), project_target) {
905            Ok(t) => {
906                self.target_inside = crate::target::inside(&self.root, &t.dir);
907                // Databricks Workspace files don't reliably keep a rename: run folders' `current`
908                // pointer and atomic writes need a Volume (or local disk).
909                if crate::slash(&t.dir).to_string_lossy().starts_with("/Workspace/") {
910                    let from_file =
911                        self.opts.target_path.is_none() && crate::settings::env(crate::target::ENV).is_none();
912                    self.diags.warning(
913                        Code::TargetPathOnWorkspace,
914                        from_file.then(|| PathBuf::from(PROJECT_FILE)),
915                        from_file.then_some(target_line).flatten(),
916                        format!(
917                            "the target path {} is in Databricks Workspace files, which don't reliably keep DRE's atomic writes and run pointer; use a Volume (`/Volumes/<catalog>/<schema>/<volume>/...`) or local disk",
918                            t.dir.display()
919                        ),
920                    );
921                }
922                self.target = t;
923            }
924            Err(e) => {
925                let from_file =
926                    self.opts.target_path.is_none() && crate::settings::env(crate::target::ENV).is_none();
927                self.diags.error(
928                    Code::InvalidTargetPath,
929                    from_file.then(|| PathBuf::from(PROJECT_FILE)),
930                    from_file.then_some(target_line).flatten(),
931                    e,
932                );
933                return None;
934            }
935        }
936        let folders_cfg = pfile_typed.reports.take();
937        let default_profile_line = pfile_typed.default_profile.as_ref().and_then(de::Located::line);
938        let mut project = self.parse_project_file(&pyaml, pfile_typed)?;
939
940        let found = self.discover();
941        project.folders = found.folders.clone();
942        project.macros = found.macros.clone();
943        project.files = found.sources.clone();
944        let declared = packages::declared(&self.root, &mut self.diags);
945        project.packages = packages::resolve(&self.root, &declared, &mut self.diags);
946        self.check_macro_namespaces(&project);
947
948        let (profiles_dir, found_by) =
949            crate::profiles::locate(self.opts.profiles_dir.as_deref(), Some(&self.root));
950        project.profiles = Profiles::load(&profiles_dir, found_by, &mut self.diags);
951        project.profiles.run = project.run_target();
952        self.record_settings(&mut project);
953
954        // Folder config needs the folder list to warn about folders that don't exist.
955        let folder_cfg = self.parse_folder_config(&pyaml.display, folders_cfg, &project.folders);
956        let mut used = Usage::default();
957        if let Some(p) = &project.default_profile {
958            used.connection(p, Some(pyaml.display.clone()), default_profile_line);
959        }
960        for cfg in folder_cfg.values() {
961            if let Some((p, line)) = &cfg.profile {
962                used.connection(p, Some(pyaml.display.clone()), *line);
963            }
964        }
965
966        // Classify every YAML file by shape.
967        let mut fragments = Vec::new();
968        let mut plugin_decls: Vec<(Rc<YamlFile>, Mapping)> = Vec::new();
969        let mut set_files = Vec::new();
970        let mut schedule_files = Vec::new();
971        let mut timing_files = Vec::new();
972        let mut source_decls: Vec<Rc<YamlFile>> = Vec::new();
973        plugin_decls.push((pyaml.clone(), pick(&pyaml.value, PLUGIN_KEYS)));
974        if pyaml.value.get(SOURCES_KEY).is_some() {
975            source_decls.push(pyaml.clone());
976        }
977        for path in &found.yaml {
978            let display = self.rel(path);
979            let Some(yf) = YamlFile::load(path, display.clone(), &mut self.diags) else {
980                continue;
981            };
982            let yf = Rc::new(yf);
983            if yf.value.get(SOURCES_KEY).is_some() {
984                source_decls.push(yf.clone());
985            }
986            self.classify(
987                yf,
988                &mut fragments,
989                &mut plugin_decls,
990                &mut set_files,
991                &mut schedule_files,
992                &mut timing_files,
993            );
994        }
995
996        project.sources = self.parse_sources(&source_decls);
997        project.sets = self.parse_sets(&set_files);
998        let sql_index = self.index_sql(&found.sql);
999        project.sql = sql_index
1000            .iter()
1001            .filter(|(_, paths)| paths.len() == 1)
1002            .map(|(name, paths)| (name.clone(), paths[0].clone()))
1003            .collect();
1004        // Queries resolve by bare basename; whatever no report YAML references is unmanaged.
1005        let mut referenced: BTreeSet<String> = fragments
1006            .iter()
1007            .filter_map(|f| f.map.get("queries").and_then(Value::as_array))
1008            .flatten()
1009            .filter_map(entry_name)
1010            .collect();
1011        project.lookups = lookups::discover(&self.root, &found.lookups, &mut self.diags);
1012        for (name, l) in &project.lookups {
1013            if let Some(sql) = sql_index.get(name) {
1014                self.diags.error(
1015                    Code::DuplicateRefName,
1016                    Some(l.file.clone()),
1017                    None,
1018                    format!(
1019                        "lookup `{name}` has the same name as {}; `ref()` names must be unique",
1020                        sql[0].display()
1021                    ),
1022                );
1023            }
1024            if let Err(e) = lookups::read(&self.root, l) {
1025                self.diags
1026                    .error(Code::InvalidLookup, Some(l.file.clone()), None, e);
1027            }
1028        }
1029        // A file used through `ref()` is shared SQL, not an unmanaged report.
1030        referenced.extend(self.check_refs(&found.sql, &found.macros, &sql_index, &project.lookups));
1031        let mut reports = self.merge_reports(fragments);
1032
1033        let mut built = Vec::new();
1034        for r in reports.iter_mut() {
1035            let queries = self.resolve_queries(r, &sql_index, &mut referenced);
1036            built.push((queries, r));
1037        }
1038        let mut resolved: Vec<Report> = Vec::new();
1039        for (queries, raw) in built {
1040            if let Some(rep) = self.resolve_managed(raw, queries, &project, &folder_cfg, &mut used) {
1041                resolved.push(rep);
1042            }
1043        }
1044        let managed_names: BTreeMap<String, PathBuf> = resolved
1045            .iter()
1046            .map(|r| (r.name.clone(), r.file.clone()))
1047            .collect();
1048        for (name, path) in &sql_index {
1049            if referenced.contains(name) || path.len() != 1 {
1050                continue;
1051            }
1052            let path = &path[0];
1053            if let Some(other) = managed_names.get(name) {
1054                self.diags.error(
1055                    Code::DuplicateReportName,
1056                    Some(path.clone()),
1057                    None,
1058                    format!(
1059                        "unmanaged report `{name}` has the same name as the report declared in {}; report names must be unique project-wide",
1060                        other.display()
1061                    ),
1062                );
1063                continue;
1064            }
1065            resolved.push(self.resolve_unmanaged(name, path, &project, &folder_cfg, &mut used));
1066        }
1067        resolved.sort_by(|a, b| a.name.cmp(&b.name));
1068        project.reports = resolved;
1069
1070        // Pre-flight first: its errors say a template's problem better than a failed parse does.
1071        self.preflight(&project, &used);
1072        self.parse_pass(&mut project, &mut used);
1073        self.check_profiles(&project, &used);
1074        self.check_plugins(&plugin_decls, &mut project, &used);
1075        let broken_timings;
1076        (project.timings, broken_timings) = self.parse_timings(&timing_files);
1077        project.schedules = self.parse_schedules(&schedule_files, &project, &broken_timings);
1078        self.apply_schedules(&mut project);
1079        self.check_template_files(&project);
1080
1081        Some(project)
1082    }
1083
1084    // -- dre_project.yml ----------------------------------------------------------------------
1085
1086    fn parse_project_file(&mut self, yf: &Rc<YamlFile>, pf: ProjectFile) -> Option<Project> {
1087        let file = Some(yf.display.clone());
1088        for (k, line) in [("destinations", &pf.destinations), ("formats", &pf.formats)] {
1089            if line.is_some() {
1090                self.old_plugin_key(yf, k);
1091            }
1092        }
1093        if let Some(t) = &pf.target {
1094            self.diags.error(
1095                Code::RemovedKey,
1096                file.clone(),
1097                t.line(),
1098                "`target` in dre_project.yml was removed in DRE 0.2.1: give each profile its default with `target:` in profiles.yml, or choose the run's target with DRE_TARGET or --target",
1099            );
1100        }
1101        for k in &pf.unknown.0 {
1102            self.diags.error(
1103                Code::UnknownKey,
1104                file.clone(),
1105                Some(k.line),
1106                format!("unknown key `{}`", k.name),
1107            );
1108        }
1109        let name = match pf.name {
1110            Some(de::Located {
1111                value: Loose::Ok(s), ..
1112            }) if !s.trim().is_empty() => Some(s),
1113            Some(n) => {
1114                self.diags.error(
1115                    Code::InvalidField,
1116                    file.clone(),
1117                    n.line(),
1118                    "`name` must be a non-empty string",
1119                );
1120                None
1121            }
1122            None => {
1123                self.diags.error(
1124                    Code::MissingField,
1125                    file.clone(),
1126                    None,
1127                    "missing required field `name`",
1128                );
1129                None
1130            }
1131        };
1132        let default_profile_line = pf.default_profile.as_ref().and_then(de::Located::line);
1133        let default_profile = self.typed_string(&yf.display, pf.default_profile, "default_profile");
1134        let run_target = crate::profiles::RunTarget::resolve(self.opts.target.as_deref());
1135        let default_set = self.typed_string(&yf.display, pf.default_set, "default_set");
1136        let vars = match pf.vars {
1137            None
1138            | Some(de::Located {
1139                value: Loose::Ok(None),
1140                ..
1141            }) => JsonMap::new(),
1142            Some(de::Located {
1143                value: Loose::Ok(Some(m)),
1144                ..
1145            }) => m,
1146            Some(v) => {
1147                self.diags
1148                    .error(Code::InvalidField, file.clone(), v.line(), "`vars` must be a map");
1149                JsonMap::new()
1150            }
1151        };
1152        let run_query_max_rows = match pf.run_query_max_rows {
1153            None => DEFAULT_RUN_QUERY_MAX_ROWS,
1154            Some(de::Located {
1155                value: Loose::Ok(n), ..
1156            }) if n > 0 => n,
1157            Some(v) => {
1158                self.diags.error(
1159                    Code::InvalidField,
1160                    file.clone(),
1161                    v.line(),
1162                    "`run_query_max_rows` must be a positive whole number",
1163                );
1164                DEFAULT_RUN_QUERY_MAX_ROWS
1165            }
1166        };
1167        let (run_timeout, keep_runs) = self.parse_flags(&file, pf.flags);
1168        let dispatch = self.parse_dispatch(&yf.display, pf.dispatch);
1169        match pf.format_options {
1170            None | Some(de::Located { value: Loose::Ok(None), .. }) => {}
1171            Some(de::Located { value: Loose::Ok(Some(f)), .. }) if f.iter().all(|(_, v)| v.ok().is_some()) => {
1172                self.format_options = f
1173                    .0
1174                    .into_iter()
1175                    .filter_map(|(k, v)| match v {
1176                        Loose::Ok(m) => Some((k.value, Value::Object(m))),
1177                        Loose::Bad(_) => None,
1178                    })
1179                    .collect();
1180            }
1181            Some(f) => self.diags.error(
1182                Code::InvalidField,
1183                file.clone(),
1184                f.line(),
1185                "`format_options` must map format names to their options, e.g. `delimited: {delimiter: \"|\"}`",
1186            ),
1187        }
1188        let mask_secrets = match pf.mask_secrets {
1189            None => true,
1190            Some(de::Located {
1191                value: Loose::Ok(b), ..
1192            }) => b,
1193            Some(v) => {
1194                self.diags.error(
1195                    Code::InvalidField,
1196                    file.clone(),
1197                    v.line(),
1198                    "`mask_secrets` must be true or false",
1199                );
1200                true
1201            }
1202        };
1203        let timezone = pf.timezone.and_then(|v| {
1204            let line = v.line();
1205            self.timezone_str(v.value.ok().map(String::as_str), &yf.display, line, "`timezone`")
1206        });
1207        let locale = pf.locale.and_then(|v| {
1208            let line = v.line();
1209            self.locale_str(v.value.ok().map(String::as_str), &yf.display, line, "`locale`")
1210        });
1211        let week_start = match pf.week_start {
1212            None => WeekStart::Monday,
1213            Some(de::Located {
1214                value: Loose::Ok(w), ..
1215            }) => w,
1216            Some(v) => {
1217                self.diags.error(
1218                    Code::InvalidField,
1219                    file.clone(),
1220                    v.line(),
1221                    "`week_start` must be `monday` or `sunday`",
1222                );
1223                WeekStart::Monday
1224            }
1225        };
1226        let week_numbering = match pf.week_numbering {
1227            None => WeekNumbering::Iso,
1228            Some(de::Located {
1229                value: Loose::Ok(w), ..
1230            }) => w,
1231            Some(v) => {
1232                self.diags.error(
1233                    Code::InvalidField,
1234                    file.clone(),
1235                    v.line(),
1236                    "`week_numbering` must be `iso` or `us`",
1237                );
1238                WeekNumbering::Iso
1239            }
1240        };
1241        let lookup_inline_max_rows = match pf.lookup_inline_max_rows {
1242            None => DEFAULT_INLINE_MAX_ROWS,
1243            Some(de::Located {
1244                value: Loose::Ok(n), ..
1245            }) => n,
1246            Some(v) => {
1247                self.diags.error(
1248                    Code::InvalidField,
1249                    file.clone(),
1250                    v.line(),
1251                    "`lookup_inline_max_rows` must be a whole number",
1252                );
1253                DEFAULT_INLINE_MAX_ROWS
1254            }
1255        };
1256        Some(Project {
1257            name: name?,
1258            root: self.root.clone(),
1259            target_dir: self.target.dir.clone(),
1260            target_source: self.target.source,
1261            target_name: run_target.name,
1262            target_from: run_target.from,
1263            default_profile_line,
1264            default_profile,
1265            default_set,
1266            vars,
1267            run_query_max_rows,
1268            keep_runs,
1269            run_timeout,
1270            reports: Vec::new(),
1271            sets: BTreeMap::new(),
1272            plugins: Vec::new(),
1273            plugin_uses: Vec::new(),
1274            plugins_incomplete: false,
1275            schedules: Vec::new(),
1276            timings: BTreeMap::new(),
1277            macros: Vec::new(),
1278            packages: Vec::new(),
1279            mask_secrets,
1280            timezone,
1281            locale,
1282            week_start,
1283            week_numbering,
1284            dispatch,
1285            sql: BTreeMap::new(),
1286            lookups: BTreeMap::new(),
1287            lookup_inline_max_rows,
1288            format_options: self
1289                .format_options
1290                .iter()
1291                .filter_map(|(k, v)| Some((k.clone(), v.as_object()?.clone())))
1292                .collect(),
1293            folders: Vec::new(),
1294            files: Vec::new(),
1295            inputs: crate::parse::Inputs::default(),
1296            parse_errors: BTreeMap::new(),
1297            sources: BTreeMap::new(),
1298            profiles: Profiles::default(),
1299            settings: self.opts.settings.clone(),
1300        })
1301    }
1302
1303    /// The settings the load resolved, beside the command line's.
1304    fn record_settings(&self, project: &mut Project) {
1305        use crate::settings::{self, Source};
1306        let s = &mut project.settings;
1307        let target = &project.profiles.run;
1308        let target_source = match target.from {
1309            crate::profiles::TargetSource::Flag => Source::Flag("--target"),
1310            crate::profiles::TargetSource::Env => Source::Env(settings::TARGET),
1311            crate::profiles::TargetSource::Default => Source::Default,
1312        };
1313        s.set("target", Some(target.name.clone()), target_source);
1314        let path_source = match self.target.source {
1315            crate::target::Source::Flag => Source::Flag("--target-path"),
1316            crate::target::Source::Env => Source::Env(settings::TARGET_PATH),
1317            crate::target::Source::Project => Source::Project(crate::target::KEY),
1318            crate::target::Source::Default => Source::Default,
1319        };
1320        s.set(
1321            "target_path",
1322            Some(crate::slash(&self.target.dir).display().to_string()),
1323            path_source,
1324        );
1325        let dir_source = match project.profiles.found_by {
1326            "--profiles-dir" => Source::Flag("--profiles-dir"),
1327            "DRE_PROFILES_DIR" => Source::Env(settings::PROFILES_DIR),
1328            "the project directory" => Source::Found("the project directory"),
1329            _ => Source::Default,
1330        };
1331        let dir = project.profiles.path.parent().map(|d| d.display().to_string());
1332        s.set("profiles_dir", dir, dir_source);
1333        let env_or_default = |var: &'static str| match settings::env(var) {
1334            Some(_) => Source::Env(var),
1335            None => Source::Default,
1336        };
1337        s.set(
1338            "plugins_dir",
1339            Some(
1340                crate::slash(&crate::plugins::plugins_dir(Some(&self.root)))
1341                    .display()
1342                    .to_string(),
1343            ),
1344            env_or_default(settings::PLUGINS_DIR),
1345        );
1346        s.set(
1347            "registry_url",
1348            Some(crate::manager::registry_url()),
1349            env_or_default(settings::REGISTRY_URL),
1350        );
1351    }
1352
1353    /// An optional string key of a typed file: its value, else an error.
1354    fn typed_string(
1355        &mut self,
1356        file: &Path,
1357        v: Option<de::Located<Loose<String>>>,
1358        key: &str,
1359    ) -> Option<String> {
1360        match v? {
1361            de::Located {
1362                value: Loose::Ok(s), ..
1363            } => Some(s),
1364            v => {
1365                self.diags.error(
1366                    Code::InvalidField,
1367                    Some(file.to_path_buf()),
1368                    v.line(),
1369                    format!("`{key}` must be a string"),
1370                );
1371                None
1372            }
1373        }
1374    }
1375
1376    /// A `timezone:` value: an IANA name, else an error at `line` (`v` is `None` when it isn't a
1377    /// string).
1378    fn timezone_str(
1379        &mut self,
1380        v: Option<&str>,
1381        file: &Path,
1382        line: Option<usize>,
1383        what: &str,
1384    ) -> Option<String> {
1385        let msg = match v {
1386            Some(s) => match crate::dates::parse_tz(s) {
1387                Ok(_) => return Some(s.to_string()),
1388                Err(e) => format!("{what}: {e}"),
1389            },
1390            None => format!("{what} must be a string, an IANA timezone name such as `Australia/Sydney`"),
1391        };
1392        self.diags
1393            .error(Code::InvalidTimezone, Some(file.to_path_buf()), line, msg);
1394        None
1395    }
1396
1397    /// A `locale:` value: a tag the number filters know (`de-DE`), else an error at `line` (`v`
1398    /// is `None` when it isn't a string).
1399    fn locale_str(
1400        &mut self,
1401        v: Option<&str>,
1402        file: &Path,
1403        line: Option<usize>,
1404        what: &str,
1405    ) -> Option<String> {
1406        let msg = match v {
1407            Some(s) => match crate::numbers::Locale::parse(s) {
1408                Ok(_) => return Some(s.to_string()),
1409                Err(e) => format!("{what}: {e}"),
1410            },
1411            None => format!("{what} must be a string, a locale such as `de-DE`"),
1412        };
1413        self.diags
1414            .error(Code::InvalidLocale, Some(file.to_path_buf()), line, msg);
1415        None
1416    }
1417
1418    /// `flags:`: settings for DRE itself. Each takes effect where it's used (downloads read
1419    /// `http_timeout`); returns `run_timeout`.
1420    fn parse_flags(
1421        &mut self,
1422        file: &Option<PathBuf>,
1423        v: Option<de::Located<Loose<config::project::Flags>>>,
1424    ) -> (Option<std::time::Duration>, usize) {
1425        let flags = match v {
1426            None => return (None, 1),
1427            Some(de::Located {
1428                value: Loose::Ok(f), ..
1429            }) => f,
1430            Some(v) => {
1431                self.diags.error(
1432                    Code::InvalidField,
1433                    file.clone(),
1434                    v.line(),
1435                    "`flags` must be a map",
1436                );
1437                return (None, 1);
1438            }
1439        };
1440        for k in &flags.unknown.0 {
1441            self.diags.error(
1442                Code::UnknownKey,
1443                file.clone(),
1444                Some(k.line),
1445                format!(
1446                    "unknown key `flags.{}`; flags are: http_timeout, keep_runs, run_timeout",
1447                    k.name
1448                ),
1449            );
1450        }
1451        match flags.http_timeout {
1452            None => {}
1453            Some(de::Located {
1454                value: Loose::Ok(n), ..
1455            }) if n > 0 => crate::manager::set_project_http_timeout(n),
1456            Some(v) => self.diags.error(
1457                Code::InvalidField,
1458                file.clone(),
1459                v.line(),
1460                "`flags.http_timeout` must be a positive whole number of seconds",
1461            ),
1462        }
1463        let keep_runs = match flags.keep_runs {
1464            None => 1,
1465            Some(de::Located {
1466                value: Loose::Ok(n), ..
1467            }) if n > 0 => n as usize,
1468            Some(v) => {
1469                self.diags.error(
1470                    Code::InvalidField,
1471                    file.clone(),
1472                    v.line(),
1473                    "`flags.keep_runs` must be a whole number of 1 or more",
1474                );
1475                1
1476            }
1477        };
1478        let Some(v) = flags.run_timeout else {
1479            return (None, keep_runs);
1480        };
1481        let line = v.line();
1482        let parsed = match v.value {
1483            Loose::Ok(v) => dre_protocol::delivery::parse_duration(&v),
1484            _ => Err("must be a duration such as `2h` or `90m`, or seconds".to_string()),
1485        };
1486        let run_timeout = match parsed {
1487            Ok(d) if !d.is_zero() => Some(d),
1488            Ok(_) => {
1489                self.diags.error(
1490                    Code::InvalidField,
1491                    file.clone(),
1492                    line,
1493                    "`flags.run_timeout` must be more than zero",
1494                );
1495                None
1496            }
1497            Err(e) => {
1498                self.diags.error(
1499                    Code::InvalidField,
1500                    file.clone(),
1501                    line,
1502                    format!("`flags.run_timeout` {e}"),
1503                );
1504                None
1505            }
1506        };
1507        (run_timeout, keep_runs)
1508    }
1509
1510    /// `dispatch: [{macro_namespace: dre_utils, search_order: [my_project, dre_utils]}]`.
1511    fn parse_dispatch(&mut self, file: &Path, v: Option<config::project::DispatchList>) -> DispatchOrder {
1512        let mut out = DispatchOrder::new();
1513        let Some(v) = v else { return out };
1514        let line = v.line();
1515        let Loose::Ok(list) = v.value else {
1516            self.diags.error(
1517                Code::InvalidField,
1518                Some(file.to_path_buf()),
1519                line,
1520                "`dispatch` must be a list of `{macro_namespace, search_order}`",
1521            );
1522            return out;
1523        };
1524        for e in list {
1525            let (ns, order) = match e {
1526                Loose::Ok(e) => {
1527                    let ns = e.macro_namespace.and_then(|n| n.ok().cloned());
1528                    let order: Option<Vec<String>> = e
1529                        .search_order
1530                        .and_then(|s| s.ok().map(|s| s.iter().filter_map(|x| x.ok().cloned()).collect()));
1531                    (ns, order)
1532                }
1533                Loose::Bad(_) => (None, None),
1534            };
1535            match (ns, order) {
1536                (Some(ns), Some(order)) if !order.is_empty() => {
1537                    out.insert(ns, order);
1538                }
1539                _ => self.diags.error(
1540                    Code::InvalidField,
1541                    Some(file.to_path_buf()),
1542                    line,
1543                    "each `dispatch` entry needs `macro_namespace` and a non-empty `search_order` list",
1544                ),
1545            }
1546        }
1547        out
1548    }
1549
1550    /// Package names are Jinja variables: they can't clash with each other's macros, the
1551    /// project's own macros, or DRE's functions.
1552    fn check_macro_namespaces(&mut self, project: &Project) {
1553        let own: BTreeSet<String> = project
1554            .macros
1555            .iter()
1556            .filter_map(|m| std::fs::read_to_string(self.root.join(m)).ok())
1557            .flat_map(|src| preflight::macro_defs(&src).into_iter().map(|d| d.name))
1558            .collect();
1559        for p in &project.packages {
1560            let clash = if RESERVED_NAMES.contains(&p.name.as_str()) {
1561                Some("a DRE function or variable".to_string())
1562            } else if own.contains(&p.name) {
1563                Some("a macro in macros/".to_string())
1564            } else if p.name == project.name {
1565                Some("this project".to_string())
1566            } else {
1567                None
1568            };
1569            if let Some(c) = clash {
1570                self.diags.error(
1571                    Code::PackageNameClash,
1572                    None,
1573                    None,
1574                    format!(
1575                        "package `{}` has the same name as {c}; macros couldn't be called through it",
1576                        p.name
1577                    ),
1578                );
1579            }
1580        }
1581        for (ns, order) in &project.dispatch {
1582            for n in order {
1583                if n != &project.name && !project.packages.iter().any(|p| &p.name == n) {
1584                    self.diags.error(
1585                        Code::InvalidField,
1586                        Some(PathBuf::from(PROJECT_FILE)),
1587                        None,
1588                        format!("`dispatch` for `{ns}` searches `{n}`, which is neither this project nor an installed package"),
1589                    );
1590                }
1591            }
1592        }
1593    }
1594
1595    fn parse_folder_config(
1596        &mut self,
1597        file: &Path,
1598        tree: Option<de::Located<Loose<config::project::Folder>>>,
1599        folders: &[Vec<String>],
1600    ) -> BTreeMap<Vec<String>, FolderCfg> {
1601        let mut out = BTreeMap::new();
1602        let Some(tree) = tree else {
1603            return out;
1604        };
1605        let display = Some(file.to_path_buf());
1606        let mut stack = vec![(Vec::<String>::new(), tree)];
1607        while let Some((path, node)) = stack.pop() {
1608            let line = node.line();
1609            let Loose::Ok(f) = node.value else {
1610                self.diags.error(
1611                    Code::InvalidFolderConfig,
1612                    display.clone(),
1613                    line,
1614                    format!("folder config for `{}` must be a map", dotted(&path)),
1615                );
1616                continue;
1617            };
1618            let any = f.sets_anything();
1619            let mut cfg = FolderCfg::default();
1620            let bad = |s: &mut Self, k: &str, kline: Option<usize>, what: &str| {
1621                s.diags.error(
1622                    Code::InvalidField,
1623                    display.clone(),
1624                    kline,
1625                    format!("`{k}` must be {what}"),
1626                )
1627            };
1628            if let Some(v) = f.tags {
1629                match v.value {
1630                    Loose::Ok(t) => cfg.tags = t,
1631                    Loose::Bad(_) => bad(self, "+tags", v.line(), "a list of strings"),
1632                }
1633            }
1634            if let Some(v) = f.output {
1635                match v.value {
1636                    Loose::Ok(o) => cfg.output = Some(o),
1637                    Loose::Bad(_) => bad(self, "+output", v.line(), "a map"),
1638                }
1639            }
1640            if let Some(v) = f.profile {
1641                let kline = v.line();
1642                match v.value {
1643                    Loose::Ok(p) => cfg.profile = Some((p, kline)),
1644                    Loose::Bad(_) => bad(self, "+profile", kline, "a string"),
1645                }
1646            }
1647            if let Some(v) = f.schedule {
1648                let ctx = format!("folder `{}`: `+schedule`", dotted(&path));
1649                self.moved_to_schedules(file, v.line(), &ctx);
1650            }
1651            if let Some(v) = f.timezone {
1652                let kline = v.line();
1653                cfg.timezone =
1654                    self.timezone_str(v.value.ok().map(String::as_str), file, kline, "`+timezone`");
1655            }
1656            if let Some(v) = f.locale {
1657                let kline = v.line();
1658                cfg.locale = self.locale_str(v.value.ok().map(String::as_str), file, kline, "`+locale`");
1659            }
1660            if let Some(v) = f.vars {
1661                match v.value {
1662                    Loose::Ok(s) => cfg.vars = Some(s),
1663                    Loose::Bad(_) => bad(self, "+vars", v.line(), "a map"),
1664                }
1665            }
1666            for (k, v) in f.rest.0 {
1667                if k.value.starts_with('+') {
1668                    self.diags.error(
1669                        Code::UnknownKey,
1670                        display.clone(),
1671                        k.line(),
1672                        format!(
1673                            "unknown folder config `{}`; folder config keys are {}",
1674                            k.value,
1675                            FOLDER_CONFIG_KEYS.join(", ")
1676                        ),
1677                    );
1678                } else {
1679                    let mut p = path.clone();
1680                    p.push(k.value);
1681                    stack.push((p, v));
1682                }
1683            }
1684            if any && !path.is_empty() {
1685                if !folders.contains(&path) {
1686                    self.diags.warning(
1687                        Code::UnknownFolder,
1688                        display.clone(),
1689                        line,
1690                        format!(
1691                            "folder config for `{}` matches no folder under reports/",
1692                            dotted(&path)
1693                        ),
1694                    );
1695                }
1696                out.insert(path, cfg);
1697            } else if any {
1698                out.insert(path, cfg);
1699            }
1700        }
1701        out
1702    }
1703
1704    // -- discovery ----------------------------------------------------------------------------
1705
1706    fn discover(&mut self) -> Discovered {
1707        let mut d = Discovered {
1708            yaml: Vec::new(),
1709            sql: Vec::new(),
1710            macros: Vec::new(),
1711            lookups: Vec::new(),
1712            folders: Vec::new(),
1713            sources: Vec::new(),
1714        };
1715        let target_inside = self.target_inside.clone();
1716        let root = self.root.clone();
1717        let walker = walkdir::WalkDir::new(&self.root)
1718            .sort_by_file_name()
1719            .into_iter()
1720            .filter_entry(|e| {
1721                let name = e.file_name().to_string_lossy();
1722                // Generated or installed, never part of the project's own sources.
1723                e.depth() == 0
1724                    || !(name.starts_with('.')
1725                        || target_inside
1726                            .as_deref()
1727                            .is_some_and(|t| e.path().strip_prefix(&root).is_ok_and(|r| r == t))
1728                        || (e.depth() == 1 && [TARGET_DIR, DEPS_DIR, LOGS_DIR].contains(&name.as_ref())))
1729            });
1730        for e in walker.filter_map(Result::ok) {
1731            let rel = self.rel(e.path());
1732            let in_reports = rel.starts_with(REPORTS_DIR);
1733            if e.file_type().is_dir() {
1734                if in_reports && rel != Path::new(REPORTS_DIR) {
1735                    d.folders.push(folder_segments(&rel));
1736                }
1737                continue;
1738            }
1739            let ext = e.path().extension().and_then(|x| x.to_str()).unwrap_or("");
1740            let yaml = matches!(ext, "yml" | "yaml") && rel != Path::new(crate::profiles::PROFILES_FILE);
1741            if yaml || in_reports || rel.starts_with(MACROS_DIR) || rel.starts_with(LOOKUPS_DIR) {
1742                d.sources.push(rel.clone());
1743            }
1744            if rel.starts_with(LOOKUPS_DIR) {
1745                d.lookups.push(rel);
1746                continue;
1747            }
1748            match ext {
1749                // A profiles.yml at the root holds connections, not project config.
1750                "yml" | "yaml"
1751                    if rel != Path::new(PROJECT_FILE) && rel != Path::new(crate::profiles::PROFILES_FILE) =>
1752                {
1753                    d.yaml.push(e.path().to_path_buf())
1754                }
1755                "sql" if rel.starts_with(MACROS_DIR) => d.macros.push(rel),
1756                "sql" if in_reports => d.sql.push(rel),
1757                _ => {}
1758            }
1759        }
1760        d
1761    }
1762
1763    fn classify(
1764        &mut self,
1765        yf: Rc<YamlFile>,
1766        fragments: &mut Vec<Fragment>,
1767        plugins: &mut Vec<(Rc<YamlFile>, Mapping)>,
1768        sets: &mut Vec<Rc<YamlFile>>,
1769        schedules: &mut Vec<Rc<YamlFile>>,
1770        timings: &mut Vec<Rc<YamlFile>>,
1771    ) {
1772        let in_reports = yf.display.starts_with(REPORTS_DIR);
1773        let timings_file = yf.display.file_name() == Some(std::ffi::OsStr::new(TIMINGS_FILE));
1774        match &yf.value {
1775            Value::Null => {}
1776            Value::Array(items)
1777                if !items.is_empty()
1778                    && items
1779                        .iter()
1780                        .all(|i| ["name", "select", "report"].iter().any(|k| i.get(k).is_some())) =>
1781            {
1782                schedules.push(yf.clone());
1783            }
1784            Value::Object(m) => {
1785                let pl = pick(&yf.value, PLUGIN_KEYS);
1786                if !pl.is_empty() {
1787                    plugins.push((yf.clone(), pl));
1788                }
1789                // `packages:` belongs to the root dependency files, read by `packages::declared`.
1790                let dependency_file = packages::DEPENDENCY_FILES
1791                    .iter()
1792                    .any(|f| yf.display == Path::new(f));
1793                if !dependency_file && m.contains_key("packages") {
1794                    self.diags.error(
1795                        Code::MisplacedPackages,
1796                        Some(yf.display.clone()),
1797                        yf.line_of("packages", None),
1798                        "`packages:` goes in dependencies.yml or packages.yml at the project root",
1799                    );
1800                }
1801                if dependency_file {
1802                    for k in m.keys().map(String::as_str) {
1803                        if OLD_PLUGIN_KEYS.contains(&k) {
1804                            self.old_plugin_key(&yf, k);
1805                        }
1806                    }
1807                }
1808                let has_sources = m.contains_key(SOURCES_KEY);
1809                let rest: Mapping = m
1810                    .iter()
1811                    .filter(|(k, _)| !is_one_of(k, PLUGIN_KEYS) && k.as_str() != "packages")
1812                    .filter(|(k, _)| k.as_str() != SOURCES_KEY)
1813                    // dbt's `version: 2` at the top of a sources file.
1814                    .filter(|(k, _)| !(has_sources && k.as_str() == "version"))
1815                    .filter(|(k, _)| !(dependency_file && is_one_of(k, OLD_PLUGIN_KEYS)))
1816                    .map(|(k, v)| (k.clone(), v.clone()))
1817                    .collect();
1818                if rest.is_empty() {
1819                    return;
1820                }
1821                let looks_like_report = rest.contains_key("queries") || rest.contains_key("name");
1822                if !in_reports && (timings_file || is_timing_registry(&rest)) {
1823                    timings.push(yf.clone());
1824                } else if in_reports || looks_like_report {
1825                    self.report_fragment(yf.clone(), rest, fragments);
1826                } else if is_set_registry(&rest) {
1827                    sets.push(yf.clone());
1828                } else {
1829                    self.diags.warning(
1830                        Code::UnrecognizedYaml,
1831                        Some(yf.display.clone()),
1832                        None,
1833                        "not recognised as report, Set, plugin or schedule config; ignored",
1834                    );
1835                }
1836            }
1837            _ => self.diags.warning(
1838                Code::UnrecognizedYaml,
1839                Some(yf.display.clone()),
1840                None,
1841                "not recognised as report, Set, plugin or schedule config; ignored",
1842            ),
1843        }
1844    }
1845
1846    fn report_fragment(&mut self, yf: Rc<YamlFile>, map: Mapping, out: &mut Vec<Fragment>) {
1847        for k in map.keys().map(String::as_str) {
1848            if !REPORT_KEYS.contains(&k) {
1849                self.diags.error(
1850                    Code::UnknownKey,
1851                    Some(yf.display.clone()),
1852                    key_line(&yf, k),
1853                    format!("unknown report key `{k}`"),
1854                );
1855            }
1856        }
1857        let typed = match de::from_node::<Loose<ReportFile>>(&yf.node) {
1858            Ok(Loose::Ok(r)) => r,
1859            Ok(Loose::Bad(_)) => return,
1860            Err(e) => {
1861                self.diags
1862                    .error(Code::InvalidReport, Some(yf.display.clone()), e.line, e.message);
1863                return;
1864            }
1865        };
1866        let folder = folder_segments(yf.display.parent().unwrap_or(Path::new("")));
1867        let (name, explicit_name) = match &typed.name {
1868            Some(de::Located {
1869                value: Loose::Ok(s), ..
1870            }) if !s.is_empty() => (s.clone(), true),
1871            Some(n) => {
1872                self.diags.error(
1873                    Code::InvalidField,
1874                    Some(yf.display.clone()),
1875                    n.line(),
1876                    "`name` must be a non-empty string",
1877                );
1878                return;
1879            }
1880            None => match folder.last() {
1881                Some(f) => (f.clone(), false),
1882                None => {
1883                    self.diags.error(
1884                        Code::MissingField,
1885                        Some(yf.display.clone()),
1886                        None,
1887                        "a report outside a report folder needs an explicit `name:`",
1888                    );
1889                    return;
1890                }
1891            },
1892        };
1893        out.push(Fragment {
1894            file: yf,
1895            name,
1896            explicit_name,
1897            folder,
1898            map,
1899            typed,
1900        });
1901    }
1902
1903    // -- sources ------------------------------------------------------------------------------
1904
1905    /// `sources:` from every project YAML file, in dbt's shape plus DRE's `profile:`. dbt keys
1906    /// DRE doesn't use yet are noted once per file; any other key is an error.
1907    fn parse_sources(&mut self, files: &[Rc<YamlFile>]) -> BTreeMap<String, SourceDef> {
1908        let mut out: BTreeMap<String, SourceDef> = BTreeMap::new();
1909        for yf in files {
1910            let file = Some(yf.display.clone());
1911            let top = key_line(yf, SOURCES_KEY);
1912            let plugin_names = match yf.node.get(SOURCES_KEY).map(|n| &n.kind) {
1913                Some(crate::config::node::Kind::Seq(items)) => items.iter().all(|i| i.as_str().is_some()),
1914                _ => false,
1915            };
1916            if plugin_names {
1917                // DRE 0.0.x declared source plugins here.
1918                self.diags.error(
1919                    Code::MovedPluginDeclaration,
1920                    file,
1921                    top,
1922                    "`sources:` declares tables now (dbt's format); list plugin packages under `plugins:` instead (e.g. `plugins: [duckdb]`)",
1923                );
1924                continue;
1925            }
1926            let items = match de::from_node::<Loose<config::sources::SourcesFile>>(&yf.node) {
1927                Ok(Loose::Ok(f)) => f.sources.map(|s| s.value),
1928                _ => None,
1929            };
1930            let items = match items {
1931                Some(Loose::Ok(Some(items))) => items,
1932                Some(Loose::Ok(None)) => continue,
1933                _ => {
1934                    self.diags.error(
1935                        Code::InvalidSource,
1936                        file,
1937                        top,
1938                        "`sources` must be a list of sources, as in dbt: `- name: sales` with `tables:`",
1939                    );
1940                    continue;
1941                }
1942            };
1943            let mut unsupported: BTreeSet<String> = BTreeSet::new();
1944            for item in items {
1945                if let Some(src) = self.source_def(yf, item, &mut unsupported) {
1946                    if let Some(prev) = out.get(&src.name) {
1947                        self.diags.error(
1948                            Code::DuplicateSource,
1949                            file.clone(),
1950                            src.line,
1951                            format!(
1952                                "source `{}` is also declared in {}; source names must be unique",
1953                                src.name,
1954                                prev.file.display()
1955                            ),
1956                        );
1957                        continue;
1958                    }
1959                    out.insert(src.name.clone(), src);
1960                }
1961            }
1962            if !unsupported.is_empty() {
1963                self.diags.warning(
1964                    Code::SourceKeyNotSupported,
1965                    file,
1966                    top,
1967                    format!(
1968                        "dbt source keys DRE doesn't support yet are ignored: {}",
1969                        unsupported.into_iter().collect::<Vec<_>>().join(", ")
1970                    ),
1971                );
1972            }
1973        }
1974        out
1975    }
1976
1977    fn source_def(
1978        &mut self,
1979        yf: &YamlFile,
1980        item: de::Located<Loose<config::sources::Source>>,
1981        unsupported: &mut BTreeSet<String>,
1982    ) -> Option<SourceDef> {
1983        let file = Some(yf.display.clone());
1984        let top = key_line(yf, SOURCES_KEY);
1985        let item_line = item.line().or(top);
1986        let Loose::Ok(m) = item.value else {
1987            self.diags.error(
1988                Code::InvalidSource,
1989                file,
1990                top,
1991                "each source must be a map with `name:` and `tables:`",
1992            );
1993            return None;
1994        };
1995        let Some(name) = m
1996            .name
1997            .as_ref()
1998            .and_then(Loose::ok)
1999            .filter(|n| !n.is_empty())
2000            .cloned()
2001        else {
2002            self.diags
2003                .error(Code::InvalidSource, file, top, "every source needs a `name`");
2004            return None;
2005        };
2006        let line = item_line;
2007        let ctx = format!("source `{name}`");
2008        let mut ok = self.source_keys(
2009            &yf.display,
2010            &m.unknown,
2011            &m.dbt_keys(),
2012            &ctx,
2013            SOURCE_KEYS,
2014            "source",
2015            unsupported,
2016        );
2017        let mut text = |s: &mut Self, k: &str, v: &Option<Loose<de::Text>>| -> Option<String> {
2018            match v {
2019                None => None,
2020                Some(Loose::Ok(t)) => Some(t.0.clone()),
2021                Some(Loose::Bad(_)) => {
2022                    s.diags.error(
2023                        Code::InvalidSource,
2024                        file.clone(),
2025                        line,
2026                        format!("{ctx}: `{k}` must be a string"),
2027                    );
2028                    ok = false;
2029                    None
2030                }
2031            }
2032        };
2033        let description = text(self, "description", &m.description);
2034        let database = text(self, "database", &m.database);
2035        let schema = text(self, "schema", &m.schema);
2036        let profile = text(self, "profile", &m.profile);
2037        let quoting = self.quoting(&yf.display, m.quoting, &ctx, line);
2038        let tags = self.source_tags(&yf.display, m.tags, &ctx, line);
2039        let meta = self.source_meta(&yf.display, m.meta, &ctx, line);
2040        let mut tables = Vec::new();
2041        match m.tables {
2042            None | Some(Loose::Ok(None)) => {}
2043            Some(Loose::Ok(Some(ts))) => {
2044                // Duplicates by name, whether or not each entry is otherwise valid.
2045                let mut names = BTreeSet::new();
2046                for t in &ts {
2047                    let Some(n) = t.value.ok().and_then(|t| t.name.as_ref()).and_then(Loose::ok) else {
2048                        continue;
2049                    };
2050                    if !names.insert(n.clone()) {
2051                        self.diags.error(
2052                            Code::DuplicateSourceTable,
2053                            file.clone(),
2054                            t.line().or(line),
2055                            format!("{ctx} declares table `{n}` twice"),
2056                        );
2057                        ok = false;
2058                    }
2059                }
2060                for t in ts {
2061                    if let Some(t) = self.source_table(yf, &name, t, line, unsupported)
2062                        && !tables.iter().any(|x: &SourceTableDef| x.name == t.name)
2063                    {
2064                        tables.push(t);
2065                    }
2066                }
2067            }
2068            Some(Loose::Bad(_)) => {
2069                self.diags.error(
2070                    Code::InvalidSource,
2071                    file.clone(),
2072                    line,
2073                    format!("{ctx}: `tables` must be a list"),
2074                );
2075                ok = false;
2076            }
2077        }
2078        ok.then(|| SourceDef {
2079            name: name.to_string(),
2080            description,
2081            database,
2082            schema,
2083            profile,
2084            quoting,
2085            tags,
2086            meta,
2087            tables,
2088            file: yf.display.clone(),
2089            line,
2090        })
2091    }
2092
2093    fn source_table(
2094        &mut self,
2095        yf: &YamlFile,
2096        source: &str,
2097        item: de::Located<Loose<config::sources::Table>>,
2098        source_line: Option<usize>,
2099        unsupported: &mut BTreeSet<String>,
2100    ) -> Option<SourceTableDef> {
2101        let file = Some(yf.display.clone());
2102        let item_line = item.line();
2103        let Loose::Ok(m) = item.value else {
2104            self.diags.error(
2105                Code::InvalidSource,
2106                file,
2107                source_line,
2108                format!("source `{source}`: each table must be a map with a `name`"),
2109            );
2110            return None;
2111        };
2112        let Some(name) = m
2113            .name
2114            .as_ref()
2115            .and_then(Loose::ok)
2116            .filter(|n| !n.is_empty())
2117            .cloned()
2118        else {
2119            self.diags.error(
2120                Code::InvalidSource,
2121                file,
2122                source_line,
2123                format!("source `{source}`: every table needs a `name`"),
2124            );
2125            return None;
2126        };
2127        let line = item_line.or(source_line);
2128        let ctx = format!("source `{source}`, table `{name}`");
2129        let mut ok = self.source_keys(
2130            &yf.display,
2131            &m.unknown,
2132            &m.dbt_keys(),
2133            &ctx,
2134            SOURCE_TABLE_KEYS,
2135            "table",
2136            unsupported,
2137        );
2138        let mut text = |s: &mut Self, k: &str, v: &Option<Loose<de::Text>>| -> Option<String> {
2139            match v {
2140                None => None,
2141                Some(Loose::Ok(t)) => Some(t.0.clone()),
2142                Some(Loose::Bad(_)) => {
2143                    s.diags.error(
2144                        Code::InvalidSource,
2145                        file.clone(),
2146                        line,
2147                        format!("{ctx}: `{k}` must be a string"),
2148                    );
2149                    ok = false;
2150                    None
2151                }
2152            }
2153        };
2154        let identifier = text(self, "identifier", &m.identifier);
2155        let description = text(self, "description", &m.description);
2156        let quoting = self.quoting(&yf.display, m.quoting, &ctx, line);
2157        let tags = self.source_tags(&yf.display, m.tags, &ctx, line);
2158        let meta = self.source_meta(&yf.display, m.meta, &ctx, line);
2159        let mut columns: Vec<SourceColumn> = Vec::new();
2160        match m.columns {
2161            None | Some(Loose::Ok(None)) => {}
2162            Some(Loose::Ok(Some(cs))) => {
2163                for c in cs {
2164                    let Loose::Ok(cm) = c else {
2165                        self.diags.error(
2166                            Code::InvalidSource,
2167                            file.clone(),
2168                            line,
2169                            format!("{ctx}: each column must be a map with a `name`"),
2170                        );
2171                        ok = false;
2172                        continue;
2173                    };
2174                    let Some(cname) = cm
2175                        .name
2176                        .as_ref()
2177                        .and_then(Loose::ok)
2178                        .filter(|n| !n.is_empty())
2179                        .cloned()
2180                    else {
2181                        self.diags.error(
2182                            Code::InvalidSource,
2183                            file.clone(),
2184                            line,
2185                            format!("{ctx}: every column needs a `name`"),
2186                        );
2187                        ok = false;
2188                        continue;
2189                    };
2190                    let cctx = format!("{ctx}, column `{cname}`");
2191                    ok &= self.source_keys(
2192                        &yf.display,
2193                        &cm.unknown,
2194                        &cm.dbt_keys(),
2195                        &cctx,
2196                        SOURCE_COLUMN_KEYS,
2197                        "column",
2198                        unsupported,
2199                    );
2200                    if columns.iter().any(|c| c.name.eq_ignore_ascii_case(&cname)) {
2201                        self.diags.error(
2202                            Code::DuplicateSourceColumn,
2203                            file.clone(),
2204                            line,
2205                            format!("{ctx} declares column `{cname}` twice"),
2206                        );
2207                        ok = false;
2208                        continue;
2209                    }
2210                    let s = |v: &Option<Loose<de::Text>>| v.as_ref().and_then(Loose::ok).map(|t| t.0.clone());
2211                    columns.push(SourceColumn {
2212                        name: cname,
2213                        description: s(&cm.description),
2214                        data_type: s(&cm.data_type),
2215                    });
2216                }
2217            }
2218            Some(Loose::Bad(_)) => {
2219                self.diags.error(
2220                    Code::InvalidSource,
2221                    file.clone(),
2222                    line,
2223                    format!("{ctx}: `columns` must be a list"),
2224                );
2225                ok = false;
2226            }
2227        }
2228        ok.then(|| SourceTableDef {
2229            name,
2230            identifier,
2231            description,
2232            quoting,
2233            tags,
2234            meta,
2235            columns,
2236        })
2237    }
2238
2239    /// The keys of one source, table or column map that aren't DRE's: dbt's are collected into
2240    /// `unsupported`, anything else is an error. Returns false on an error.
2241    #[allow(clippy::too_many_arguments)]
2242    fn source_keys(
2243        &mut self,
2244        file: &Path,
2245        unknown: &de::UnknownKeys,
2246        dbt: &[&str],
2247        ctx: &str,
2248        known: &[&str],
2249        level: &str,
2250        unsupported: &mut BTreeSet<String>,
2251    ) -> bool {
2252        for k in dbt {
2253            unsupported.insert(format!("{level} `{k}`"));
2254        }
2255        for k in &unknown.0 {
2256            self.diags.error(
2257                Code::UnknownKey,
2258                Some(file.to_path_buf()),
2259                Some(k.line),
2260                format!(
2261                    "{ctx}: unknown key `{}`; a {level} takes {}",
2262                    k.name,
2263                    known.join(", ")
2264                ),
2265            );
2266        }
2267        unknown.0.is_empty()
2268    }
2269
2270    fn quoting(
2271        &mut self,
2272        file: &Path,
2273        v: Option<Loose<config::sources::Quoting>>,
2274        ctx: &str,
2275        line: Option<usize>,
2276    ) -> QuotingDef {
2277        let mut q = QuotingDef::default();
2278        let Some(v) = v else { return q };
2279        let bad = |s: &mut Self, msg: String| {
2280            s.diags.error(
2281                Code::InvalidSource,
2282                Some(file.to_path_buf()),
2283                line,
2284                format!("{ctx}: {msg}"),
2285            )
2286        };
2287        let Loose::Ok(qm) = v else {
2288            bad(
2289                self,
2290                "`quoting` must be a map of `database`, `schema` and `identifier` to true or false".into(),
2291            );
2292            return q;
2293        };
2294        for (k, v, slot) in [
2295            ("database", qm.database, &mut q.database),
2296            ("schema", qm.schema, &mut q.schema),
2297            ("identifier", qm.identifier, &mut q.identifier),
2298        ] {
2299            match v {
2300                None => {}
2301                Some(Loose::Ok(b)) => *slot = Some(b),
2302                Some(Loose::Bad(_)) => bad(self, format!("`quoting.{k}` must be true or false")),
2303            }
2304        }
2305        for (k, v) in qm.unknown.0 {
2306            if v.is_boolean() {
2307                bad(
2308                    self,
2309                    format!(
2310                        "unknown `quoting` key `{}`; use `database`, `schema` or `identifier`",
2311                        k.value
2312                    ),
2313                );
2314            } else {
2315                bad(self, format!("`quoting.{}` must be true or false", k.value));
2316            }
2317        }
2318        q
2319    }
2320
2321    fn source_tags(
2322        &mut self,
2323        file: &Path,
2324        v: Option<Loose<config::sources::Tags>>,
2325        ctx: &str,
2326        line: Option<usize>,
2327    ) -> Vec<String> {
2328        match v {
2329            None => Vec::new(),
2330            Some(Loose::Ok(config::sources::Tags(de::OneOf::A(t)))) => vec![t],
2331            Some(Loose::Ok(config::sources::Tags(de::OneOf::B(l)))) => l,
2332            Some(Loose::Bad(_)) => {
2333                self.diags.error(
2334                    Code::InvalidSource,
2335                    Some(file.to_path_buf()),
2336                    line,
2337                    format!("{ctx}: `tags` must be a list of strings"),
2338                );
2339                Vec::new()
2340            }
2341        }
2342    }
2343
2344    fn source_meta(
2345        &mut self,
2346        file: &Path,
2347        v: Option<Loose<config::sources::Meta>>,
2348        ctx: &str,
2349        line: Option<usize>,
2350    ) -> JsonMap<String, Json> {
2351        match v {
2352            None => JsonMap::new(),
2353            Some(Loose::Ok(m)) => m.0,
2354            Some(Loose::Bad(_)) => {
2355                self.diags.error(
2356                    Code::InvalidSource,
2357                    Some(file.to_path_buf()),
2358                    line,
2359                    format!("{ctx}: `meta` must be a map"),
2360                );
2361                JsonMap::new()
2362            }
2363        }
2364    }
2365
2366    // -- the parse pass -----------------------------------------------------------------------
2367
2368    /// Render every Binding without a database ([`crate::parse`]): each query's connection and
2369    /// sources, for selection, the manifest, `dre ls` and validate. Problems go to diagnostics,
2370    /// except for files that already have an error (the same cause, said better).
2371    fn parse_pass(&mut self, project: &mut Project, used: &mut Usage) {
2372        let inputs = crate::parse::Inputs {
2373            target: project.target_name.clone(),
2374            cli_vars: self.opts.vars.clone(),
2375            date: self.opts.date,
2376            scheduled_at: self.opts.scheduled_at,
2377            timezone: self.opts.timezone.clone(),
2378            schedule: None,
2379            started_at: None,
2380        };
2381        project.inputs = inputs.clone();
2382        let failed: BTreeSet<PathBuf> = self
2383            .diags
2384            .iter()
2385            .filter(|d| d.severity == crate::Severity::Error)
2386            .filter_map(|d| d.file.clone())
2387            .collect();
2388        let mut results = Vec::new();
2389        for (ri, r) in project.reports.iter().enumerate() {
2390            for (bi, b) in r.bindings.iter().enumerate() {
2391                let parsed = crate::parse::binding(project, r, b, &b.vars, &inputs);
2392                results.push((ri, bi, parsed));
2393            }
2394        }
2395        for (ri, bi, parsed) in results {
2396            for p in &parsed.errors {
2397                if failed.contains(&p.file) {
2398                    continue;
2399                }
2400                // A template that doesn't render may depend on this run's `--var`s: it makes
2401                // its own report invalid (and fails when that report runs or compiles), but
2402                // doesn't stop the rest of the project.
2403                if p.code == crate::parse::PARSE_FAILED {
2404                    let msg = crate::Diagnostic {
2405                        severity: crate::Severity::Error,
2406                        code: p.code,
2407                        message: p.message.clone(),
2408                        file: Some(p.file.clone()),
2409                        line: p.line,
2410                        plugin: None,
2411                    };
2412                    let errs = project
2413                        .parse_errors
2414                        .entry(project.reports[ri].name.clone())
2415                        .or_default();
2416                    if !errs.contains(&msg) {
2417                        errs.push(msg);
2418                    }
2419                    continue;
2420                }
2421                self.diags
2422                    .error(p.code, Some(p.file.clone()), p.line, p.message.clone());
2423            }
2424            for p in &parsed.warnings {
2425                self.diags
2426                    .warning(p.code, Some(p.file.clone()), p.line, p.message.clone());
2427            }
2428            let file = Some(project.reports[ri].file.clone());
2429            if let Some(i) = &parsed.inherited {
2430                used.connection(i, file.clone(), None);
2431            }
2432            for q in &parsed.queries {
2433                if let Some(c) = &q.connection {
2434                    used.connection(c, file.clone(), None);
2435                }
2436            }
2437            for d in parsed.destinations.iter().flatten() {
2438                used.destination(d, file.clone(), None);
2439            }
2440            project.reports[ri].bindings[bi].parsed = Some(std::sync::Arc::new(parsed));
2441        }
2442    }
2443
2444    // -- sets.yml -----------------------------------------------------------------------------
2445
2446    fn parse_sets(&mut self, files: &[Rc<YamlFile>]) -> BTreeMap<String, SetDef> {
2447        let mut out = BTreeMap::new();
2448        let mut seen: BTreeMap<String, PathBuf> = BTreeMap::new();
2449        for yf in files {
2450            let Ok(Loose::Ok(sets)) = de::from_node::<Loose<config::schedule::SetsFile>>(&yf.node) else {
2451                continue;
2452            };
2453            for (k, v) in sets.0.0 {
2454                let line = k.line();
2455                let name = k.value;
2456                if let Some(prev) = seen.get(&name) {
2457                    self.diags.error(
2458                        Code::DuplicateSet,
2459                        Some(yf.display.clone()),
2460                        line,
2461                        format!("Set `{name}` is also declared in {}", prev.display()),
2462                    );
2463                    continue;
2464                }
2465                seen.insert(name.clone(), yf.display.clone());
2466                // Anything but a map is a Set with the report's defaults.
2467                let v = match v {
2468                    Loose::Ok(Some(v)) => Some(v),
2469                    _ => None,
2470                };
2471                let profile = v
2472                    .as_ref()
2473                    .and_then(|v| v.profile.as_ref())
2474                    .and_then(Loose::ok)
2475                    .cloned();
2476                if v.as_ref().is_some_and(|v| v.profile.is_some()) && profile.is_none() {
2477                    self.diags.error(
2478                        Code::InvalidField,
2479                        Some(yf.display.clone()),
2480                        line,
2481                        format!("Set `{name}`: `profile` must be a string"),
2482                    );
2483                }
2484                let locale = match v.as_ref().and_then(|v| v.locale.as_ref()) {
2485                    None => None,
2486                    Some(l) => self.locale_str(
2487                        l.ok().map(String::as_str),
2488                        &yf.display,
2489                        line,
2490                        &format!("Set `{name}`: `locale`"),
2491                    ),
2492                };
2493                let vars = match v.as_ref().and_then(|v| v.vars.as_ref()) {
2494                    Some(Loose::Ok(m)) => m.clone(),
2495                    None => JsonMap::new(),
2496                    Some(Loose::Bad(_)) => {
2497                        self.diags.error(
2498                            Code::InvalidField,
2499                            Some(yf.display.clone()),
2500                            line,
2501                            format!("Set `{name}`: `vars` must be a map"),
2502                        );
2503                        JsonMap::new()
2504                    }
2505                };
2506                out.insert(
2507                    name,
2508                    SetDef {
2509                        profile,
2510                        vars,
2511                        locale,
2512                        file: yf.display.clone(),
2513                        line,
2514                    },
2515                );
2516            }
2517        }
2518        out
2519    }
2520
2521    // -- .sql index ---------------------------------------------------------------------------
2522
2523    /// Literal `ref('name')` calls in SQL and macro files: each must name a project `.sql` file.
2524    /// Returns the names referenced.
2525    fn check_refs(
2526        &mut self,
2527        sql: &[PathBuf],
2528        macros: &[PathBuf],
2529        index: &BTreeMap<String, Vec<PathBuf>>,
2530        lookups: &BTreeMap<String, Lookup>,
2531    ) -> BTreeSet<String> {
2532        let mut names = BTreeSet::new();
2533        // name → the names its file refs, for cycle detection.
2534        let mut graph: BTreeMap<String, Vec<(String, usize)>> = BTreeMap::new();
2535        for file in sql.iter().chain(macros) {
2536            let Ok(src) = std::fs::read_to_string(self.root.join(file)) else {
2537                continue;
2538            };
2539            let is_sql = !file.starts_with(MACROS_DIR);
2540            for (name, line) in preflight::refs(&src) {
2541                if index.contains_key(&name) {
2542                    if is_sql {
2543                        let stem = file.file_stem().unwrap().to_string_lossy().to_string();
2544                        graph.entry(stem).or_default().push((name.clone(), line));
2545                    }
2546                    names.insert(name);
2547                } else if !lookups.contains_key(&name) {
2548                    self.diags.error(
2549                        Code::UnknownRef,
2550                        Some(file.clone()),
2551                        Some(line),
2552                        format!("`ref('{name}')`: there's no `{name}.sql` under reports/ and no lookup `{name}` under lookups/"),
2553                    );
2554                }
2555            }
2556        }
2557        // Report each cycle once, at the ref that closes it.
2558        let mut reported: BTreeSet<Vec<String>> = BTreeSet::new();
2559        for start in graph.keys() {
2560            let mut path = vec![start.clone()];
2561            let mut stack = vec![graph[start].iter()];
2562            while let Some(it) = stack.last_mut() {
2563                let Some((next, line)) = it.next() else {
2564                    stack.pop();
2565                    path.pop();
2566                    continue;
2567                };
2568                if let Some(i) = path.iter().position(|n| n == next) {
2569                    let mut cycle = path[i..].to_vec();
2570                    let mut key = cycle.clone();
2571                    key.sort();
2572                    if reported.insert(key) {
2573                        cycle.push(next.clone());
2574                        let file = index[path.last().unwrap()][0].clone();
2575                        self.diags.error(
2576                            Code::RefCycle,
2577                            Some(file),
2578                            Some(*line),
2579                            format!("`ref()` cycle: {}", cycle.join(" → ")),
2580                        );
2581                    }
2582                    continue;
2583                }
2584                if let Some(edges) = graph.get(next) {
2585                    path.push(next.clone());
2586                    stack.push(edges.iter());
2587                }
2588            }
2589        }
2590        names
2591    }
2592
2593    fn index_sql(&mut self, sql: &[PathBuf]) -> BTreeMap<String, Vec<PathBuf>> {
2594        let mut index: BTreeMap<String, Vec<PathBuf>> = BTreeMap::new();
2595        for p in sql {
2596            let stem = p.file_stem().unwrap().to_string_lossy().to_string();
2597            index.entry(stem).or_default().push(p.clone());
2598        }
2599        for (name, paths) in &index {
2600            if paths.len() > 1 {
2601                self.diags.error(
2602                    Code::DuplicateSqlName,
2603                    Some(paths[1].clone()),
2604                    None,
2605                    format!(
2606                        "`{name}.sql` exists more than once: {}; .sql basenames must be unique project-wide",
2607                        paths
2608                            .iter()
2609                            .map(|p| p.display().to_string())
2610                            .collect::<Vec<_>>()
2611                            .join(", ")
2612                    ),
2613                );
2614            }
2615        }
2616        index
2617    }
2618
2619    // -- merging report fragments ---------------------------------------------------------------
2620
2621    fn merge_reports(&mut self, fragments: Vec<Fragment>) -> Vec<RawReport> {
2622        let mut by_name: BTreeMap<String, Vec<Fragment>> = BTreeMap::new();
2623        for f in fragments {
2624            by_name.entry(f.name.clone()).or_default().push(f);
2625        }
2626        let mut out = Vec::new();
2627        for (name, frags) in by_name {
2628            let definers: Vec<usize> = (0..frags.len())
2629                .filter(|i| frags[*i].map.contains_key("queries"))
2630                .collect();
2631            if definers.len() > 1 {
2632                let second = &frags[definers[1]].file;
2633                self.diags.error(
2634                    Code::DuplicateReportName,
2635                    Some(second.display.clone()),
2636                    key_line(second, "queries"),
2637                    format!(
2638                        "report `{name}` is declared in both {} and {}; report names must be unique project-wide",
2639                        frags[definers[0]].file.display.display(),
2640                        second.display.display()
2641                    ),
2642                );
2643                continue;
2644            }
2645            let Some(&def) = definers.first() else {
2646                for f in &frags {
2647                    let msg = if f.explicit_name {
2648                        format!(
2649                            "report `{name}` has no `queries`; a config fragment must name a report that declares `queries:`"
2650                        )
2651                    } else {
2652                        format!("report `{name}` has no `queries`")
2653                    };
2654                    self.diags
2655                        .error(Code::MissingQueries, Some(f.file.display.clone()), None, msg);
2656                }
2657                continue;
2658            };
2659            let mut keys: BTreeMap<String, Located<Value>> = BTreeMap::new();
2660            let mut owner: BTreeMap<String, usize> = BTreeMap::new();
2661            let mut lines: BTreeMap<String, Option<usize>> = BTreeMap::new();
2662            for (i, f) in frags.iter().enumerate() {
2663                for (k, v) in &f.map {
2664                    let k = k.as_str();
2665                    if k == "name" || !REPORT_KEYS.contains(&k) {
2666                        continue;
2667                    }
2668                    if let Some(prev) = keys.get(k) {
2669                        self.diags.error(
2670                            Code::ConflictingDeclaration,
2671                            Some(f.file.display.clone()),
2672                            key_line(&f.file, k),
2673                            format!(
2674                                "`{k}` for report `{name}` is declared in both {} and {}",
2675                                prev.file.display.display(),
2676                                f.file.display.display()
2677                            ),
2678                        );
2679                        continue;
2680                    }
2681                    keys.insert(
2682                        k.to_string(),
2683                        Located {
2684                            value: v.clone(),
2685                            file: f.file.clone(),
2686                        },
2687                    );
2688                    owner.insert(k.to_string(), i);
2689                    lines.insert(k.to_string(), key_line(&f.file, k));
2690                }
2691            }
2692            let file = frags[def].file.clone();
2693            let folder = frags[def].folder.clone();
2694            let mut typed = ReportFile::default();
2695            for (i, f) in frags.into_iter().enumerate() {
2696                let owns = |k: &str| owner.get(k) == Some(&i);
2697                let ReportFile {
2698                    name: _,
2699                    output: _,
2700                    plugins: _,
2701                    sources: _,
2702                    tags,
2703                    queries,
2704                    profile,
2705                    default_set,
2706                    vars,
2707                    timezone,
2708                    locale,
2709                    sets,
2710                    schedule,
2711                    unknown: _,
2712                } = f.typed;
2713                if owns("tags") {
2714                    typed.tags = tags;
2715                }
2716                if owns("queries") {
2717                    typed.queries = queries;
2718                }
2719                if owns("profile") {
2720                    typed.profile = profile;
2721                }
2722                if owns("default_set") {
2723                    typed.default_set = default_set;
2724                }
2725                if owns("vars") {
2726                    typed.vars = vars;
2727                }
2728                if owns("timezone") {
2729                    typed.timezone = timezone;
2730                }
2731                if owns("locale") {
2732                    typed.locale = locale;
2733                }
2734                if owns("sets") {
2735                    typed.sets = sets;
2736                }
2737                if owns("schedule") {
2738                    typed.schedule = schedule;
2739                }
2740            }
2741            out.push(RawReport {
2742                name,
2743                file,
2744                folder,
2745                keys,
2746                lines,
2747                typed,
2748            });
2749        }
2750        out
2751    }
2752
2753    fn resolve_queries(
2754        &mut self,
2755        r: &RawReport,
2756        index: &BTreeMap<String, Vec<PathBuf>>,
2757        referenced: &mut BTreeSet<String>,
2758    ) -> Vec<QueryEntry> {
2759        let Some(q) = &r.typed.queries else {
2760            return Vec::new();
2761        };
2762        let file = r
2763            .keys
2764            .get("queries")
2765            .map_or_else(|| r.file.display.clone(), |k| k.file.display.clone());
2766        let Loose::Ok(items) = &q.value else {
2767            self.diags.error(
2768                Code::InvalidField,
2769                Some(file),
2770                q.line(),
2771                format!("report `{}`: `queries` must be a list", r.name),
2772            );
2773            return Vec::new();
2774        };
2775        if items.is_empty() {
2776            self.diags.error(
2777                Code::MissingQueries,
2778                Some(file.clone()),
2779                q.line(),
2780                format!("report `{}` has an empty `queries` list", r.name),
2781            );
2782        }
2783        let mut out = Vec::new();
2784        for item in items {
2785            if let Some(e) = self.query_entry(&r.name, item, &file, index) {
2786                referenced.insert(e.query.clone());
2787                out.push(e);
2788            } else if let Some(n) = query_item_name(item) {
2789                referenced.insert(n.to_string());
2790            }
2791        }
2792        out
2793    }
2794
2795    fn query_entry(
2796        &mut self,
2797        report: &str,
2798        item: &QueryItem,
2799        file: &Path,
2800        index: &BTreeMap<String, Vec<PathBuf>>,
2801    ) -> Option<QueryEntry> {
2802        let file = Some(file.to_path_buf());
2803        let line = item.line();
2804        let (name, m) = match &item.value {
2805            Loose::Ok(de::OneOf::A(s)) => (s.clone(), None),
2806            Loose::Ok(de::OneOf::B(m)) => match &m.query {
2807                Some(Loose::Ok(s)) => (s.clone(), Some(m)),
2808                _ => {
2809                    self.diags.error(
2810                        Code::InvalidField,
2811                        file,
2812                        line,
2813                        format!("report `{report}`: a `queries` map entry needs a `query:` name"),
2814                    );
2815                    return None;
2816                }
2817            },
2818            Loose::Bad(_) => {
2819                self.diags.error(
2820                    Code::InvalidField,
2821                    file,
2822                    line,
2823                    format!("report `{report}`: `queries` entries must be names or maps"),
2824                );
2825                return None;
2826            }
2827        };
2828        if name.ends_with(".sql") || name.contains('/') || name.contains('\\') {
2829            self.diags.error(
2830                Code::InvalidQueryName,
2831                file,
2832                line,
2833                format!(
2834                    "report `{report}`: query `{name}` must be a bare .sql basename, with no path and no extension"
2835                ),
2836            );
2837            return None;
2838        }
2839        let mut e = QueryEntry {
2840            query: name.clone(),
2841            path: PathBuf::new(),
2842            profile: None,
2843            tab: true,
2844            tab_name: None,
2845            anchor: None,
2846            header: None,
2847            columns: BTreeMap::new(),
2848            autofit: None,
2849            style: None,
2850        };
2851        if let Some(m) = m {
2852            for k in &m.unknown.0 {
2853                self.diags.error(
2854                    Code::UnknownKey,
2855                    file.clone(),
2856                    Some(k.line),
2857                    format!("report `{report}`: unknown key `{}` on query `{name}`", k.name),
2858                );
2859            }
2860            match &m.profile {
2861                None => {}
2862                Some(Loose::Ok(p)) if !p.trim().is_empty() => e.profile = Some(p.clone()),
2863                Some(_) => self.diags.error(
2864                    Code::InvalidField,
2865                    file.clone(),
2866                    line,
2867                    format!("report `{report}`: `profile` of `{name}` must be a connection name"),
2868                ),
2869            }
2870            e.tab_name = match &m.tab_name {
2871                None => None,
2872                Some(Loose::Ok(s)) => Some(s.clone()),
2873                Some(Loose::Bad(f)) if f.kind == "a list" => {
2874                    self.diags.error(
2875                        Code::InvalidField,
2876                        file.clone(),
2877                        line,
2878                        format!("report `{report}`: {}", one_tab_per_file(&name)),
2879                    );
2880                    None
2881                }
2882                Some(Loose::Bad(_)) => {
2883                    self.diags.error(
2884                        Code::InvalidField,
2885                        file.clone(),
2886                        line,
2887                        format!("report `{report}`: `tab_name` of `{name}` must be a string"),
2888                    );
2889                    None
2890                }
2891            };
2892            match &m.tab {
2893                None => {}
2894                Some(Loose::Ok(b)) => e.tab = *b,
2895                Some(Loose::Bad(_)) => self.diags.error(
2896                    Code::InvalidField,
2897                    file.clone(),
2898                    line,
2899                    format!("report `{report}`: `tab` of `{name}` must be true or false"),
2900                ),
2901            }
2902            if !e.tab && e.tab_name.is_some() {
2903                self.diags.error(
2904                    Code::InvalidField,
2905                    file.clone(),
2906                    line,
2907                    format!("report `{report}`: `{name}` has `tab: false`, so its `tab_name` would never be used; remove one of them"),
2908                );
2909            }
2910            if let Some(a) = &m.anchor {
2911                match a {
2912                    Loose::Ok(s) if options::is_cell(s) => e.anchor = Some(s.clone()),
2913                    _ => self.diags.error(
2914                        Code::InvalidCell,
2915                        file.clone(),
2916                        line,
2917                        format!("report `{report}`: `anchor` of `{name}` must be a cell reference like `A1`"),
2918                    ),
2919                }
2920            }
2921            if let Some(h) = &m.header {
2922                match h {
2923                    Loose::Ok(b) => e.header = Some(*b),
2924                    Loose::Bad(_) => self.diags.error(
2925                        Code::InvalidField,
2926                        file.clone(),
2927                        line,
2928                        format!("report `{report}`: `header` of `{name}` must be true or false"),
2929                    ),
2930                }
2931            }
2932            if let Some(a) = &m.autofit {
2933                match a {
2934                    Loose::Ok(b) => e.autofit = Some(*b),
2935                    Loose::Bad(_) => self.diags.error(
2936                        Code::InvalidField,
2937                        file.clone(),
2938                        line,
2939                        format!("report `{report}`: `autofit` of `{name}` must be true or false"),
2940                    ),
2941                }
2942            }
2943            if let Some(st) = &m.style {
2944                let (style, errs) = dre_protocol::style::parse_sheet(st);
2945                for err in errs {
2946                    self.diags.error(
2947                        Code::InvalidField,
2948                        file.clone(),
2949                        line,
2950                        format!("report `{report}`: query `{name}`: `style`: {err}"),
2951                    );
2952                }
2953                e.style = Some(style);
2954            }
2955            if let Some(c) = &m.columns {
2956                let (columns, errs) = dre_protocol::options::parse_columns(c);
2957                for err in errs {
2958                    self.diags.error(
2959                        Code::InvalidField,
2960                        file.clone(),
2961                        line,
2962                        format!("report `{report}`: query `{name}`: {err}"),
2963                    );
2964                }
2965                e.columns = columns;
2966            }
2967        }
2968        match index.get(&name) {
2969            Some(paths) => {
2970                e.path = paths[0].clone();
2971                Some(e)
2972            }
2973            None => {
2974                self.diags.error(
2975                    Code::UnknownQuery,
2976                    file,
2977                    line,
2978                    format!("report `{report}`: query `{name}` doesn't match any .sql file under reports/"),
2979                );
2980                None
2981            }
2982        }
2983    }
2984
2985    // -- resolving a managed report -------------------------------------------------------------
2986
2987    fn resolve_managed(
2988        &mut self,
2989        r: &RawReport,
2990        queries: Vec<QueryEntry>,
2991        project: &Project,
2992        folders: &BTreeMap<Vec<String>, FolderCfg>,
2993        used: &mut Usage,
2994    ) -> Option<Report> {
2995        let name = r.name.clone();
2996        let layers = folder_layers(folders, &r.folder);
2997        let key = |k: &str| r.keys.get(k);
2998        let located = |k: &str| {
2999            r.keys
3000                .get(k)
3001                .map(|l| (l.file.display.clone(), r.lines.get(k).copied().flatten()))
3002        };
3003        let typed = &r.typed;
3004
3005        let mut tags: Vec<String> = layers.iter().flat_map(|l| l.tags.clone()).collect();
3006        if let Some(t) = &typed.tags {
3007            match &t.value {
3008                Loose::Ok(l) => tags.extend(l.iter().cloned()),
3009                Loose::Bad(_) => {
3010                    let (f, l) = located("tags").unwrap();
3011                    self.diags.error(
3012                        Code::InvalidField,
3013                        Some(f),
3014                        l,
3015                        format!("report `{name}`: `tags` must be a list of strings"),
3016                    );
3017                }
3018            }
3019        }
3020        dedup(&mut tags);
3021
3022        if key("profile").is_some() && key("sets").is_some() {
3023            let (f, l) = located("sets").unwrap();
3024            self.diags.error(
3025                Code::ProfileAndSets,
3026                Some(f),
3027                l,
3028                format!("report `{name}` declares both `profile:` and `sets:`; use one or the other"),
3029            );
3030        }
3031
3032        let report_profile = match &typed.profile {
3033            Some(p) => match &p.value {
3034                Loose::Ok(s) => {
3035                    let (f, l) = located("profile").unwrap();
3036                    used.connection(s, Some(f), l);
3037                    Some(s.to_string())
3038                }
3039                Loose::Bad(_) => {
3040                    let (f, l) = located("profile").unwrap();
3041                    self.diags.error(
3042                        Code::InvalidField,
3043                        Some(f),
3044                        l,
3045                        format!("report `{name}`: `profile` must be a string"),
3046                    );
3047                    None
3048                }
3049            },
3050            None => None,
3051        };
3052        let folder_profile = layers
3053            .iter()
3054            .rev()
3055            .find_map(|l| l.profile.as_ref().map(|p| p.0.clone()));
3056        let report_timezone = match &typed.timezone {
3057            Some(t) => {
3058                let (f, l) = located("timezone").unwrap();
3059                self.timezone_str(
3060                    t.value.ok().map(String::as_str),
3061                    &f,
3062                    l,
3063                    &format!("report `{name}`: `timezone`"),
3064                )
3065            }
3066            None => None,
3067        };
3068        let timezone = report_timezone
3069            .or_else(|| layers.iter().rev().find_map(|l| l.timezone.clone()))
3070            .or_else(|| project.timezone.clone());
3071        let report_locale = match &typed.locale {
3072            Some(t) => {
3073                let (f, l) = located("locale").unwrap();
3074                self.locale_str(
3075                    t.value.ok().map(String::as_str),
3076                    &f,
3077                    l,
3078                    &format!("report `{name}`: `locale`"),
3079                )
3080            }
3081            None => None,
3082        };
3083        let locale = report_locale
3084            .or_else(|| layers.iter().rev().find_map(|l| l.locale.clone()))
3085            .or_else(|| project.locale.clone());
3086        let profile_at = if report_profile.is_some() {
3087            located("profile").map(|(file, line)| ProfileAt {
3088                file,
3089                line,
3090                key: "`profile`".into(),
3091            })
3092        } else {
3093            inherited_profile_at(&layers, project)
3094        };
3095        let base_profile = report_profile
3096            .or(folder_profile)
3097            .or(project.default_profile.clone());
3098
3099        let mut vars = project.vars.clone();
3100        for l in &layers {
3101            if let Some(v) = &l.vars {
3102                vars.extend(v.clone());
3103            }
3104        }
3105        if let Some(v) = &typed.vars {
3106            match &v.value {
3107                Loose::Ok(m) => vars.extend(m.clone()),
3108                Loose::Bad(_) => {
3109                    let (f, l) = located("vars").unwrap();
3110                    self.diags.error(
3111                        Code::InvalidField,
3112                        Some(f),
3113                        l,
3114                        format!("report `{name}`: `vars` must be a map"),
3115                    );
3116                }
3117            }
3118        }
3119
3120        let mut output = builtin_output();
3121        let mut merge_problems = Vec::new();
3122        if let Some(o) = project_default_output(project) {
3123            merge_problems.extend(merge_output(&mut output, &o));
3124        }
3125        for l in &layers {
3126            if let Some(o) = &l.output {
3127                merge_problems.extend(merge_output(&mut output, o));
3128            }
3129        }
3130        for p in merge_problems {
3131            self.diags.error(
3132                Code::InvalidField,
3133                Some(r.file.display.clone()),
3134                None,
3135                format!("report `{name}`: folder config: {p}"),
3136            );
3137        }
3138        let mut outputs = vec![output];
3139        if let Some(o) = key("output") {
3140            for p in layer_outputs(&mut outputs, &o.value) {
3141                let (f, l) = located("output").unwrap();
3142                self.diags
3143                    .error(Code::InvalidField, Some(f), l, format!("report `{name}`: {p}"));
3144            }
3145        }
3146
3147        if typed.schedule.is_some() {
3148            let (f, l) = located("schedule").unwrap();
3149            self.moved_to_schedules(&f, l, &format!("report `{name}`: `schedule`"));
3150        }
3151
3152        let default_set = typed.default_set.as_ref().and_then(|d| d.value.ok().cloned());
3153
3154        let base = BindingBase {
3155            profile: base_profile,
3156            profile_at,
3157            vars,
3158            outputs,
3159            locale,
3160        };
3161        let has_sets = key("sets").is_some();
3162        let report_base = self.silent_binding(&name, &base, &queries, &r.file.display);
3163        let mut bindings = Vec::new();
3164        if let (Some(s), Some(at)) = (&typed.sets, key("sets")) {
3165            bindings = self.resolve_sets(&name, s, &at.file.display, &queries, &base, project, used);
3166            let declared: Vec<&str> = bindings.iter().filter_map(|b| b.set.as_deref()).collect();
3167            if let Some(d) = &default_set
3168                && !declared.contains(&d.as_str())
3169            {
3170                let (f, l) = located("default_set").unwrap();
3171                self.diags.error(
3172                    Code::UnknownDefaultSet,
3173                    Some(f),
3174                    l,
3175                    format!("report `{name}`: `default_set` `{d}` isn't one of the report's `sets:`"),
3176                );
3177            }
3178        } else {
3179            if default_set.is_some() {
3180                let (f, l) = located("default_set").unwrap();
3181                self.diags.error(
3182                    Code::UnknownDefaultSet,
3183                    Some(f),
3184                    l,
3185                    format!("report `{name}`: `default_set` needs a `sets:` list"),
3186                );
3187            }
3188            let b = self.finish_binding(
3189                &name,
3190                None,
3191                &base,
3192                None,
3193                queries.clone(),
3194                r.file.display.clone(),
3195                used,
3196            );
3197            bindings.push(b);
3198        }
3199
3200        Some(Report {
3201            name,
3202            managed: true,
3203            file: r.file.display.clone(),
3204            folder: r.folder.clone(),
3205            tags,
3206            queries,
3207            default_set,
3208            timezone,
3209            has_sets,
3210            bindings,
3211            base: report_base,
3212        })
3213    }
3214
3215    #[allow(clippy::too_many_arguments)]
3216    fn resolve_sets(
3217        &mut self,
3218        report: &str,
3219        sets: &de::Located<Loose<Vec<SetItem>>>,
3220        display: &Path,
3221        queries: &[QueryEntry],
3222        base: &BindingBase,
3223        project: &Project,
3224        used: &mut Usage,
3225    ) -> Vec<Binding> {
3226        let file = Some(display.to_path_buf());
3227        let sets_line = sets.line();
3228        let Loose::Ok(items) = &sets.value else {
3229            self.diags.error(
3230                Code::InvalidField,
3231                file,
3232                sets_line,
3233                format!("report `{report}`: `sets` must be a list"),
3234            );
3235            return Vec::new();
3236        };
3237        let mut seen = BTreeSet::new();
3238        let mut out = Vec::new();
3239        for item in items {
3240            let line = item.line();
3241            let (name, inline): (String, Option<&SetEntry>) = match &item.value {
3242                Loose::Ok(de::OneOf::A(s)) => (s.clone(), None),
3243                Loose::Ok(de::OneOf::B(m)) => match &m.name {
3244                    Some(Loose::Ok(n)) => (n.clone(), Some(m)),
3245                    _ => {
3246                        self.diags.error(
3247                            Code::InvalidField,
3248                            file.clone(),
3249                            line,
3250                            format!("report `{report}`: a `sets:` map entry needs a `name:`"),
3251                        );
3252                        continue;
3253                    }
3254                },
3255                Loose::Bad(_) => {
3256                    self.diags.error(
3257                        Code::InvalidField,
3258                        file.clone(),
3259                        line,
3260                        format!("report `{report}`: `sets:` entries must be names or maps"),
3261                    );
3262                    continue;
3263                }
3264            };
3265            if !seen.insert(name.clone()) {
3266                self.diags.error(
3267                    Code::DuplicateSet,
3268                    file.clone(),
3269                    line,
3270                    format!("report `{report}` lists Set `{name}` more than once"),
3271                );
3272                continue;
3273            }
3274            let registry = project.sets.get(&name);
3275            if inline.is_none() && registry.is_none() {
3276                self.diags.error(
3277                    Code::UnknownSet,
3278                    file.clone(),
3279                    line,
3280                    format!("report `{report}`: Set `{name}` isn't declared in sets.yml"),
3281                );
3282                continue;
3283            }
3284            let mut b = BindingBase {
3285                profile: base.profile.clone(),
3286                profile_at: base.profile_at.clone(),
3287                vars: base.vars.clone(),
3288                outputs: base.outputs.clone(),
3289                locale: base.locale.clone(),
3290            };
3291            if let Some(reg) = registry {
3292                if let Some(p) = &reg.profile {
3293                    b.profile = Some(p.clone());
3294                    b.profile_at = Some(ProfileAt {
3295                        file: reg.file.clone(),
3296                        line: reg.line,
3297                        key: format!("`profile` of Set `{name}`"),
3298                    });
3299                    used.connection(p, None, None);
3300                }
3301                b.vars.extend(reg.vars.clone());
3302                if reg.locale.is_some() {
3303                    b.locale = reg.locale.clone();
3304                }
3305            }
3306            let mut qs = queries.to_vec();
3307            let mut tab_names: Option<Mapping> = None;
3308            if let Some(m) = inline {
3309                let ctx = format!("report `{report}`, Set `{name}`");
3310                for k in &m.unknown.0 {
3311                    self.diags.error(
3312                        Code::UnknownKey,
3313                        file.clone(),
3314                        Some(k.line),
3315                        format!("{ctx}: unknown key `{}`", k.name),
3316                    );
3317                }
3318                if let Some(p) = &m.profile {
3319                    let pline = p.line();
3320                    match &p.value {
3321                        Loose::Ok(p) => {
3322                            b.profile = Some(p.to_string());
3323                            b.profile_at = Some(ProfileAt {
3324                                file: display.to_path_buf(),
3325                                line: pline,
3326                                key: format!("`profile` of Set `{name}`"),
3327                            });
3328                            used.connection(p, file.clone(), pline);
3329                        }
3330                        Loose::Bad(_) => self.diags.error(
3331                            Code::InvalidField,
3332                            file.clone(),
3333                            pline,
3334                            format!("{ctx}: `profile` must be a string"),
3335                        ),
3336                    }
3337                }
3338                match &m.vars {
3339                    Some(Loose::Ok(v)) => b.vars.extend(v.clone()),
3340                    None => {}
3341                    Some(Loose::Bad(_)) => self.diags.error(
3342                        Code::InvalidField,
3343                        file.clone(),
3344                        line,
3345                        format!("{ctx}: `vars` must be a map"),
3346                    ),
3347                }
3348                if let Some(o) = &m.output {
3349                    for p in layer_outputs(&mut b.outputs, o) {
3350                        self.diags
3351                            .error(Code::InvalidField, file.clone(), line, format!("{ctx}: {p}"));
3352                    }
3353                }
3354                if m.schedule.is_some() {
3355                    self.moved_to_schedules(display, line, &format!("{ctx}: `schedule`"));
3356                }
3357                if let Some(l) = &m.locale
3358                    && let Some(l) = self.locale_str(
3359                        l.ok().map(String::as_str),
3360                        display,
3361                        line,
3362                        &format!("{ctx}: `locale`"),
3363                    )
3364                {
3365                    b.locale = Some(l);
3366                }
3367                let listed: Vec<&str> = queries.iter().map(|q| q.query.as_str()).collect();
3368                match (&m.exclude, &m.queries) {
3369                    (Some(_), Some(_)) => self.diags.error(
3370                        Code::ExcludeAndQueries,
3371                        file.clone(),
3372                        line,
3373                        format!("{ctx}: use either `exclude:` or `queries:`, not both"),
3374                    ),
3375                    (Some(ex), None) => match &ex.value {
3376                        Loose::Ok(names) => {
3377                            for q in names {
3378                                if !listed.contains(&q.as_str()) {
3379                                    self.diags.error(
3380                                        Code::UnknownQuery,
3381                                        file.clone(),
3382                                        ex.line(),
3383                                        format!("{ctx}: `exclude` names `{q}`, which isn't in the report's `queries:`"),
3384                                    );
3385                                }
3386                            }
3387                            qs.retain(|q| !names.contains(&q.query));
3388                        }
3389                        Loose::Bad(_) => self.diags.error(
3390                            Code::InvalidField,
3391                            file.clone(),
3392                            line,
3393                            format!("{ctx}: `exclude` must be a list of query names"),
3394                        ),
3395                    },
3396                    (None, Some(ov)) => match &ov.value {
3397                        Loose::Ok(items) => {
3398                            let mut sub = Vec::new();
3399                            for it in items {
3400                                let (qn, settings) = match it {
3401                                    Loose::Ok(de::OneOf::A(s)) => (s.clone(), None),
3402                                    Loose::Ok(de::OneOf::B(e)) => {
3403                                        match e.query.as_ref().and_then(Loose::ok) {
3404                                            Some(q) => (q.clone(), Some(e)),
3405                                            None => continue,
3406                                        }
3407                                    }
3408                                    Loose::Bad(_) => continue,
3409                                };
3410                                match queries.iter().find(|q| q.query == qn) {
3411                                    Some(q) => {
3412                                        let mut q = q.clone();
3413                                        if let Some(e) = settings {
3414                                            match &e.tab_name {
3415                                                None => {}
3416                                                Some(Loose::Ok(s)) => q.tab_name = Some(s.clone()),
3417                                                Some(Loose::Bad(_)) => self.diags.error(
3418                                                    Code::InvalidField,
3419                                                    file.clone(),
3420                                                    line,
3421                                                    format!("{ctx}: {}", one_tab_per_file(&qn)),
3422                                                ),
3423                                            }
3424                                            if let Some(Loose::Ok(b)) = &e.tab {
3425                                                q.tab = *b;
3426                                            }
3427                                        }
3428                                        sub.push(q);
3429                                    }
3430                                    None => self.diags.error(
3431                                        Code::UnknownQuery,
3432                                        file.clone(),
3433                                        ov.line(),
3434                                        format!("{ctx}: `queries` names `{qn}`, which isn't in the report's `queries:`"),
3435                                    ),
3436                                }
3437                            }
3438                            qs = sub;
3439                        }
3440                        Loose::Bad(_) => self.diags.error(
3441                            Code::InvalidField,
3442                            file.clone(),
3443                            line,
3444                            format!("{ctx}: `queries` must be a list"),
3445                        ),
3446                    },
3447                    (None, None) => {}
3448                }
3449                tab_names = match &m.tab_names {
3450                    None => None,
3451                    Some(Loose::Ok(t)) => Some(t.clone()),
3452                    Some(Loose::Bad(_)) => {
3453                        self.diags.error(
3454                            Code::InvalidField,
3455                            file.clone(),
3456                            line,
3457                            format!("{ctx}: `tab_names` must be a map of query name to tab name"),
3458                        );
3459                        None
3460                    }
3461                };
3462            }
3463            let bind = self.finish_binding(
3464                report,
3465                Some(name),
3466                &b,
3467                tab_names.as_ref(),
3468                qs,
3469                display.to_path_buf(),
3470                used,
3471            );
3472            out.push(bind);
3473        }
3474        out
3475    }
3476
3477    /// Resolve a Binding without recording diagnostics or usage (they're reported elsewhere).
3478    fn silent_binding(
3479        &mut self,
3480        report: &str,
3481        base: &BindingBase,
3482        queries: &[QueryEntry],
3483        file: &Path,
3484    ) -> Binding {
3485        let saved = std::mem::take(&mut self.diags);
3486        let mut scratch = Usage::default();
3487        let b = self.finish_binding(
3488            report,
3489            None,
3490            base,
3491            None,
3492            queries.to_vec(),
3493            file.to_path_buf(),
3494            &mut scratch,
3495        );
3496        self.diags = saved;
3497        b
3498    }
3499
3500    #[allow(clippy::too_many_arguments)]
3501    fn finish_binding(
3502        &mut self,
3503        report: &str,
3504        set: Option<String>,
3505        base: &BindingBase,
3506        tab_names: Option<&Mapping>,
3507        mut queries: Vec<QueryEntry>,
3508        file: PathBuf,
3509        used: &mut Usage,
3510    ) -> Binding {
3511        let ctx = match &set {
3512            Some(s) => format!("report `{report}`, Set `{s}`"),
3513            None => format!("report `{report}`"),
3514        };
3515        if let Some(t) = tab_names {
3516            for (k, v) in t {
3517                let q = k.as_str();
3518                match queries.iter_mut().find(|e| e.query == q) {
3519                    Some(e) => match v {
3520                        Value::String(s) => e.tab_name = Some(s.clone()),
3521                        _ => self.diags.error(
3522                            Code::InvalidField,
3523                            Some(file.clone()),
3524                            None,
3525                            format!("{ctx}: {}", one_tab_per_file(q)),
3526                        ),
3527                    },
3528                    None => self.diags.error(
3529                        Code::UnknownQuery,
3530                        Some(file.clone()),
3531                        None,
3532                        format!("{ctx}: `tab_names` names `{q}`, which isn't one of this Binding's queries"),
3533                    ),
3534                }
3535            }
3536        }
3537        let outputs = self.typed_outputs(&base.outputs, &ctx, &file, &queries, used);
3538        if let Some(p) = &base.profile {
3539            used.connection(p, None, None);
3540        }
3541        Binding {
3542            set,
3543            profile: base.profile.clone(),
3544            vars: base.vars.clone(),
3545            queries: std::mem::take(&mut queries),
3546            locale: base.locale.clone(),
3547            outputs,
3548            schedules: Vec::new(),
3549            parsed: None,
3550            profile_at: base.profile_at.clone(),
3551        }
3552    }
3553
3554    /// Every merged output, typed, plus the checks across them: unique names, `queries:` naming
3555    /// the Binding's queries, xlsx-only `columns`, and queries that feed no output.
3556    fn typed_outputs(
3557        &mut self,
3558        maps: &[Mapping],
3559        ctx: &str,
3560        file: &Path,
3561        queries: &[QueryEntry],
3562        used: &mut Usage,
3563    ) -> Vec<Output> {
3564        let at = Some(file.to_path_buf());
3565        let several = maps.len() > 1;
3566        let mut outputs = Vec::new();
3567        for (i, m) in maps.iter().enumerate() {
3568            let octx = if several {
3569                format!(
3570                    "{ctx}, {}",
3571                    output_label(m.get("name").and_then(Value::as_str), i)
3572                )
3573            } else {
3574                ctx.to_string()
3575            };
3576            let subset: Vec<QueryEntry> = match m.get("queries") {
3577                None | Some(Value::Null) => queries.to_vec(),
3578                Some(v) => {
3579                    match string_list(v) {
3580                        Some(names) => {
3581                            for n in names.iter().filter(|n| !queries.iter().any(|q| &&q.query == n)) {
3582                                self.diags.error(
3583                                Code::UnknownQuery,
3584                                at.clone(),
3585                                None,
3586                                format!("{octx}: `queries` names `{n}`, which isn't one of this Binding's queries"),
3587                            );
3588                            }
3589                            queries
3590                                .iter()
3591                                .filter(|q| names.contains(&q.query))
3592                                .cloned()
3593                                .collect()
3594                        }
3595                        None => {
3596                            self.diags.error(
3597                                Code::InvalidField,
3598                                at.clone(),
3599                                None,
3600                                format!("{octx}: `queries` must be a list of query names"),
3601                            );
3602                            queries.to_vec()
3603                        }
3604                    }
3605                }
3606            };
3607            outputs.push(self.typed_output(m, &octx, file, &subset, used));
3608        }
3609        let mut seen = BTreeSet::new();
3610        for o in &outputs {
3611            if let Some(n) = &o.name
3612                && !seen.insert(n.clone())
3613            {
3614                self.diags.error(
3615                    Code::DuplicateOutput,
3616                    at.clone(),
3617                    None,
3618                    format!("{ctx}: output `{n}` is declared twice; output names must be unique"),
3619                );
3620            }
3621        }
3622        // Two unnamed outputs of one format would write the same default file.
3623        let mut exts = BTreeSet::new();
3624        for o in outputs.iter().filter(|o| o.name.is_none()) {
3625            let ext = o.extension.clone().unwrap_or_else(|| o.format.clone());
3626            if !exts.insert(ext) {
3627                self.diags.error(
3628                    Code::DuplicateOutput,
3629                    at.clone(),
3630                    None,
3631                    format!(
3632                        "{ctx}: two unnamed `{}` outputs would write the same file; give each output a `name:`",
3633                        o.format
3634                    ),
3635                );
3636            }
3637        }
3638        for q in queries.iter().filter(|q| !q.columns.is_empty()) {
3639            let formats: Vec<&str> = outputs
3640                .iter()
3641                .filter(|o| o.feeds(&q.query))
3642                .map(|o| o.format.as_str())
3643                .collect();
3644            if !formats.is_empty() && !formats.contains(&"xlsx") {
3645                self.diags.error(
3646                    Code::InvalidField,
3647                    at.clone(),
3648                    None,
3649                    format!(
3650                        "{ctx}: `columns` on query `{}` only applies to the xlsx format",
3651                        q.query
3652                    ),
3653                );
3654            }
3655        }
3656        for (i, o) in outputs.iter().enumerate() {
3657            let octx = if several {
3658                format!("{ctx}, {}", o.label(i))
3659            } else {
3660                ctx.to_string()
3661            };
3662            self.check_output_templates(o, &outputs, &octx, file, queries);
3663            for d in o.destinations.iter().filter(|d| !d.attach.is_empty()) {
3664                for a in &d.attach {
3665                    let problem = match outputs.iter().find(|x| x.name.as_deref() == Some(a.as_str())) {
3666                        _ if !o.is_message() => Some("`attach` only applies to a message output".to_string()),
3667                        None => Some(format!("no output of this report is named `{a}`")),
3668                        Some(x) if std::ptr::eq(x, o) => Some(format!("`{a}` is this output itself")),
3669                        Some(x) if x.is_message() => Some(format!(
3670                            "`{a}` is a message; attach file outputs (its `.md` is linked with `outputs.{a}.location`)"
3671                        )),
3672                        Some(_) => None,
3673                    };
3674                    if let Some(p) = problem {
3675                        self.diags.error(
3676                            Code::InvalidDestinationOption,
3677                            at.clone(),
3678                            None,
3679                            format!("{octx}: destination `{}`: `attach: {a}`: {p}", d.profile),
3680                        );
3681                    }
3682                }
3683            }
3684        }
3685        for q in queries.iter().filter(|q| q.tab) {
3686            if !outputs.iter().any(|o| o.feeds(&q.query)) {
3687                self.diags.warning(
3688                    Code::UnusedQuery,
3689                    at.clone(),
3690                    None,
3691                    format!(
3692                        "{ctx}: query `{}` feeds no output; add it to an output's `queries:`, or give it `tab: false` if it only prepares later queries",
3693                        q.query
3694                    ),
3695                );
3696            }
3697        }
3698        outputs
3699    }
3700
3701    /// A message output's options, and the templates of any output (`text`, `file`, `title`,
3702    /// `when`): they compile, `results.<x>` names one of the output's queries and `outputs.<x>`
3703    /// another output. Only attribute access is checked; dynamic access isn't seen.
3704    fn check_output_templates(
3705        &mut self,
3706        o: &Output,
3707        all: &[Output],
3708        ctx: &str,
3709        file: &Path,
3710        queries: &[QueryEntry],
3711    ) {
3712        let at = Some(file.to_path_buf());
3713        let err = |s: &mut Self, msg: String| {
3714            s.diags.error(
3715                Code::InvalidOutputOption,
3716                at.clone(),
3717                None,
3718                format!("{ctx}: {msg}"),
3719            )
3720        };
3721        // (what, source) of every template to check.
3722        let mut templates: Vec<(String, String)> = Vec::new();
3723        if let Some(w) = &o.when {
3724            templates.push(("`when`".into(), format!("{{% if {w} %}}{{% endif %}}")));
3725        }
3726        if o.is_message() {
3727            for k in o.options.keys() {
3728                if !crate::message::MESSAGE_KEYS.contains(&k.as_str()) {
3729                    err(
3730                        self,
3731                        format!(
3732                            "`{k}` isn't an option of the `message` format; its options are {}",
3733                            crate::message::MESSAGE_KEYS.join(", ")
3734                        ),
3735                    );
3736                }
3737            }
3738            if o.options.contains_key("text") && o.options.contains_key("file") {
3739                err(self, "use either `text:` or `file:`, not both".into());
3740            }
3741            for k in ["text", "title"] {
3742                match o.options.get(k) {
3743                    None => {}
3744                    Some(Json::String(t)) => templates.push((format!("`{k}`"), t.clone())),
3745                    Some(_) => err(self, format!("`{k}` must be a string")),
3746                }
3747            }
3748            match o.options.get("file") {
3749                None => {}
3750                Some(Json::String(f)) => match find_template(&self.root, f).map(std::fs::read_to_string) {
3751                    Some(Ok(t)) => templates.push((format!("`file` {f}"), t)),
3752                    Some(Err(e)) => err(self, format!("can't read message file `{f}`: {e}")),
3753                    None => err(
3754                        self,
3755                        format!(
3756                            "message file `{f}` doesn't exist (looked in the project root and templates/)"
3757                        ),
3758                    ),
3759                },
3760                Some(_) => err(self, "`file` must be a path (a string)".into()),
3761            }
3762            match o.options.get("max_rows") {
3763                None => {}
3764                Some(v) if v.as_u64().is_some_and(|n| n > 0) => {}
3765                Some(_) => err(self, "`max_rows` must be a positive whole number".into()),
3766            }
3767        }
3768        let fed: Vec<&str> = queries
3769            .iter()
3770            .filter(|q| q.tab && o.feeds(&q.query))
3771            .map(|q| q.query.as_str())
3772            .collect();
3773        for (what, src) in &templates {
3774            if let Err((line, msg)) = preflight::check_syntax("output", src) {
3775                let line = line.map(|l| format!(" (line {l})")).unwrap_or_default();
3776                err(self, format!("{what} doesn't compile{line}: {msg}"));
3777                continue;
3778            }
3779            for (name, _) in preflight::attributes(src, "results") {
3780                if !fed.contains(&name.as_str()) {
3781                    err(
3782                        self,
3783                        format!(
3784                            "{what} reads `results.{name}`, but `{name}` isn't one of this output's queries ({})",
3785                            if fed.is_empty() {
3786                                "none".to_string()
3787                            } else {
3788                                fed.join(", ")
3789                            }
3790                        ),
3791                    );
3792                }
3793            }
3794            if !o.is_message() && !preflight::attributes(src, "outputs").is_empty() {
3795                err(
3796                    self,
3797                    format!(
3798                        "{what} reads `outputs.*`, but a file output's `when` is decided before anything is delivered; use `outputs.*` in a message output"
3799                    ),
3800                );
3801                continue;
3802            }
3803            for (name, _) in preflight::attributes(src, "outputs") {
3804                let other = all.iter().find(|x| x.name.as_deref() == Some(name.as_str()));
3805                match other {
3806                    None => err(
3807                        self,
3808                        format!(
3809                            "{what} reads `outputs.{name}`, but no output of this report is named `{name}`"
3810                        ),
3811                    ),
3812                    Some(x) if std::ptr::eq(x, o) => err(
3813                        self,
3814                        format!("{what} reads `outputs.{name}`, which is this output itself"),
3815                    ),
3816                    Some(_) => {}
3817                }
3818            }
3819        }
3820    }
3821
3822    /// Convert a merged output map into a typed `Output`, validating options and references.
3823    fn typed_output(
3824        &mut self,
3825        m: &Mapping,
3826        ctx: &str,
3827        file: &Path,
3828        queries: &[QueryEntry],
3829        used: &mut Usage,
3830    ) -> Output {
3831        let file_path = file.to_path_buf();
3832        let file = Some(file_path.clone());
3833        let node = crate::yaml::to_node(&Value::Object(m.clone()));
3834        let o: OutputConfig = match de::from_node(&node) {
3835            Ok(o) => o,
3836            // Every key is `Loose`, so a map always reads.
3837            Err(e) => unreachable!("an output map reads as an output: {e}"),
3838        };
3839        let format = o
3840            .format
3841            .as_ref()
3842            .and_then(Loose::ok)
3843            .cloned()
3844            .unwrap_or_else(|| "csv".to_string());
3845        // `message` is built in; every other format is a plugin.
3846        if format != MESSAGE_FORMAT {
3847            used.format(&format, ctx, &file_path);
3848        }
3849        let mut opts: JsonMap<String, Json> = o.options.0.into_iter().map(|(k, v)| (k.value, v)).collect();
3850        // The project's defaults for this format sit under whatever the layers set.
3851        if let Some(Value::Object(d)) = self.format_options.get(format.as_str()) {
3852            for (k, v) in d {
3853                if !OUTPUT_SHARED_KEYS.contains(&k.as_str()) {
3854                    opts.entry(k.clone()).or_insert_with(|| v.clone());
3855                }
3856            }
3857        }
3858        let destinations = match o.destination {
3859            None => Vec::new(),
3860            Some(Loose::Ok(de::OneOf::A(d))) => {
3861                self.typed_destination(d, ctx, &file, used).into_iter().collect()
3862            }
3863            Some(Loose::Ok(de::OneOf::B(list))) if list.is_empty() => {
3864                self.diags.error(
3865                    Code::InvalidField,
3866                    file.clone(),
3867                    None,
3868                    format!(
3869                        "{ctx}: `output.destination` is an empty list; name at least one destination, or remove it to keep the output in target/"
3870                    ),
3871                );
3872                Vec::new()
3873            }
3874            Some(Loose::Ok(de::OneOf::B(list))) => {
3875                let mut out = Vec::new();
3876                for (i, d) in list.into_iter().enumerate() {
3877                    match d {
3878                        Loose::Ok(d) => out.extend(self.typed_destination(d, ctx, &file, used)),
3879                        Loose::Bad(_) => self.diags.error(
3880                            Code::InvalidField,
3881                            file.clone(),
3882                            None,
3883                            format!("{ctx}: `output.destination` entry {} must be a map", i + 1),
3884                        ),
3885                    }
3886                }
3887                out
3888            }
3889            Some(Loose::Bad(_)) => {
3890                self.diags.error(
3891                    Code::InvalidField,
3892                    file.clone(),
3893                    None,
3894                    format!("{ctx}: `output.destination` must be a map or a list of maps"),
3895                );
3896                Vec::new()
3897            }
3898        };
3899        let template = match o.template {
3900            None => None,
3901            Some(t) => {
3902                if format != "xlsx" {
3903                    self.diags.error(
3904                        Code::InvalidOutputOption,
3905                        file.clone(),
3906                        None,
3907                        format!("{ctx}: `output.template` only applies to the xlsx format"),
3908                    );
3909                }
3910                self.typed_template(t, ctx, &file, queries)
3911            }
3912        };
3913        let extension = match o.extension {
3914            de::Maybe::Absent => None,
3915            de::Maybe::Given(Loose::Ok(None | Some(de::OneOf::B(false)))) => Some(String::new()),
3916            de::Maybe::Given(Loose::Ok(Some(de::OneOf::A(e)))) => {
3917                let e = e.strip_prefix('.').unwrap_or(&e).to_string();
3918                if e.contains(['/', '\\']) || e.chars().any(char::is_whitespace) {
3919                    self.diags.error(
3920                        Code::InvalidOutputOption,
3921                        file.clone(),
3922                        None,
3923                        format!(
3924                            "{ctx}: `extension` must be a file extension like `aba` (no path, no spaces)"
3925                        ),
3926                    );
3927                }
3928                Some(e)
3929            }
3930            de::Maybe::Given(_) => {
3931                self.diags.error(
3932                    Code::InvalidOutputOption,
3933                    file.clone(),
3934                    None,
3935                    format!("{ctx}: `extension` must be a string (`aba`), or `\"\"`/`false` for none"),
3936                );
3937                None
3938            }
3939        };
3940        let name = match o.name {
3941            None => None,
3942            Some(Loose::Ok(n)) if is_identifier(&n) => Some(n),
3943            Some(_) => {
3944                self.diags.error(
3945                    Code::InvalidField,
3946                    file.clone(),
3947                    None,
3948                    format!("{ctx}: output `name` must be an identifier (letters, digits, `_`)"),
3949                );
3950                None
3951            }
3952        };
3953        let when = match o.when {
3954            None => None,
3955            Some(Loose::Ok(de::OneOf::A(w))) => Some(w),
3956            Some(Loose::Ok(de::OneOf::B(b))) => Some(b.to_string()),
3957            Some(Loose::Bad(_)) => {
3958                self.diags.error(
3959                    Code::InvalidField,
3960                    file.clone(),
3961                    None,
3962                    format!("{ctx}: `when` must be a Jinja expression (a string)"),
3963                );
3964                None
3965            }
3966        };
3967        let queries = match &o.queries {
3968            Some(Loose::Ok(_)) => Some(queries.iter().map(|q| q.query.clone()).collect()),
3969            _ => None,
3970        };
3971        if extension.is_some() && format == "xlsx" {
3972            self.diags.error(
3973                Code::InvalidOutputOption,
3974                file.clone(),
3975                None,
3976                format!("{ctx}: `extension` doesn't apply to xlsx (Excel only opens .xlsx workbooks)"),
3977            );
3978        }
3979        Output {
3980            name,
3981            format,
3982            queries,
3983            when,
3984            options: opts,
3985            destinations,
3986            template,
3987            extension,
3988        }
3989    }
3990
3991    /// One `output.destination` entry: `profile`, optional `path`, and plugin options.
3992    fn typed_destination(
3993        &mut self,
3994        d: config::report::Destination,
3995        ctx: &str,
3996        file: &Option<PathBuf>,
3997        used: &mut Usage,
3998    ) -> Option<Destination> {
3999        let Some(Loose::Ok(p)) = d.profile else {
4000            self.diags.error(
4001                Code::InvalidField,
4002                file.clone(),
4003                None,
4004                format!("{ctx}: `output.destination` needs a `profile:` naming a profiles.yml entry"),
4005            );
4006            return None;
4007        };
4008        let path = match d.path {
4009            None => None,
4010            Some(Loose::Ok(s)) => Some(s),
4011            Some(Loose::Bad(_)) => {
4012                self.diags.error(
4013                    Code::InvalidField,
4014                    file.clone(),
4015                    None,
4016                    format!("{ctx}: destination `{p}`: `path` must be a string"),
4017                );
4018                None
4019            }
4020        };
4021        used.destination(&p, file.clone(), None);
4022        let options = d.options.0.into_iter().map(|(k, v)| (k.value, v)).collect();
4023        let attach = match d.attach {
4024            None => Vec::new(),
4025            Some(Loose::Ok(de::OneOf::A(s))) => vec![s],
4026            Some(Loose::Ok(de::OneOf::B(list))) => list,
4027            Some(Loose::Bad(_)) => {
4028                self.diags.error(
4029                    Code::InvalidField,
4030                    file.clone(),
4031                    None,
4032                    format!("{ctx}: destination `{p}`: `attach` must be an output name or a list of them"),
4033                );
4034                Vec::new()
4035            }
4036        };
4037        Some(Destination {
4038            profile: p,
4039            path,
4040            options,
4041            attach,
4042        })
4043    }
4044
4045    fn typed_template(
4046        &mut self,
4047        t: Loose<config::report::Template>,
4048        ctx: &str,
4049        file: &Option<PathBuf>,
4050        queries: &[QueryEntry],
4051    ) -> Option<Template> {
4052        let err = |s: &mut Self, msg: String| {
4053            s.diags
4054                .error(Code::InvalidTemplate, file.clone(), None, format!("{ctx}: {msg}"))
4055        };
4056        let Some(Loose::Ok(tf)) = t.ok().and_then(|t| t.file.clone()) else {
4057            err(self, "`output.template` needs a `file:`".into());
4058            return None;
4059        };
4060        let Loose::Ok(t) = t else {
4061            unreachable!("checked above")
4062        };
4063        let mut bindings = Vec::new();
4064        let items = match t.bindings {
4065            None => Vec::new(),
4066            Some(Loose::Ok(s)) => s,
4067            Some(Loose::Bad(_)) => {
4068                err(self, "`output.template.bindings` must be a list".into());
4069                Vec::new()
4070            }
4071        };
4072        let names: Vec<&str> = queries.iter().map(|q| q.query.as_str()).collect();
4073        for (i, b) in items.into_iter().enumerate() {
4074            let n = i + 1;
4075            let Loose::Ok(m) = b else {
4076                err(self, format!("template binding {n} must be a map"));
4077                continue;
4078            };
4079            let s = |v: &Option<Loose<String>>| v.as_ref().and_then(Loose::ok).cloned();
4080            for k in &m.unknown.0 {
4081                err(self, format!("template binding {n} has unknown key `{}`", k.name));
4082            }
4083            let Some(sheet) = s(&m.sheet) else {
4084                err(self, format!("template binding {n} needs a `sheet:`"));
4085                continue;
4086            };
4087            let tb = TemplateBinding {
4088                sheet,
4089                query: s(&m.query),
4090                result_index: m.result_index.as_ref().and_then(Loose::ok).map(|x| *x as usize),
4091                anchor: s(&m.anchor),
4092                header: m.header.as_ref().and_then(Loose::ok).copied(),
4093                columns: m.columns.as_ref().and_then(Loose::ok).cloned(),
4094                cell: s(&m.cell),
4095                value: s(&m.value),
4096                column: s(&m.column),
4097            };
4098            if let Some(q) = &tb.query
4099                && !names.contains(&q.as_str())
4100            {
4101                err(
4102                    self,
4103                    format!(
4104                        "template binding {n} uses query `{q}`, which isn't one of this Binding's queries"
4105                    ),
4106                );
4107            }
4108            if let Some(ri) = &m.result_index
4109                && ri.ok().is_none_or(|x| *x == 0)
4110            {
4111                err(
4112                    self,
4113                    format!("template binding {n}: `result_index` must be 1 or more"),
4114                );
4115            }
4116            match &tb.cell {
4117                Some(c) => {
4118                    if !options::is_cell(c) {
4119                        err(
4120                            self,
4121                            format!("template binding {n}: `cell` `{c}` isn't a valid cell reference"),
4122                        );
4123                    }
4124                    let by_value = tb.value.is_some();
4125                    let by_query = tb.query.is_some() && tb.column.is_some();
4126                    if by_value == by_query || (tb.query.is_some() != tb.column.is_some()) {
4127                        err(
4128                            self,
4129                            format!(
4130                                "template binding {n}: a single-cell binding needs exactly one of `value` or `query` + `column`"
4131                            ),
4132                        );
4133                    }
4134                    if tb.anchor.is_some() || tb.columns.is_some() {
4135                        err(
4136                            self,
4137                            format!(
4138                                "template binding {n}: `anchor`/`columns` apply to table blocks, not single cells"
4139                            ),
4140                        );
4141                    }
4142                }
4143                None => {
4144                    if tb.query.is_none() {
4145                        err(
4146                            self,
4147                            format!(
4148                                "template binding {n}: a table block needs a `query` (or use `cell` for a single cell)"
4149                            ),
4150                        );
4151                    }
4152                    if let Some(a) = &tb.anchor
4153                        && !options::is_cell(a)
4154                    {
4155                        err(
4156                            self,
4157                            format!("template binding {n}: `anchor` `{a}` isn't a valid cell reference"),
4158                        );
4159                    }
4160                    if tb.value.is_some() || tb.column.is_some() {
4161                        err(
4162                            self,
4163                            format!(
4164                                "template binding {n}: `value`/`column` apply to single-cell bindings (add `cell`)"
4165                            ),
4166                        );
4167                    }
4168                }
4169            }
4170            bindings.push(tb);
4171        }
4172        Some(Template { file: tf, bindings })
4173    }
4174
4175    // -- unmanaged reports --------------------------------------------------------------------
4176
4177    fn resolve_unmanaged(
4178        &mut self,
4179        name: &str,
4180        path: &Path,
4181        project: &Project,
4182        folders: &BTreeMap<Vec<String>, FolderCfg>,
4183        used: &mut Usage,
4184    ) -> Report {
4185        let folder = folder_segments(path.parent().unwrap_or(Path::new("")));
4186        let layers = folder_layers(folders, &folder);
4187        let mut tags: Vec<String> = layers.iter().flat_map(|l| l.tags.clone()).collect();
4188        dedup(&mut tags);
4189        let profile = layers
4190            .iter()
4191            .rev()
4192            .find_map(|l| l.profile.as_ref().map(|p| p.0.clone()))
4193            .or(project.default_profile.clone());
4194        let mut vars = project.vars.clone();
4195        for l in &layers {
4196            if let Some(v) = &l.vars {
4197                vars.extend(v.clone());
4198            }
4199        }
4200        let mut output = builtin_output();
4201        let mut merge_problems = Vec::new();
4202        if let Some(o) = project_default_output(project) {
4203            merge_problems.extend(merge_output(&mut output, &o));
4204        }
4205        for l in &layers {
4206            if let Some(o) = &l.output {
4207                merge_problems.extend(merge_output(&mut output, o));
4208            }
4209        }
4210        for p in merge_problems {
4211            self.diags.error(
4212                Code::InvalidField,
4213                Some(path.to_path_buf()),
4214                None,
4215                format!("folder config: {p}"),
4216            );
4217        }
4218        self.diags.warning(
4219            Code::UnmanagedReport,
4220            Some(path.to_path_buf()),
4221            None,
4222            format!(
4223                "`{name}` is an unmanaged report (no YAML lists it in `queries:`); unmanaged reports are for quick tests — add a YAML to make it a managed report"
4224            ),
4225        );
4226        self.check_unmanaged_sql(name, path);
4227        let query = QueryEntry {
4228            query: name.to_string(),
4229            path: path.to_path_buf(),
4230            profile: None,
4231            tab: true,
4232            tab_name: None,
4233            anchor: None,
4234            header: None,
4235            columns: BTreeMap::new(),
4236            autofit: None,
4237            style: None,
4238        };
4239        let base = BindingBase {
4240            profile: profile.clone(),
4241            profile_at: inherited_profile_at(&layers, project),
4242            vars,
4243            outputs: vec![output],
4244            locale: layers
4245                .iter()
4246                .rev()
4247                .find_map(|l| l.locale.clone())
4248                .or_else(|| project.locale.clone()),
4249        };
4250        if let Some(p) = &profile {
4251            used.connection(p, None, None);
4252        }
4253        let b = self.finish_binding(
4254            name,
4255            None,
4256            &base,
4257            None,
4258            vec![query.clone()],
4259            path.to_path_buf(),
4260            used,
4261        );
4262        Report {
4263            name: name.to_string(),
4264            managed: false,
4265            file: path.to_path_buf(),
4266            folder,
4267            tags,
4268            queries: vec![query],
4269            default_set: None,
4270            timezone: layers
4271                .iter()
4272                .rev()
4273                .find_map(|l| l.timezone.clone())
4274                .or_else(|| project.timezone.clone()),
4275            has_sets: false,
4276            base: b.clone(),
4277            bindings: vec![b],
4278        }
4279    }
4280
4281    /// Best-effort check on the raw text; the run path re-checks the rendered SQL.
4282    fn check_unmanaged_sql(&mut self, name: &str, path: &Path) {
4283        let Ok(text) = std::fs::read_to_string(self.root.join(path)) else {
4284            return;
4285        };
4286        let head = sqlsplit::strip_leading_comments(&text);
4287        if head.starts_with("{{") || head.starts_with("{%") || head.starts_with("{#") {
4288            return;
4289        }
4290        for st in sqlsplit::split(&text) {
4291            if !sqlsplit::classify(&st.text).is_read_only_safe() {
4292                let body = sqlsplit::strip_leading_comments(&st.text);
4293                let line = st.line + st.text[..st.text.len() - body.len()].matches('\n').count();
4294                self.diags.error(
4295                    Code::UnmanagedSideEffect,
4296                    Some(path.to_path_buf()),
4297                    Some(line),
4298                    format!(
4299                        "unmanaged report `{name}` may only run SELECT/WITH or CREATE [OR REPLACE] TEMP|TEMPORARY TABLE|VIEW, but found `{}`; rewrite the statement, or give the report a YAML to declare it",
4300                        dre_protocol::util::summarize(body, 60)
4301                    ),
4302                );
4303            }
4304        }
4305    }
4306
4307    // -- schedules ----------------------------------------------------------------------------
4308
4309    /// Schedules live only in schedules.yml (named, with vars); anywhere else is an error.
4310    fn moved_to_schedules(&mut self, file: &Path, line: Option<usize>, ctx: &str) {
4311        self.diags.error(
4312            Code::ScheduleMoved,
4313            Some(file.to_path_buf()),
4314            line,
4315            format!(
4316                "{ctx} is no longer supported; declare schedules in schedules.yml as named entries (`name`, `report:`/`select:`, `cron`/`every`/`rrule`, optional `vars`)"
4317            ),
4318        );
4319    }
4320
4321    /// timings.yml: named timings. Returns the valid ones, and the names of those with errors
4322    /// (schedules using them aren't reported again).
4323    fn parse_timings(&mut self, files: &[Rc<YamlFile>]) -> (BTreeMap<String, Timing>, BTreeSet<String>) {
4324        let mut out = BTreeMap::new();
4325        let mut broken = BTreeSet::new();
4326        let mut seen: BTreeMap<String, String> = BTreeMap::new();
4327        for yf in files {
4328            let Ok(Loose::Ok(timings)) = de::from_node::<Loose<config::schedule::TimingsFile>>(&yf.node)
4329            else {
4330                continue;
4331            };
4332            for (k, v) in timings.0.0 {
4333                let file = Some(yf.display.clone());
4334                let line = k.line();
4335                let name = k.value;
4336                let mut ok = true;
4337                if !is_identifier(&name) {
4338                    self.diags.error(
4339                        Code::InvalidTiming,
4340                        file.clone(),
4341                        line,
4342                        format!(
4343                            "timing name `{name}` must be letters, digits and `_`, not starting with a digit"
4344                        ),
4345                    );
4346                    ok = false;
4347                }
4348                if let Some(prev) = seen.get(&name) {
4349                    self.diags.error(
4350                        Code::DuplicateTimingName,
4351                        file.clone(),
4352                        line,
4353                        format!("timing `{name}` is already declared at {prev}; timing names must be unique"),
4354                    );
4355                    continue;
4356                }
4357                seen.insert(
4358                    name.clone(),
4359                    format!("{}:{}", yf.display.display(), line.unwrap_or(0)),
4360                );
4361                let Loose::Ok(t) = v else {
4362                    self.diags.error(
4363                        Code::InvalidTiming,
4364                        file.clone(),
4365                        line,
4366                        format!("timing `{name}` must be a map, e.g. `{{cron: \"0 6 1 * *\", timezone: Australia/Sydney}}`"),
4367                    );
4368                    broken.insert(name);
4369                    continue;
4370                };
4371                for k in &t.unknown.0 {
4372                    self.diags.error(
4373                        Code::InvalidTiming,
4374                        file.clone(),
4375                        line,
4376                        format!("timing `{name}`: unknown key `{}`", k.name),
4377                    );
4378                    ok = false;
4379                }
4380                let block = t.block();
4381                let shape = schedule::validate_block(&block, "a timing", "`cron`, `every` or `rrule`");
4382                for e in &shape {
4383                    self.diags.error(
4384                        Code::InvalidTiming,
4385                        file.clone(),
4386                        line,
4387                        format!("timing `{name}`: {e}"),
4388                    );
4389                    ok = false;
4390                }
4391                let timezone = match &t.timezone {
4392                    None => None,
4393                    Some(v) => {
4394                        let tz = self.timezone_str(
4395                            v.ok().map(String::as_str),
4396                            &yf.display,
4397                            line,
4398                            &format!("timing `{name}`: `timezone`"),
4399                        );
4400                        ok &= tz.is_some();
4401                        tz
4402                    }
4403                };
4404                if shape.is_empty() {
4405                    for (code, msg) in schedule::strictness(&block) {
4406                        self.diags
4407                            .error(code, file.clone(), line, format!("timing `{name}`: {msg}"));
4408                    }
4409                    if let Some(msg) = schedule::no_time(&block) {
4410                        self.diags.warning(
4411                            Code::ScheduleNoTime,
4412                            file.clone(),
4413                            line,
4414                            format!("timing `{name}`: {msg}"),
4415                        );
4416                    }
4417                }
4418                if ok {
4419                    out.insert(
4420                        name,
4421                        Timing {
4422                            schedule: block,
4423                            timezone,
4424                            location: (yf.display.clone(), line),
4425                        },
4426                    );
4427                } else {
4428                    broken.insert(name);
4429                }
4430            }
4431        }
4432        (out, broken)
4433    }
4434
4435    fn parse_schedules(
4436        &mut self,
4437        files: &[Rc<YamlFile>],
4438        project: &Project,
4439        broken_timings: &BTreeSet<String>,
4440    ) -> Vec<ScheduleEntry> {
4441        let mut out = Vec::new();
4442        let mut used_timings = BTreeSet::new();
4443        let mut seen: BTreeMap<String, String> = BTreeMap::new();
4444        for yf in files {
4445            let Ok(Loose::Ok(items)) = de::from_node::<Loose<config::schedule::SchedulesFile>>(&yf.node)
4446            else {
4447                continue;
4448            };
4449            for item in &items.0 {
4450                // Schedules files are recognised by every item being a map with a name or target.
4451                let Loose::Ok(m) = &item.value else { continue };
4452                let line = item.line();
4453                let file = Some(yf.display.clone());
4454                let s = |v: &Option<Loose<String>>| v.as_ref().and_then(Loose::ok).cloned();
4455                let (select, report, set) = (s(&m.select), s(&m.report), s(&m.set));
4456                let mut ok = true;
4457                let name = match &m.name {
4458                    Some(Loose::Ok(n)) if is_identifier(n) => {
4459                        if let Some(prev) = seen.get(n) {
4460                            self.diags.error(
4461                                Code::DuplicateScheduleName,
4462                                file.clone(),
4463                                line,
4464                                format!("schedule `{n}` is already declared at {prev}; schedule names must be unique"),
4465                            );
4466                            ok = false;
4467                        }
4468                        seen.insert(
4469                            n.clone(),
4470                            format!("{}:{}", yf.display.display(), line.unwrap_or(0)),
4471                        );
4472                        n.clone()
4473                    }
4474                    Some(Loose::Ok(n)) => {
4475                        self.diags.error(
4476                            Code::InvalidSchedule,
4477                            file.clone(),
4478                            line,
4479                            format!("schedule name `{n}` must be letters, digits and `_`, not starting with a digit"),
4480                        );
4481                        ok = false;
4482                        n.clone()
4483                    }
4484                    _ => {
4485                        self.diags.error(
4486                            Code::InvalidSchedule,
4487                            file.clone(),
4488                            line,
4489                            "every schedule needs a `name`",
4490                        );
4491                        ok = false;
4492                        String::new()
4493                    }
4494                };
4495                let vars = match &m.vars {
4496                    None => JsonMap::new(),
4497                    Some(Loose::Ok(v)) => v.clone(),
4498                    Some(Loose::Bad(_)) => {
4499                        self.diags.error(
4500                            Code::InvalidSchedule,
4501                            file.clone(),
4502                            line,
4503                            format!("schedule `{name}`: `vars` must be a map"),
4504                        );
4505                        ok = false;
4506                        JsonMap::new()
4507                    }
4508                };
4509                for k in &m.unknown.0 {
4510                    self.diags.error(
4511                        Code::InvalidSchedule,
4512                        file.clone(),
4513                        line,
4514                        format!("unknown schedule key `{}`", k.name),
4515                    );
4516                }
4517                let sched = m.block();
4518                let timing = match &m.timing {
4519                    None => None,
4520                    Some(Loose::Ok(t)) => {
4521                        used_timings.insert(t.clone());
4522                        Some(t.clone())
4523                    }
4524                    Some(Loose::Bad(_)) => {
4525                        self.diags.error(
4526                            Code::InvalidSchedule,
4527                            file.clone(),
4528                            line,
4529                            format!(
4530                                "schedule `{name}`: `timing` must be the name of a timing in timings.yml"
4531                            ),
4532                        );
4533                        ok = false;
4534                        None
4535                    }
4536                };
4537                let mut resolved = None;
4538                let shape = if let Some(t) = &timing {
4539                    let mut errs = Vec::new();
4540                    let own: Vec<String> = m.timing_keys().into_iter().map(|k| format!("`{k}`")).collect();
4541                    if !own.is_empty() {
4542                        let keys = match own.split_last() {
4543                            Some((last, [])) => last.clone(),
4544                            Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
4545                            None => String::new(),
4546                        };
4547                        errs.push(format!(
4548                            "schedule `{name}` uses timing `{t}`, so it can't set {keys} too; change the timing, or give the schedule its own timing instead"
4549                        ));
4550                    }
4551                    match project.timings.get(t) {
4552                        Some(def) => resolved = Some(def.schedule.clone()),
4553                        None if broken_timings.contains(t) => ok = false,
4554                        None => {
4555                            let names: Vec<&str> = project.timings.keys().map(String::as_str).collect();
4556                            let valid = if names.is_empty() {
4557                                "the project has no timings.yml entries".to_string()
4558                            } else {
4559                                format!("valid names: {}", names.join(", "))
4560                            };
4561                            self.diags.error(
4562                                Code::UnknownTiming,
4563                                file.clone(),
4564                                line,
4565                                format!("schedule `{name}`: no timing `{t}`; {valid}"),
4566                            );
4567                            ok = false;
4568                        }
4569                    }
4570                    errs
4571                } else {
4572                    schedule::validate_block(&sched, "a schedule", "`timing`, `cron`, `every` or `rrule`")
4573                };
4574                if shape.is_empty() && timing.is_none() {
4575                    let block = &sched;
4576                    for (code, msg) in schedule::strictness(block) {
4577                        self.diags
4578                            .error(code, file.clone(), line, format!("schedule `{name}`: {msg}"));
4579                    }
4580                    if let Some(msg) = schedule::no_time(block) {
4581                        self.diags.warning(
4582                            Code::ScheduleNoTime,
4583                            file.clone(),
4584                            line,
4585                            format!("schedule `{name}`: {msg}"),
4586                        );
4587                    }
4588                }
4589                for e in shape {
4590                    self.diags.error(Code::InvalidSchedule, file.clone(), line, e);
4591                    ok = false;
4592                }
4593                if select.is_some() && report.is_some() {
4594                    self.diags.error(
4595                        Code::InvalidSchedule,
4596                        file.clone(),
4597                        line,
4598                        "use either `select:` or `report:`, not both",
4599                    );
4600                    ok = false;
4601                }
4602                if select.is_some() && set.is_some() {
4603                    self.diags.error(
4604                        Code::InvalidSchedule,
4605                        file.clone(),
4606                        line,
4607                        "`set:` only applies with `report:`",
4608                    );
4609                    ok = false;
4610                }
4611                if let Some(sel) = &select {
4612                    match selector::resolve(project, sel) {
4613                        Ok(r) if r.is_empty() => {
4614                            self.diags.error(
4615                                Code::SelectorMatchesNothing,
4616                                file.clone(),
4617                                line,
4618                                format!("selector `{sel}` matches no report"),
4619                            );
4620                            ok = false;
4621                        }
4622                        Ok(_) => {}
4623                        Err(e) => {
4624                            self.diags.error(e.code(), file.clone(), line, e.to_string());
4625                            ok = false;
4626                        }
4627                    }
4628                }
4629                if let Some(rn) = &report {
4630                    match project.report(rn) {
4631                        None => {
4632                            self.diags.error(
4633                                Code::SelectorMatchesNothing,
4634                                file.clone(),
4635                                line,
4636                                format!("report `{rn}` doesn't exist"),
4637                            );
4638                            ok = false;
4639                        }
4640                        Some(r) => {
4641                            if let Some(sn) = &set
4642                                && r.binding(sn).is_none()
4643                            {
4644                                self.diags.error(
4645                                    Code::SelectorMatchesNothing,
4646                                    file.clone(),
4647                                    line,
4648                                    format!("report `{rn}` has no Set `{sn}`"),
4649                                );
4650                                ok = false;
4651                            }
4652                        }
4653                    }
4654                }
4655                if select.is_none() && report.is_none() {
4656                    self.diags.error(
4657                        Code::InvalidSchedule,
4658                        file.clone(),
4659                        line,
4660                        format!("schedule `{name}` needs `report:` (optionally with `set:`) or `select:`"),
4661                    );
4662                    ok = false;
4663                }
4664                let timezone = match (&m.timezone, &timing) {
4665                    (None, Some(t)) => project.timings.get(t).and_then(|d| d.timezone.clone()),
4666                    (None, None) => None,
4667                    (Some(v), _) => {
4668                        let t = self.timezone_str(
4669                            v.value.ok().map(String::as_str),
4670                            &yf.display,
4671                            line,
4672                            &format!("schedule `{name}`: `timezone`"),
4673                        );
4674                        ok &= t.is_some();
4675                        t
4676                    }
4677                };
4678                let enabled = match &m.enabled {
4679                    None => true,
4680                    Some(Loose::Ok(b)) => *b,
4681                    Some(Loose::Bad(_)) => {
4682                        self.diags.error(
4683                            Code::InvalidSchedule,
4684                            file.clone(),
4685                            line,
4686                            format!("schedule `{name}`: `enabled` must be true or false"),
4687                        );
4688                        ok = false;
4689                        true
4690                    }
4691                };
4692                if ok {
4693                    out.push(ScheduleEntry {
4694                        name,
4695                        select,
4696                        report,
4697                        set,
4698                        schedule: resolved.unwrap_or(sched),
4699                        timing,
4700                        enabled,
4701                        vars,
4702                        timezone,
4703                        location: (yf.display.clone(), line),
4704                    });
4705                }
4706            }
4707        }
4708        for (name, t) in &project.timings {
4709            if !used_timings.contains(name) {
4710                self.diags.warning(
4711                    Code::UnusedTiming,
4712                    Some(t.location.0.clone()),
4713                    t.location.1,
4714                    format!("timing `{name}` isn't used by any schedule"),
4715                );
4716            }
4717        }
4718        out
4719    }
4720
4721    /// Warn when two schedules of one Binding would deliver to the same path: each schedule's
4722    /// paths are rendered with its vars and one fixed date. Paths that can't render offline (a
4723    /// `run_query()`, a missing `env_var()`) are skipped.
4724    fn check_schedule_paths(&mut self, project: &Project) {
4725        let date = chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
4726        for report in &project.reports {
4727            for b in report.bindings.iter().filter(|b| b.schedules.len() > 1) {
4728                let paths: Vec<&String> = b.destinations().filter_map(|d| d.path.as_ref()).collect();
4729                if paths.is_empty() {
4730                    continue;
4731                }
4732                let mut rendered: Vec<(&String, Vec<String>)> = Vec::new();
4733                for name in &b.schedules {
4734                    let Some(e) = project.schedules.iter().find(|e| &e.name == name) else {
4735                        continue;
4736                    };
4737                    let mut vars = b.vars.clone();
4738                    vars.extend(e.vars.clone());
4739                    let Ok(r) = crate::render::Renderer::new(crate::render::RendererConfig {
4740                        root: &project.root,
4741                        macros: &project.macros,
4742                        context: crate::render::RunContext {
4743                            report: report.name.clone(),
4744                            set: b.set.clone(),
4745                            target: project.target_name.clone(),
4746                            schedule: Some(name.clone()),
4747                            date,
4748                            now: chrono::Utc::now(),
4749                            scheduled_at: None,
4750                            calendar: crate::dates::Calendar::default(),
4751                            locale: Default::default(),
4752                        },
4753                        vars,
4754                        cli_vars: self.opts.vars.clone(),
4755                        runner: None,
4756                        connections: None,
4757                        mode: crate::render::Mode::Run,
4758                        connection: None,
4759                        source_type: String::new(),
4760                        sources: None,
4761                        run_query_max_rows: project.run_query_max_rows,
4762                        sql: project.sql.clone(),
4763                        lookups: project.lookups.clone(),
4764                        lookup_inline_max_rows: project.lookup_inline_max_rows,
4765                        packages: project.packages.clone(),
4766                        project_name: project.name.clone(),
4767                        dispatch: project.dispatch.clone(),
4768                    }) else {
4769                        continue;
4770                    };
4771                    let out: Result<Vec<String>, _> =
4772                        paths.iter().map(|p| r.render(&report.file, p)).collect();
4773                    if let Ok(out) = out {
4774                        rendered.push((name, out));
4775                    }
4776                }
4777                for (i, (a, pa)) in rendered.iter().enumerate() {
4778                    for (bn, pb) in &rendered[i + 1..] {
4779                        if let Some(same) = pa.iter().find(|p| pb.contains(p)) {
4780                            self.diags.warning(
4781                                Code::SchedulePathClash,
4782                                Some(report.file.clone()),
4783                                None,
4784                                format!(
4785                                    "schedules `{a}` and `{bn}` both run report `{}`{} and deliver to `{same}`; the second overwrites the first — put a schedule var or `run.schedule` in the path",
4786                                    report.name,
4787                                    b.set.as_ref().map(|s| format!(", Set `{s}`,")).unwrap_or_default()
4788                                ),
4789                            );
4790                        }
4791                    }
4792                }
4793            }
4794        }
4795    }
4796
4797    /// Attach every schedules.yml entry to the Bindings it targets: `report:` + `set:` one
4798    /// Binding, `report:` alone all of that report's Bindings, `select:` all Bindings of every
4799    /// report it matches. Several schedules per Binding is the point, not a conflict.
4800    fn apply_schedules(&mut self, project: &mut Project) {
4801        let entries = project.schedules.clone();
4802        for e in &entries {
4803            let reports: Vec<String> = match (&e.select, &e.report) {
4804                (Some(sel), _) => selector::resolve(project, sel)
4805                    .map(|r| r.into_iter().map(|r| r.name.clone()).collect())
4806                    .unwrap_or_default(),
4807                (_, Some(r)) => vec![r.clone()],
4808                _ => Vec::new(),
4809            };
4810            for report in project.reports.iter_mut().filter(|r| reports.contains(&r.name)) {
4811                for b in &mut report.bindings {
4812                    if e.set.is_none() || e.set == b.set {
4813                        b.schedules.push(e.name.clone());
4814                    }
4815                }
4816            }
4817        }
4818        self.check_schedule_paths(project);
4819        self.check_schedule_timezones(project);
4820    }
4821
4822    /// Warn when a schedule fires in one timezone and a report it runs renders in another: the
4823    /// run date is then the report's date at the firing time, which may not be the day meant.
4824    fn check_schedule_timezones(&mut self, project: &Project) {
4825        for e in project.schedules.iter().filter(|e| e.timezone.is_none()) {
4826            let fires = crate::occurrences::firing_tz(project, e);
4827            let mut seen = BTreeSet::new();
4828            for (report, _) in crate::occurrences::bindings(project, &e.name) {
4829                let Some(r) = project.report(report) else { continue };
4830                let renders = r
4831                    .timezone
4832                    .as_ref()
4833                    .and_then(|t| crate::dates::parse_tz(t).ok())
4834                    .unwrap_or(chrono_tz::Tz::UTC);
4835                if renders != fires && seen.insert(report) {
4836                    self.diags.warning(
4837                        Code::ScheduleTimezoneMismatch,
4838                        Some(e.location.0.clone()),
4839                        e.location.1,
4840                        format!(
4841                            "schedule `{}` fires in {} but report `{report}` renders in {}, so its run date is {}'s date at the firing time; set `timezone:` on the schedule to fire and render in one timezone",
4842                            e.name,
4843                            fires.name(),
4844                            renders.name(),
4845                            renders.name()
4846                        ),
4847                    );
4848                }
4849            }
4850        }
4851    }
4852
4853    // -- profiles and plugins -----------------------------------------------------------------
4854
4855    fn check_profiles(&mut self, project: &Project, used: &Usage) {
4856        let profiles = &project.profiles;
4857        if used.connections.is_empty() && used.destinations.is_empty() {
4858            return;
4859        }
4860        if !profiles.exists() {
4861            self.diags.error(
4862                Code::ProfilesMissing,
4863                None,
4864                None,
4865                if profiles.found_by == "~/.dre" {
4866                    format!(
4867                        "the project references profiles, but no profiles.yml was found in the project directory or at {}",
4868                        profiles.path.display()
4869                    )
4870                } else {
4871                    format!(
4872                        "the project references profiles, but no profiles.yml was found at {} (from {})",
4873                        profiles.path.display(),
4874                        profiles.found_by
4875                    )
4876                },
4877            );
4878            return;
4879        }
4880        for (role, refs) in [
4881            (Role::Connection, &used.connections),
4882            (Role::Destination, &used.destinations),
4883        ] {
4884            for (name, (file, line)) in refs {
4885                if role == Role::Destination && name == LOCAL_TYPE {
4886                    continue;
4887                }
4888                if !profiles.declares(role, name) {
4889                    self.diags.error(
4890                        Code::UnknownProfile,
4891                        file.clone(),
4892                        *line,
4893                        format!(
4894                            "{} profile `{name}` isn't defined under `{}:` in {}",
4895                            role.as_str(),
4896                            profiles.section_key(role),
4897                            profiles.path.display()
4898                        ),
4899                    );
4900                }
4901            }
4902        }
4903    }
4904
4905    /// A `plugins:` entry in its map form: `{name: foo, github: acme/dre-foo, version: "^1"}`.
4906    fn plugin_entry(
4907        &mut self,
4908        m: &config::dependencies::PluginEntry,
4909        file: &Path,
4910        line: Option<usize>,
4911    ) -> Option<(String, Option<Value>, PluginSource)> {
4912        let file = Some(file.to_path_buf());
4913        let name = m.name.ok().cloned().unwrap_or_default();
4914        let err = |s: &mut Self, msg: String| {
4915            s.diags.error(
4916                Code::InvalidPluginDeclaration,
4917                file.clone(),
4918                line,
4919                format!("plugin package `{name}`: {msg}"),
4920            );
4921        };
4922        if name.is_empty() {
4923            err(self, "`name` must be a non-empty string".into());
4924            return None;
4925        }
4926        if let Some(k) = m.unknown.0.first() {
4927            err(
4928                self,
4929                format!(
4930                    "unknown key `{}`; use `name`, `version` and one of `github`, `local`, `registry`",
4931                    k.name
4932                ),
4933            );
4934            return None;
4935        }
4936        let given: Vec<(&str, &str)> = [
4937            ("github", &m.github),
4938            ("local", &m.local),
4939            ("registry", &m.registry),
4940        ]
4941        .into_iter()
4942        .filter_map(|(k, v)| v.as_ref().map(|v| (k, v.as_str().unwrap_or(""))))
4943        .collect();
4944        let source = match given.as_slice() {
4945            [] => PluginSource::Default,
4946            [(k, "")] => {
4947                err(self, format!("`{k}` must be a non-empty string"));
4948                return None;
4949            }
4950            [("github", r)] => {
4951                let ok = r.split('/').count() == 2 && r.split('/').all(|p| !p.is_empty());
4952                if !ok {
4953                    err(self, format!("`github: {r}` must be `owner/repo`"));
4954                    return None;
4955                }
4956                PluginSource::Github(r.to_string())
4957            }
4958            [("local", p)] => {
4959                if m.version.is_some() {
4960                    err(
4961                        self,
4962                        "a `local` package has no `version`: it's used as it is".into(),
4963                    );
4964                    return None;
4965                }
4966                PluginSource::Local(p.to_string())
4967            }
4968            [(_, u)] => PluginSource::Registry(u.to_string()),
4969            _ => {
4970                err(self, "give only one of `github`, `local` and `registry`".into());
4971                return None;
4972            }
4973        };
4974        Some((name, m.version.clone(), source))
4975    }
4976
4977    /// The `plugins:` declarations, merged across files; and every plugin the project uses, for
4978    /// [`crate::plugins::check_uses`] to check against what the declared packages provide.
4979    fn check_plugins(&mut self, decls: &[(Rc<YamlFile>, Mapping)], project: &mut Project, used: &Usage) {
4980        struct Decl {
4981            req: semver::VersionReq,
4982            raw: String,
4983            file: PathBuf,
4984            source: PluginSource,
4985        }
4986        let mut by_package: BTreeMap<String, Vec<Decl>> = BTreeMap::new();
4987        for (yf, _) in decls {
4988            {
4989                let Ok(config::dependencies::PluginsKey { plugins: Some(v) }) = de::from_node(&yf.node)
4990                else {
4991                    continue;
4992                };
4993                let line = v.line();
4994                let file = Some(yf.display.clone());
4995                let entries: Vec<(String, Option<Value>, PluginSource, Option<usize>)> = match v.value {
4996                    Loose::Ok(de::OneOf::A(items)) => items
4997                        .into_iter()
4998                        .filter_map(|i| {
4999                            let iline = i.line();
5000                            match i.value {
5001                                Loose::Ok(de::OneOf::A(name)) => Some((name.0, None, PluginSource::Default, iline)),
5002                                Loose::Ok(de::OneOf::B(de::OneOf::B(entry))) => self
5003                                    .plugin_entry(&entry, &yf.display, iline)
5004                                    .map(|(n, c, s)| (n, c, s, iline)),
5005                                Loose::Ok(de::OneOf::B(de::OneOf::A(pin))) => Some((
5006                                    pin.name.value,
5007                                    Some(pin.version.clone()),
5008                                    PluginSource::Default,
5009                                    iline,
5010                                )),
5011                                Loose::Bad(_) => {
5012                                    self.diags.error(
5013                                        Code::InvalidPluginDeclaration,
5014                                        file.clone(),
5015                                        iline,
5016                                        "each `plugins` entry is a package name, `name: \"<version>\"`, or a map with `name:` and one of `github:`, `local:`, `registry:`",
5017                                    );
5018                                    None
5019                                }
5020                            }
5021                        })
5022                        .collect(),
5023                    Loose::Ok(de::OneOf::B(m)) => m
5024                        .0
5025                        .into_iter()
5026                        .map(|(k, v)| {
5027                            let kline = k.line();
5028                            (k.value, Some(v), PluginSource::Default, kline)
5029                        })
5030                        .collect(),
5031                    Loose::Bad(f) if f.kind == "nothing" => Vec::new(),
5032                    Loose::Bad(_) => {
5033                        self.diags.error(
5034                            Code::InvalidPluginDeclaration,
5035                            file.clone(),
5036                            line,
5037                            "`plugins` must be a list like `- duckdb: \">=1.0\"`",
5038                        );
5039                        continue;
5040                    }
5041                };
5042                for (name, c, source, eline) in entries {
5043                    if !dre_protocol::valid_name(&name) {
5044                        self.diags.error(
5045                            Code::InvalidPluginDeclaration,
5046                            file.clone(),
5047                            eline,
5048                            format!(
5049                                "plugin package `{name}`: a package name is lowercase letters, digits and `_`"
5050                            ),
5051                        );
5052                        continue;
5053                    }
5054                    let raw = match &c {
5055                        None | Some(Value::Null) => "*".to_string(),
5056                        Some(v) => crate::yaml::scalar_str(v).unwrap_or_default(),
5057                    };
5058                    match semver::VersionReq::parse(&raw) {
5059                        Ok(req) => by_package.entry(name).or_default().push(Decl {
5060                            req,
5061                            raw,
5062                            file: yf.display.clone(),
5063                            source,
5064                        }),
5065                        Err(e) => self.diags.error(
5066                            Code::InvalidVersionConstraint,
5067                            file.clone(),
5068                            eline,
5069                            format!("plugin package `{name}`: invalid version constraint `{raw}`: {e}"),
5070                        ),
5071                    }
5072                }
5073            }
5074        }
5075        let mut out = Vec::new();
5076        for (name, ds) in &by_package {
5077            // Every declaration of one package must agree on where it comes from.
5078            let source = ds
5079                .iter()
5080                .map(|d| &d.source)
5081                .find(|s| !s.is_default())
5082                .cloned()
5083                .unwrap_or_default();
5084            if let Some(other) = ds.iter().find(|d| !d.source.is_default() && d.source != source) {
5085                self.diags.error(
5086                    Code::ConflictingPluginSources,
5087                    Some(other.file.clone()),
5088                    None,
5089                    format!(
5090                        "plugin package `{name}` is declared with two sources: {} and {}",
5091                        source.lock_key().unwrap_or_default(),
5092                        other.source.lock_key().unwrap_or_default()
5093                    ),
5094                );
5095                project.plugins_incomplete = true;
5096                continue;
5097            }
5098            let reqs: Vec<&semver::VersionReq> = ds.iter().map(|d| &d.req).collect();
5099            if !constraints::compatible(&reqs) {
5100                for (i, a) in ds.iter().enumerate() {
5101                    for b in &ds[i + 1..] {
5102                        if !constraints::compatible(&[&a.req, &b.req]) {
5103                            self.diags.error(
5104                                Code::ConflictingPluginConstraints,
5105                                Some(b.file.clone()),
5106                                None,
5107                                format!(
5108                                    "plugin package `{name}` is declared `{}` in {} and `{}` in {}; no version satisfies both",
5109                                    a.raw,
5110                                    a.file.display(),
5111                                    b.raw,
5112                                    b.file.display()
5113                                ),
5114                            );
5115                        }
5116                    }
5117                }
5118                project.plugins_incomplete = true;
5119                continue;
5120            }
5121            let combined = constraints::combine(&reqs);
5122            let mut files: Vec<PathBuf> = ds.iter().map(|d| d.file.clone()).collect();
5123            files.dedup();
5124            out.push(PluginRequirement {
5125                name: name.clone(),
5126                version: combined.to_string(),
5127                declared_in: files,
5128                source,
5129            });
5130        }
5131        project.plugins = out;
5132
5133        let mut uses = Vec::new();
5134        let profiles = &project.profiles;
5135        for (role, kind, refs) in [
5136            (Role::Connection, PluginKind::Source, &used.connections),
5137            (Role::Destination, PluginKind::Destination, &used.destinations),
5138        ] {
5139            for name in refs.keys() {
5140                let Some(p) = profiles.get(role, name) else {
5141                    continue;
5142                };
5143                let role_name = role.as_str();
5144                for out_ in p.targets.values() {
5145                    if kind == PluginKind::Destination && out_.kind == LOCAL_TYPE {
5146                        continue;
5147                    }
5148                    let id = PluginId::new(kind, out_.kind.clone());
5149                    if uses.iter().any(|u: &PluginUse| u.plugin == id) {
5150                        continue;
5151                    }
5152                    uses.push(PluginUse {
5153                        plugin: id,
5154                        file: profiles.file.clone(),
5155                        line: profiles.line_of(role, name),
5156                        what: format!("`type: {}` used by {role_name} profile `{name}`", out_.kind),
5157                    });
5158                }
5159            }
5160        }
5161        for (fmt, (ctx, file)) in &used.formats {
5162            let note = if fmt == "csv" {
5163                " (csv is the built-in default output)"
5164            } else {
5165                ""
5166            };
5167            uses.push(PluginUse {
5168                plugin: PluginId::new(PluginKind::Format, fmt.clone()),
5169                file: Some(file.clone()),
5170                line: None,
5171                what: format!("format `{fmt}` is used by {ctx}{note}"),
5172            });
5173        }
5174        project.plugin_uses = uses;
5175    }
5176
5177    /// `sources:`, `formats:` or `destinations:` where plugins were once declared.
5178    fn old_plugin_key(&mut self, yf: &YamlFile, key: &str) {
5179        self.diags.error(
5180            Code::MovedPluginDeclaration,
5181            Some(yf.display.clone()),
5182            yf.line_of(key, None),
5183            format!(
5184                "`{key}:` no longer declares plugins: list plugin packages under `plugins:` instead (e.g. `plugins: [duckdb, object_store]`)"
5185            ),
5186        );
5187    }
5188
5189    // -- Jinja pre-flight ---------------------------------------------------------------------
5190
5191    fn preflight(&mut self, project: &Project, _used: &Usage) {
5192        let cli_owned: Vec<String> = self.opts.vars.keys().cloned().collect();
5193        let cli: BTreeSet<&str> = cli_owned.iter().map(String::as_str).collect();
5194        let mut sources: BTreeMap<PathBuf, String> = BTreeMap::new();
5195        let mut read = |root: &Path, p: &Path| -> Option<String> {
5196            if let Some(s) = sources.get(p) {
5197                return Some(s.clone());
5198            }
5199            let s = std::fs::read_to_string(root.join(p)).ok()?;
5200            sources.insert(p.to_path_buf(), s.clone());
5201            Some(s)
5202        };
5203        let mut checked: BTreeSet<PathBuf> = BTreeSet::new();
5204        let root = self.root.clone();
5205        // Macros: syntax, env_var, run.*; their definitions, for var() checks per Binding.
5206        let mut defs: BTreeMap<String, (PathBuf, preflight::MacroDef)> = BTreeMap::new();
5207        for m in &project.macros {
5208            if let Some(src) = read(&root, m) {
5209                self.check_template_text(m, &src, 0, None, &cli);
5210                checked.insert(m.clone());
5211                for d in preflight::macro_defs(&src) {
5212                    defs.insert(d.name.clone(), (m.clone(), d));
5213                }
5214            }
5215        }
5216        for r in &project.reports {
5217            for b in &r.bindings {
5218                let ctx = match &b.set {
5219                    Some(s) => format!("report `{}`, Set `{s}`", r.name),
5220                    None => format!("report `{}`", r.name),
5221                };
5222                let mut called: Vec<String> = Vec::new();
5223                let mut refs: Vec<String> = Vec::new();
5224                for q in &b.queries {
5225                    let Some(src) = read(&root, &q.path) else { continue };
5226                    let first = checked.insert(q.path.clone());
5227                    self.check_vars(&q.path, &src, 0, &b.vars, &cli, &ctx);
5228                    if first {
5229                        self.check_template_text(&q.path, &src, 0, None, &cli);
5230                    }
5231                    called.extend(preflight::called_names(&src));
5232                    refs.extend(preflight::refs(&src).into_iter().map(|(n, _)| n));
5233                }
5234                // SQL this Binding pulls in through ref(), checked in the Binding's context.
5235                let mut seen_refs = BTreeSet::new();
5236                while let Some(name) = refs.pop() {
5237                    let Some(path) = project.sql.get(&name) else {
5238                        continue;
5239                    };
5240                    if !seen_refs.insert(name) {
5241                        continue;
5242                    }
5243                    let Some(src) = read(&root, path) else { continue };
5244                    self.check_vars(path, &src, 0, &b.vars, &cli, &ctx);
5245                    if checked.insert(path.clone()) {
5246                        self.check_template_text(path, &src, 0, None, &cli);
5247                    }
5248                    called.extend(preflight::called_names(&src));
5249                    refs.extend(preflight::refs(&src).into_iter().map(|(n, _)| n));
5250                }
5251                // Macros this Binding calls, directly or through other macros.
5252                let mut seen = BTreeSet::new();
5253                while let Some(name) = called.pop() {
5254                    let Some((file, def)) = defs.get(&name) else {
5255                        continue;
5256                    };
5257                    if !seen.insert(name) {
5258                        continue;
5259                    }
5260                    self.check_vars(file, &def.body, def.line_offset, &b.vars, &cli, &ctx);
5261                    called.extend(preflight::called_names(&def.body));
5262                }
5263                // Templated output values render with the same context.
5264                let mut values: Vec<String> = Vec::new();
5265                for d in b.destinations() {
5266                    values.extend(d.path.clone());
5267                    values.extend(d.options.values().flat_map(json_strings));
5268                }
5269                for t in b.outputs.iter().filter_map(|o| o.template.as_ref()) {
5270                    values.extend(t.bindings.iter().filter_map(|tb| tb.value.clone()));
5271                }
5272                for v in values.iter().filter(|v| preflight::is_templated(v)) {
5273                    let yf = std::fs::read_to_string(root.join(&r.file)).unwrap_or_default();
5274                    let line = yf.lines().position(|l| l.contains(v.as_str())).map(|i| i + 1);
5275                    self.check_template_text(&r.file, v, line.map_or(0, |l| l - 1), line, &cli);
5276                    self.check_vars(&r.file, v, line.map_or(0, |l| l - 1), &b.vars, &cli, &ctx);
5277                }
5278            }
5279        }
5280    }
5281
5282    /// Syntax, `env_var()` and `run.*` checks for one template source.
5283    fn check_template_text(
5284        &mut self,
5285        file: &Path,
5286        src: &str,
5287        line_offset: usize,
5288        fixed_line: Option<usize>,
5289        _cli: &BTreeSet<&str>,
5290    ) {
5291        let f = Some(file.to_path_buf());
5292        if let Err((line, msg)) = preflight::check_syntax(&file.to_string_lossy(), src) {
5293            let line = fixed_line.or(line.map(|l| l + line_offset));
5294            self.diags.error(
5295                Code::JinjaSyntax,
5296                f.clone(),
5297                line,
5298                format!("Jinja syntax error: {msg}"),
5299            );
5300            return;
5301        }
5302        for c in preflight::calls(src)
5303            .into_iter()
5304            .filter(|c| c.func == "env_var" && !c.has_default)
5305        {
5306            if std::env::var_os(&c.name).is_none() {
5307                self.diags.error(
5308                    Code::UnsetEnvVar,
5309                    f.clone(),
5310                    Some(fixed_line.unwrap_or(c.line + line_offset)),
5311                    format!(
5312                        "`env_var('{}')`: environment variable `{}` is not set and no default is given",
5313                        c.name, c.name
5314                    ),
5315                );
5316            }
5317        }
5318        for (name, instead, line) in preflight::removed_names(src) {
5319            self.diags.error(
5320                Code::RemovedTemplateName,
5321                f.clone(),
5322                Some(fixed_line.unwrap_or(line + line_offset)),
5323                format!("`{name}` was removed in DRE 0.2: use {instead}"),
5324            );
5325        }
5326        for (r, line) in preflight::unknown_run_refs(src) {
5327            self.diags.error(
5328                Code::UnknownRunAttribute,
5329                f.clone(),
5330                Some(fixed_line.unwrap_or(line + line_offset)),
5331                format!(
5332                    "`{r}` isn't part of the run context; known: run.report, run.set, run.target, run.schedule, run.date (a date: .prev_month, .month_start, .yyyymmdd, ...), run.now, run.scheduled_at, run.timezone, run.date_format(...)"
5333                ),
5334            );
5335        }
5336    }
5337
5338    fn check_vars(
5339        &mut self,
5340        file: &Path,
5341        src: &str,
5342        line_offset: usize,
5343        vars: &JsonMap<String, Json>,
5344        cli: &BTreeSet<&str>,
5345        ctx: &str,
5346    ) {
5347        for c in preflight::calls(src)
5348            .into_iter()
5349            .filter(|c| c.func == "var" && !c.has_default)
5350        {
5351            if !vars.contains_key(&c.name) && !cli.contains(c.name.as_str()) {
5352                self.diags.error(
5353                    Code::UnresolvedVar,
5354                    Some(file.to_path_buf()),
5355                    Some(c.line + line_offset),
5356                    format!(
5357                        "`var('{}')` has no value for {ctx} (checked --var, Set, report, folder and project vars) and no default",
5358                        c.name
5359                    ),
5360                );
5361            }
5362        }
5363    }
5364
5365    fn check_template_files(&mut self, project: &Project) {
5366        let mut seen = BTreeSet::new();
5367        for r in &project.reports {
5368            for t in r
5369                .bindings
5370                .iter()
5371                .flat_map(|b| b.outputs.iter().filter_map(|o| o.template.as_ref()))
5372            {
5373                if !seen.insert((r.name.clone(), t.file.clone(), format!("{:?}", t.bindings))) {
5374                    continue;
5375                }
5376                let Some(path) = find_template(&self.root, &t.file) else {
5377                    self.diags.error(
5378                        Code::MissingTemplate,
5379                        Some(r.file.clone()),
5380                        None,
5381                        format!("report `{}`: template file `{}` doesn't exist", r.name, t.file),
5382                    );
5383                    continue;
5384                };
5385                match template_sheets(&path) {
5386                    Ok(sheets) => {
5387                        for tb in &t.bindings {
5388                            if !sheets.contains(&tb.sheet) {
5389                                self.diags.error(
5390                                    Code::InvalidTemplate,
5391                                    Some(r.file.clone()),
5392                                    None,
5393                                    format!(
5394                                        "report `{}`: template `{}` has no sheet `{}` (sheets: {})",
5395                                        r.name,
5396                                        t.file,
5397                                        tb.sheet,
5398                                        sheets.join(", ")
5399                                    ),
5400                                );
5401                            }
5402                        }
5403                    }
5404                    Err(e) => self.diags.error(
5405                        Code::InvalidTemplate,
5406                        Some(r.file.clone()),
5407                        None,
5408                        format!("report `{}`: can't read template `{}`: {e}", r.name, t.file),
5409                    ),
5410                }
5411            }
5412        }
5413    }
5414}
5415
5416/// Sheet names in an xlsx template, read without modifying it.
5417/// Where a template file named in YAML (`template:`, a message's `file:`) is read from: the
5418/// project root, else `templates/`.
5419pub fn find_template(root: &Path, file: &str) -> Option<PathBuf> {
5420    [root.join(file), root.join("templates").join(file)]
5421        .into_iter()
5422        .find(|p| p.is_file())
5423}
5424
5425/// "output `name`", or "output 2" for an unnamed one, as messages name an output.
5426pub fn output_label(name: Option<&str>, index: usize) -> String {
5427    match name {
5428        Some(n) => format!("output `{n}`"),
5429        None => format!("output {}", index + 1),
5430    }
5431}
5432
5433pub fn template_sheets(path: &Path) -> Result<Vec<String>, String> {
5434    use calamine::Reader;
5435    let wb: calamine::Xlsx<_> =
5436        calamine::open_workbook(path).map_err(|e: calamine::XlsxError| e.to_string())?;
5437    Ok(wb.sheet_names().to_vec())
5438}
5439
5440struct RawReport {
5441    name: String,
5442    file: Rc<YamlFile>,
5443    folder: Vec<String>,
5444    /// Each key's value and the fragment it's in, for the keys not yet read as typed config.
5445    keys: BTreeMap<String, Located<Value>>,
5446    /// The line of each key, in its fragment.
5447    lines: BTreeMap<String, Option<usize>>,
5448    /// The typed keys, each from the fragment that declares it.
5449    typed: ReportFile,
5450}
5451
5452struct BindingBase {
5453    profile: Option<String>,
5454    profile_at: Option<ProfileAt>,
5455    vars: JsonMap<String, Json>,
5456    /// The merged outputs, in order: one unless a layer gave a list.
5457    outputs: Vec<Mapping>,
5458    locale: Option<String>,
5459}
5460
5461/// Everything the project references, for profile and plugin checks.
5462#[derive(Default)]
5463struct Usage {
5464    connections: BTreeMap<String, (Option<PathBuf>, Option<usize>)>,
5465    destinations: BTreeMap<String, (Option<PathBuf>, Option<usize>)>,
5466    /// format → first user, for messages.
5467    formats: BTreeMap<String, (String, PathBuf)>,
5468}
5469
5470impl Usage {
5471    /// A connection profile used by name. A value holding Jinja is recorded once the parse pass
5472    /// has rendered it.
5473    fn connection(&mut self, p: &str, file: Option<PathBuf>, line: Option<usize>) {
5474        if preflight::is_templated(p) {
5475            return;
5476        }
5477        let e = self.connections.entry(p.to_string()).or_insert((None, None));
5478        if e.0.is_none() {
5479            *e = (file, line);
5480        }
5481    }
5482    fn destination(&mut self, p: &str, file: Option<PathBuf>, line: Option<usize>) {
5483        if preflight::is_templated(p) {
5484            return;
5485        }
5486        let e = self.destinations.entry(p.to_string()).or_insert((None, None));
5487        if e.0.is_none() {
5488            *e = (file, line);
5489        }
5490    }
5491    fn format(&mut self, f: &str, ctx: &str, file: &Path) {
5492        self.formats
5493            .entry(f.to_string())
5494            .or_insert_with(|| (ctx.to_string(), file.to_path_buf()));
5495    }
5496}
5497
5498// ---------------------------------------------------------------------------------------------
5499// Helpers
5500// ---------------------------------------------------------------------------------------------
5501
5502fn builtin_output() -> Mapping {
5503    let mut m = Mapping::new();
5504    m.insert("format".into(), Value::String("csv".into()));
5505    m
5506}
5507
5508fn project_default_output(p: &Project) -> Option<Mapping> {
5509    let yf = YamlFile::parse(
5510        std::fs::read_to_string(p.root.join(PROJECT_FILE)).ok()?,
5511        PathBuf::from(PROJECT_FILE),
5512        &mut Diagnostics::default(),
5513    )?;
5514    yf.value.get("default_output").and_then(Value::as_object).cloned()
5515}
5516
5517/// Apply one report or Set `output:` layer over the inherited outputs; returns the problems.
5518///
5519/// - A list replaces whatever was inherited; each entry starts from the built-in defaults.
5520/// - A map merges ([`merge_output`]) into the single inherited output, or into the one its
5521///   `name:` names. With several inherited outputs and no `name:`, it's unclear which one to
5522///   change: the layer is ignored and the problem says why.
5523pub fn layer_outputs(base: &mut Vec<Mapping>, over: &Value) -> Vec<String> {
5524    let mut problems = Vec::new();
5525    match over {
5526        Value::Array(list) if list.is_empty() => problems.push(
5527            "`output` is an empty list; give at least one output, or remove it to use the inherited one"
5528                .into(),
5529        ),
5530        Value::Array(list) => {
5531            let mut outs = Vec::new();
5532            for (i, entry) in list.iter().enumerate() {
5533                match entry.as_object() {
5534                    Some(m) => {
5535                        let mut o = builtin_output();
5536                        problems.extend(merge_output(&mut o, m));
5537                        outs.push(o);
5538                    }
5539                    None => problems.push(format!("`output` entry {} must be a map", i + 1)),
5540                }
5541            }
5542            if problems.is_empty() {
5543                *base = outs;
5544            }
5545        }
5546        Value::Object(m) => {
5547            let name_of = |o: &Mapping| o.get("name").and_then(Value::as_str).map(str::to_string);
5548            let target = match m.get("name").and_then(Value::as_str) {
5549                Some(n) => match base.iter().position(|o| name_of(o).as_deref() == Some(n)) {
5550                    Some(i) => Some(i),
5551                    None if base.len() == 1 && name_of(&base[0]).is_none() => Some(0),
5552                    None => {
5553                        let names: Vec<String> = base
5554                            .iter()
5555                            .filter_map(name_of)
5556                            .map(|n| format!("`{n}`"))
5557                            .collect();
5558                        problems.push(format!(
5559                            "no inherited output is named `{n}`{}; override the full `output:` list to add one",
5560                            if names.is_empty() {
5561                                String::new()
5562                            } else {
5563                                format!(" (they're {})", names.join(", "))
5564                            }
5565                        ));
5566                        None
5567                    }
5568                },
5569                None if base.len() == 1 => Some(0),
5570                None => {
5571                    problems.push(format!(
5572                        "this overrides `output` with no `name:`, but it inherits {} outputs, so it's unclear which one to change; give the `name:` of the output to change, or override the full list",
5573                        base.len()
5574                    ));
5575                    None
5576                }
5577            };
5578            if let Some(i) = target {
5579                problems.extend(merge_output(&mut base[i], m));
5580            }
5581        }
5582        _ => problems.push("`output` must be a map or a list of maps".into()),
5583    }
5584    problems
5585}
5586
5587/// Merge an output layer over `base`. Changing `format` drops the lower layers' format options,
5588/// since they belong to a different format.
5589///
5590/// `destination` (a map or a list of maps):
5591/// - a list replaces whatever was inherited;
5592/// - a map naming a different `profile` replaces it too, so one plugin's options never leak into
5593///   another's;
5594/// - a map without `profile` (or with the same one) merges key by key into the inherited single
5595///   destination, so a Binding can override just `path`. With several inherited destinations
5596///   that's ambiguous: the layer's destination is ignored and the returned message says why.
5597pub fn merge_output(base: &mut Mapping, over: &Mapping) -> Option<String> {
5598    if let Some(f) = over.get("format")
5599        && base.get("format") != Some(f)
5600    {
5601        base.retain(|k, _| is_one_of(k, FORMAT_INDEPENDENT_KEYS));
5602    }
5603    let mut problem = None;
5604    for (k, v) in over {
5605        if k.as_str() == "destination"
5606            && let Value::Object(o) = v
5607        {
5608            let profile = |m: &Mapping| m.get("profile").cloned();
5609            match base.get_mut(k) {
5610                Some(Value::Array(list)) if o.get("profile").is_none() && list.len() > 1 => {
5611                    problem = Some(format!(
5612                        "this overrides `destination` with no `profile:`, but it inherits {} destinations, so it's unclear which one to change; override the full list instead",
5613                        list.len()
5614                    ));
5615                    continue;
5616                }
5617                Some(Value::Array(list))
5618                    if list.len() == 1
5619                        && list[0]
5620                            .as_object()
5621                            .is_some_and(|b| o.get("profile").is_none() || profile(b) == profile(o)) =>
5622                {
5623                    let mut b = list[0].as_object().cloned().unwrap_or_default();
5624                    b.extend(o.clone());
5625                    base.insert(k.clone(), Value::Object(b));
5626                    continue;
5627                }
5628                Some(Value::Object(b)) if o.get("profile").is_none() || profile(b) == profile(o) => {
5629                    b.extend(o.clone());
5630                    continue;
5631                }
5632                _ => {}
5633            }
5634        }
5635        base.insert(k.clone(), v.clone());
5636    }
5637    problem
5638}
5639
5640/// Where an inherited `profile` comes from when the report sets none: the deepest folder's
5641/// `+profile`, else `default_profile`.
5642fn inherited_profile_at(layers: &[&FolderCfg], project: &Project) -> Option<ProfileAt> {
5643    if let Some((_, line)) = layers.iter().rev().find_map(|l| l.profile.as_ref()) {
5644        return Some(ProfileAt {
5645            file: PathBuf::from(PROJECT_FILE),
5646            line: *line,
5647            key: "`+profile`".into(),
5648        });
5649    }
5650    project.default_profile.as_ref().map(|_| ProfileAt {
5651        file: PathBuf::from(PROJECT_FILE),
5652        line: project.default_profile_line,
5653        key: "`default_profile`".into(),
5654    })
5655}
5656
5657fn folder_layers<'a>(folders: &'a BTreeMap<Vec<String>, FolderCfg>, folder: &[String]) -> Vec<&'a FolderCfg> {
5658    (0..=folder.len())
5659        .filter_map(|n| folders.get(&folder[..n].to_vec()))
5660        .collect()
5661}
5662
5663fn folder_segments(rel: &Path) -> Vec<String> {
5664    rel.components()
5665        .skip(1)
5666        .map(|c| c.as_os_str().to_string_lossy().to_string())
5667        .collect()
5668}
5669
5670pub fn dotted(path: &[String]) -> String {
5671    path.join(".")
5672}
5673
5674fn pick(v: &Value, keys: &[&str]) -> Mapping {
5675    v.as_object()
5676        .map(|m| {
5677            m.iter()
5678                .filter(|(k, _)| is_one_of(k, keys))
5679                .map(|(k, v)| (k.clone(), v.clone()))
5680                .collect()
5681        })
5682        .unwrap_or_default()
5683}
5684
5685/// Every string inside a JSON value (the templated values of a destination option).
5686fn json_strings(v: &Json) -> Vec<String> {
5687    match v {
5688        Json::String(s) => vec![s.clone()],
5689        Json::Array(a) => a.iter().flat_map(json_strings).collect(),
5690        Json::Object(o) => o.values().flat_map(json_strings).collect(),
5691        _ => Vec::new(),
5692    }
5693}
5694
5695fn is_one_of(k: &str, keys: &[&str]) -> bool {
5696    keys.contains(&k)
5697}
5698
5699/// A map of names to timings: every value holds `cron`, `every` or `rrule`.
5700fn is_timing_registry(m: &Mapping) -> bool {
5701    !m.is_empty()
5702        && m.values().all(|v| {
5703            v.as_object()
5704                .is_some_and(|t| ["cron", "every", "rrule"].iter().any(|k| t.contains_key(*k)))
5705        })
5706}
5707
5708/// Every entry is a Set: a map of `profile`/`vars`, which may be empty (`plain: {}`, the
5709/// report's defaults) or left blank.
5710fn is_set_registry(m: &Mapping) -> bool {
5711    m.values().any(Value::is_object)
5712        && m.values().all(|v| {
5713            v.is_null()
5714                || v.as_object()
5715                    .is_some_and(|e| e.keys().all(|k| is_one_of(k, &["profile", "vars", "locale"])))
5716        })
5717}
5718
5719fn string_list(v: &Value) -> Option<Vec<String>> {
5720    v.as_array()?
5721        .iter()
5722        .map(|i| i.as_str().map(str::to_string))
5723        .collect()
5724}
5725
5726/// The query a `queries:` item names, if it names one.
5727fn query_item_name(item: &QueryItem) -> Option<&str> {
5728    match &item.value {
5729        Loose::Ok(de::OneOf::A(s)) => Some(s),
5730        Loose::Ok(de::OneOf::B(e)) => e.query.as_ref().and_then(Loose::ok).map(String::as_str),
5731        Loose::Bad(_) => None,
5732    }
5733}
5734
5735fn entry_name(v: &Value) -> Option<String> {
5736    match v {
5737        Value::String(s) => Some(s.clone()),
5738        Value::Object(m) => m.get("query").and_then(Value::as_str).map(str::to_string),
5739        _ => None,
5740    }
5741}
5742
5743fn dedup(v: &mut Vec<String>) {
5744    let mut seen = BTreeSet::new();
5745    v.retain(|t| seen.insert(t.clone()));
5746}
5747
5748/// Letters, digits and `_`, not starting with a digit.
5749fn is_identifier(s: &str) -> bool {
5750    !s.is_empty()
5751        && !s.starts_with(|c: char| c.is_ascii_digit())
5752        && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
5753}