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