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 and required flag");
3089                BTreeMap::new()
3090            }
3091        };
3092        for (name, definition) in &var_schema {
3093            if let Some(value) = &definition.default
3094                && let Err(error) = definition.check(name, value)
3095            {
3096                self.diags
3097                    .error(Code::InvalidField, Some(r.file.display.clone()), None, error);
3098            }
3099            if name.trim().is_empty() {
3100                self.diags.error(
3101                    Code::InvalidField,
3102                    Some(r.file.display.clone()),
3103                    None,
3104                    "variable names must be non-empty",
3105                );
3106            }
3107        }
3108        let description = self.typed_string(
3109            &located("description").map_or_else(|| r.file.display.clone(), |(f, _)| f),
3110            typed.description.clone(),
3111            "description",
3112        );
3113        let meta_file = located("meta").map_or_else(|| r.file.display.clone(), |(f, _)| f);
3114        let meta = self.typed_metadata(&meta_file, typed.meta.clone(), "meta");
3115        let mut meta_layers = metadata_layers(project, &layers);
3116        if typed.meta.is_some() {
3117            meta_layers.push(MetaLayer {
3118                file: crate::slash(&meta_file).to_string_lossy().into_owned(),
3119                key: "meta".into(),
3120                meta: meta.clone(),
3121            });
3122        }
3123
3124        let mut tags: Vec<String> = layers.iter().flat_map(|l| l.tags.clone()).collect();
3125        if let Some(t) = &typed.tags {
3126            match &t.value {
3127                Loose::Ok(l) => tags.extend(l.iter().cloned()),
3128                Loose::Bad(_) => {
3129                    let (f, l) = located("tags").unwrap();
3130                    self.diags.error(
3131                        Code::InvalidField,
3132                        Some(f),
3133                        l,
3134                        format!("report `{name}`: `tags` must be a list of strings"),
3135                    );
3136                }
3137            }
3138        }
3139        dedup(&mut tags);
3140
3141        if key("profile").is_some() && key("sets").is_some() {
3142            let (f, l) = located("sets").unwrap();
3143            self.diags.error(
3144                Code::ProfileAndSets,
3145                Some(f),
3146                l,
3147                format!("report `{name}` declares both `profile:` and `sets:`; use one or the other"),
3148            );
3149        }
3150
3151        let report_profile = match &typed.profile {
3152            Some(p) => match &p.value {
3153                Loose::Ok(s) => {
3154                    let (f, l) = located("profile").unwrap();
3155                    used.connection(s, Some(f), l);
3156                    Some(s.to_string())
3157                }
3158                Loose::Bad(_) => {
3159                    let (f, l) = located("profile").unwrap();
3160                    self.diags.error(
3161                        Code::InvalidField,
3162                        Some(f),
3163                        l,
3164                        format!("report `{name}`: `profile` must be a string"),
3165                    );
3166                    None
3167                }
3168            },
3169            None => None,
3170        };
3171        let folder_profile = layers
3172            .iter()
3173            .rev()
3174            .find_map(|l| l.profile.as_ref().map(|p| p.0.clone()));
3175        let report_timezone = match &typed.timezone {
3176            Some(t) => {
3177                let (f, l) = located("timezone").unwrap();
3178                self.timezone_str(
3179                    t.value.ok().map(String::as_str),
3180                    &f,
3181                    l,
3182                    &format!("report `{name}`: `timezone`"),
3183                )
3184            }
3185            None => None,
3186        };
3187        let timezone = report_timezone
3188            .or_else(|| layers.iter().rev().find_map(|l| l.timezone.clone()))
3189            .or_else(|| project.timezone.clone());
3190        let report_locale = match &typed.locale {
3191            Some(t) => {
3192                let (f, l) = located("locale").unwrap();
3193                self.locale_str(
3194                    t.value.ok().map(String::as_str),
3195                    &f,
3196                    l,
3197                    &format!("report `{name}`: `locale`"),
3198                )
3199            }
3200            None => None,
3201        };
3202        let locale = report_locale
3203            .or_else(|| layers.iter().rev().find_map(|l| l.locale.clone()))
3204            .or_else(|| project.locale.clone());
3205        let profile_at = if report_profile.is_some() {
3206            located("profile").map(|(file, line)| ProfileAt {
3207                file,
3208                line,
3209                key: "`profile`".into(),
3210            })
3211        } else {
3212            inherited_profile_at(&layers, project)
3213        };
3214        let base_profile = report_profile
3215            .or(folder_profile)
3216            .or(project.default_profile.clone());
3217
3218        let mut vars = project.vars.clone();
3219        for l in &layers {
3220            if let Some(v) = &l.vars {
3221                vars.extend(v.clone());
3222            }
3223        }
3224        if let Some(v) = &typed.vars {
3225            match &v.value {
3226                Loose::Ok(m) => vars.extend(m.clone()),
3227                Loose::Bad(_) => {
3228                    let (f, l) = located("vars").unwrap();
3229                    self.diags.error(
3230                        Code::InvalidField,
3231                        Some(f),
3232                        l,
3233                        format!("report `{name}`: `vars` must be a map"),
3234                    );
3235                }
3236            }
3237        }
3238
3239        let mut output = builtin_output();
3240        for (name, definition) in &var_schema {
3241            if let Some(value) = &definition.default {
3242                vars.entry(name.clone()).or_insert_with(|| value.clone());
3243            }
3244        }
3245        let mut merge_problems = Vec::new();
3246        if let Some(o) = project_default_output(project) {
3247            merge_problems.extend(merge_output(&mut output, &o));
3248        }
3249        for l in &layers {
3250            if let Some(o) = &l.output {
3251                merge_problems.extend(merge_output(&mut output, o));
3252            }
3253        }
3254        for p in merge_problems {
3255            self.diags.error(
3256                Code::InvalidField,
3257                Some(r.file.display.clone()),
3258                None,
3259                format!("report `{name}`: folder config: {p}"),
3260            );
3261        }
3262        let mut outputs = vec![output];
3263        if let Some(o) = key("output") {
3264            for p in layer_outputs(&mut outputs, &o.value) {
3265                let (f, l) = located("output").unwrap();
3266                self.diags
3267                    .error(Code::InvalidField, Some(f), l, format!("report `{name}`: {p}"));
3268            }
3269        }
3270
3271        if typed.schedule.is_some() {
3272            let (f, l) = located("schedule").unwrap();
3273            self.moved_to_schedules(&f, l, &format!("report `{name}`: `schedule`"));
3274        }
3275
3276        let default_set = typed.default_set.as_ref().and_then(|d| d.value.ok().cloned());
3277
3278        let base = BindingBase {
3279            profile: base_profile,
3280            profile_at,
3281            vars,
3282            outputs,
3283            locale,
3284        };
3285        let has_sets = key("sets").is_some();
3286        let report_base = self.silent_binding(&name, &base, &queries, &r.file.display);
3287        let mut bindings = Vec::new();
3288        if let (Some(s), Some(at)) = (&typed.sets, key("sets")) {
3289            bindings = self.resolve_sets(&name, s, &at.file.display, &queries, &base, project, used);
3290            let declared: Vec<&str> = bindings.iter().filter_map(|b| b.set.as_deref()).collect();
3291            if let Some(d) = &default_set
3292                && !declared.contains(&d.as_str())
3293            {
3294                let (f, l) = located("default_set").unwrap();
3295                self.diags.error(
3296                    Code::UnknownDefaultSet,
3297                    Some(f),
3298                    l,
3299                    format!("report `{name}`: `default_set` `{d}` isn't one of the report's `sets:`"),
3300                );
3301            }
3302        } else {
3303            if default_set.is_some() {
3304                let (f, l) = located("default_set").unwrap();
3305                self.diags.error(
3306                    Code::UnknownDefaultSet,
3307                    Some(f),
3308                    l,
3309                    format!("report `{name}`: `default_set` needs a `sets:` list"),
3310                );
3311            }
3312            let b = self.finish_binding(
3313                &name,
3314                None,
3315                &base,
3316                None,
3317                queries.clone(),
3318                r.file.display.clone(),
3319                used,
3320            );
3321            bindings.push(b);
3322        }
3323
3324        Some(Report {
3325            name,
3326            var_schema,
3327            description,
3328            meta,
3329            meta_layers,
3330            managed: true,
3331            file: r.file.display.clone(),
3332            folder: r.folder.clone(),
3333            tags,
3334            queries,
3335            default_set,
3336            timezone,
3337            has_sets,
3338            bindings,
3339            base: report_base,
3340        })
3341    }
3342
3343    #[allow(clippy::too_many_arguments)]
3344    fn resolve_sets(
3345        &mut self,
3346        report: &str,
3347        sets: &de::Located<Loose<Vec<SetItem>>>,
3348        display: &Path,
3349        queries: &[QueryEntry],
3350        base: &BindingBase,
3351        project: &Project,
3352        used: &mut Usage,
3353    ) -> Vec<Binding> {
3354        let file = Some(display.to_path_buf());
3355        let sets_line = sets.line();
3356        let Loose::Ok(items) = &sets.value else {
3357            self.diags.error(
3358                Code::InvalidField,
3359                file,
3360                sets_line,
3361                format!("report `{report}`: `sets` must be a list"),
3362            );
3363            return Vec::new();
3364        };
3365        let mut seen = BTreeSet::new();
3366        let mut out = Vec::new();
3367        for item in items {
3368            let line = item.line();
3369            let (name, inline): (String, Option<&SetEntry>) = match &item.value {
3370                Loose::Ok(de::OneOf::A(s)) => (s.clone(), None),
3371                Loose::Ok(de::OneOf::B(m)) => match &m.name {
3372                    Some(Loose::Ok(n)) => (n.clone(), Some(m)),
3373                    _ => {
3374                        self.diags.error(
3375                            Code::InvalidField,
3376                            file.clone(),
3377                            line,
3378                            format!("report `{report}`: a `sets:` map entry needs a `name:`"),
3379                        );
3380                        continue;
3381                    }
3382                },
3383                Loose::Bad(_) => {
3384                    self.diags.error(
3385                        Code::InvalidField,
3386                        file.clone(),
3387                        line,
3388                        format!("report `{report}`: `sets:` entries must be names or maps"),
3389                    );
3390                    continue;
3391                }
3392            };
3393            if !seen.insert(name.clone()) {
3394                self.diags.error(
3395                    Code::DuplicateSet,
3396                    file.clone(),
3397                    line,
3398                    format!("report `{report}` lists Set `{name}` more than once"),
3399                );
3400                continue;
3401            }
3402            let registry = project.sets.get(&name);
3403            if inline.is_none() && registry.is_none() {
3404                self.diags.error(
3405                    Code::UnknownSet,
3406                    file.clone(),
3407                    line,
3408                    format!("report `{report}`: Set `{name}` isn't declared in sets.yml"),
3409                );
3410                continue;
3411            }
3412            let mut b = BindingBase {
3413                profile: base.profile.clone(),
3414                profile_at: base.profile_at.clone(),
3415                vars: base.vars.clone(),
3416                outputs: base.outputs.clone(),
3417                locale: base.locale.clone(),
3418            };
3419            if let Some(reg) = registry {
3420                if let Some(p) = &reg.profile {
3421                    b.profile = Some(p.clone());
3422                    b.profile_at = Some(ProfileAt {
3423                        file: reg.file.clone(),
3424                        line: reg.line,
3425                        key: format!("`profile` of Set `{name}`"),
3426                    });
3427                    used.connection(p, None, None);
3428                }
3429                b.vars.extend(reg.vars.clone());
3430                if reg.locale.is_some() {
3431                    b.locale = reg.locale.clone();
3432                }
3433            }
3434            let mut qs = queries.to_vec();
3435            let mut tab_names: Option<Mapping> = None;
3436            if let Some(m) = inline {
3437                let ctx = format!("report `{report}`, Set `{name}`");
3438                for k in &m.unknown.0 {
3439                    self.diags.error(
3440                        Code::UnknownKey,
3441                        file.clone(),
3442                        Some(k.line),
3443                        format!("{ctx}: unknown key `{}`", k.name),
3444                    );
3445                }
3446                if let Some(p) = &m.profile {
3447                    let pline = p.line();
3448                    match &p.value {
3449                        Loose::Ok(p) => {
3450                            b.profile = Some(p.to_string());
3451                            b.profile_at = Some(ProfileAt {
3452                                file: display.to_path_buf(),
3453                                line: pline,
3454                                key: format!("`profile` of Set `{name}`"),
3455                            });
3456                            used.connection(p, file.clone(), pline);
3457                        }
3458                        Loose::Bad(_) => self.diags.error(
3459                            Code::InvalidField,
3460                            file.clone(),
3461                            pline,
3462                            format!("{ctx}: `profile` must be a string"),
3463                        ),
3464                    }
3465                }
3466                match &m.vars {
3467                    Some(Loose::Ok(v)) => b.vars.extend(v.clone()),
3468                    None => {}
3469                    Some(Loose::Bad(_)) => self.diags.error(
3470                        Code::InvalidField,
3471                        file.clone(),
3472                        line,
3473                        format!("{ctx}: `vars` must be a map"),
3474                    ),
3475                }
3476                if let Some(o) = &m.output {
3477                    for p in layer_outputs(&mut b.outputs, o) {
3478                        self.diags
3479                            .error(Code::InvalidField, file.clone(), line, format!("{ctx}: {p}"));
3480                    }
3481                }
3482                if m.schedule.is_some() {
3483                    self.moved_to_schedules(display, line, &format!("{ctx}: `schedule`"));
3484                }
3485                if let Some(l) = &m.locale
3486                    && let Some(l) = self.locale_str(
3487                        l.ok().map(String::as_str),
3488                        display,
3489                        line,
3490                        &format!("{ctx}: `locale`"),
3491                    )
3492                {
3493                    b.locale = Some(l);
3494                }
3495                let listed: Vec<&str> = queries.iter().map(|q| q.query.as_str()).collect();
3496                match (&m.exclude, &m.queries) {
3497                    (Some(_), Some(_)) => self.diags.error(
3498                        Code::ExcludeAndQueries,
3499                        file.clone(),
3500                        line,
3501                        format!("{ctx}: use either `exclude:` or `queries:`, not both"),
3502                    ),
3503                    (Some(ex), None) => match &ex.value {
3504                        Loose::Ok(names) => {
3505                            for q in names {
3506                                if !listed.contains(&q.as_str()) {
3507                                    self.diags.error(
3508                                        Code::UnknownQuery,
3509                                        file.clone(),
3510                                        ex.line(),
3511                                        format!("{ctx}: `exclude` names `{q}`, which isn't in the report's `queries:`"),
3512                                    );
3513                                }
3514                            }
3515                            qs.retain(|q| !names.contains(&q.query));
3516                        }
3517                        Loose::Bad(_) => self.diags.error(
3518                            Code::InvalidField,
3519                            file.clone(),
3520                            line,
3521                            format!("{ctx}: `exclude` must be a list of query names"),
3522                        ),
3523                    },
3524                    (None, Some(ov)) => match &ov.value {
3525                        Loose::Ok(items) => {
3526                            let mut sub = Vec::new();
3527                            for it in items {
3528                                let (qn, settings) = match it {
3529                                    Loose::Ok(de::OneOf::A(s)) => (s.clone(), None),
3530                                    Loose::Ok(de::OneOf::B(e)) => {
3531                                        match e.query.as_ref().and_then(Loose::ok) {
3532                                            Some(q) => (q.clone(), Some(e)),
3533                                            None => continue,
3534                                        }
3535                                    }
3536                                    Loose::Bad(_) => continue,
3537                                };
3538                                match queries.iter().find(|q| q.query == qn) {
3539                                    Some(q) => {
3540                                        let mut q = q.clone();
3541                                        if let Some(e) = settings {
3542                                            match &e.tab_name {
3543                                                None => {}
3544                                                Some(Loose::Ok(s)) => q.tab_name = Some(s.clone()),
3545                                                Some(Loose::Bad(_)) => self.diags.error(
3546                                                    Code::InvalidField,
3547                                                    file.clone(),
3548                                                    line,
3549                                                    format!("{ctx}: {}", one_tab_per_file(&qn)),
3550                                                ),
3551                                            }
3552                                            if let Some(Loose::Ok(b)) = &e.tab {
3553                                                q.tab = *b;
3554                                            }
3555                                        }
3556                                        sub.push(q);
3557                                    }
3558                                    None => self.diags.error(
3559                                        Code::UnknownQuery,
3560                                        file.clone(),
3561                                        ov.line(),
3562                                        format!("{ctx}: `queries` names `{qn}`, which isn't in the report's `queries:`"),
3563                                    ),
3564                                }
3565                            }
3566                            qs = sub;
3567                        }
3568                        Loose::Bad(_) => self.diags.error(
3569                            Code::InvalidField,
3570                            file.clone(),
3571                            line,
3572                            format!("{ctx}: `queries` must be a list"),
3573                        ),
3574                    },
3575                    (None, None) => {}
3576                }
3577                tab_names = match &m.tab_names {
3578                    None => None,
3579                    Some(Loose::Ok(t)) => Some(t.clone()),
3580                    Some(Loose::Bad(_)) => {
3581                        self.diags.error(
3582                            Code::InvalidField,
3583                            file.clone(),
3584                            line,
3585                            format!("{ctx}: `tab_names` must be a map of query name to tab name"),
3586                        );
3587                        None
3588                    }
3589                };
3590            }
3591            let bind = self.finish_binding(
3592                report,
3593                Some(name),
3594                &b,
3595                tab_names.as_ref(),
3596                qs,
3597                display.to_path_buf(),
3598                used,
3599            );
3600            out.push(bind);
3601        }
3602        out
3603    }
3604
3605    /// Resolve a Binding without recording diagnostics or usage (they're reported elsewhere).
3606    fn silent_binding(
3607        &mut self,
3608        report: &str,
3609        base: &BindingBase,
3610        queries: &[QueryEntry],
3611        file: &Path,
3612    ) -> Binding {
3613        let saved = std::mem::take(&mut self.diags);
3614        let mut scratch = Usage::default();
3615        let b = self.finish_binding(
3616            report,
3617            None,
3618            base,
3619            None,
3620            queries.to_vec(),
3621            file.to_path_buf(),
3622            &mut scratch,
3623        );
3624        self.diags = saved;
3625        b
3626    }
3627
3628    #[allow(clippy::too_many_arguments)]
3629    fn finish_binding(
3630        &mut self,
3631        report: &str,
3632        set: Option<String>,
3633        base: &BindingBase,
3634        tab_names: Option<&Mapping>,
3635        mut queries: Vec<QueryEntry>,
3636        file: PathBuf,
3637        used: &mut Usage,
3638    ) -> Binding {
3639        let ctx = match &set {
3640            Some(s) => format!("report `{report}`, Set `{s}`"),
3641            None => format!("report `{report}`"),
3642        };
3643        if let Some(t) = tab_names {
3644            for (k, v) in t {
3645                let q = k.as_str();
3646                match queries.iter_mut().find(|e| e.query == q) {
3647                    Some(e) => match v {
3648                        Value::String(s) => e.tab_name = Some(s.clone()),
3649                        _ => self.diags.error(
3650                            Code::InvalidField,
3651                            Some(file.clone()),
3652                            None,
3653                            format!("{ctx}: {}", one_tab_per_file(q)),
3654                        ),
3655                    },
3656                    None => self.diags.error(
3657                        Code::UnknownQuery,
3658                        Some(file.clone()),
3659                        None,
3660                        format!("{ctx}: `tab_names` names `{q}`, which isn't one of this Binding's queries"),
3661                    ),
3662                }
3663            }
3664        }
3665        let outputs = self.typed_outputs(&base.outputs, &ctx, &file, &queries, used);
3666        if let Some(p) = &base.profile {
3667            used.connection(p, None, None);
3668        }
3669        Binding {
3670            set,
3671            profile: base.profile.clone(),
3672            vars: base.vars.clone(),
3673            queries: std::mem::take(&mut queries),
3674            locale: base.locale.clone(),
3675            outputs,
3676            schedules: Vec::new(),
3677            parsed: None,
3678            profile_at: base.profile_at.clone(),
3679        }
3680    }
3681
3682    /// Every merged output, typed, plus the checks across them: unique names, `queries:` naming
3683    /// the Binding's queries, xlsx-only `columns`, and queries that feed no output.
3684    fn typed_outputs(
3685        &mut self,
3686        maps: &[Mapping],
3687        ctx: &str,
3688        file: &Path,
3689        queries: &[QueryEntry],
3690        used: &mut Usage,
3691    ) -> Vec<Output> {
3692        let at = Some(file.to_path_buf());
3693        let several = maps.len() > 1;
3694        let mut outputs = Vec::new();
3695        for (i, m) in maps.iter().enumerate() {
3696            let octx = if several {
3697                format!(
3698                    "{ctx}, {}",
3699                    output_label(m.get("name").and_then(Value::as_str), i)
3700                )
3701            } else {
3702                ctx.to_string()
3703            };
3704            let subset: Vec<QueryEntry> = match m.get("queries") {
3705                None | Some(Value::Null) => queries.to_vec(),
3706                Some(v) => {
3707                    match string_list(v) {
3708                        Some(names) => {
3709                            for n in names.iter().filter(|n| !queries.iter().any(|q| &&q.query == n)) {
3710                                self.diags.error(
3711                                Code::UnknownQuery,
3712                                at.clone(),
3713                                None,
3714                                format!("{octx}: `queries` names `{n}`, which isn't one of this Binding's queries"),
3715                            );
3716                            }
3717                            queries
3718                                .iter()
3719                                .filter(|q| names.contains(&q.query))
3720                                .cloned()
3721                                .collect()
3722                        }
3723                        None => {
3724                            self.diags.error(
3725                                Code::InvalidField,
3726                                at.clone(),
3727                                None,
3728                                format!("{octx}: `queries` must be a list of query names"),
3729                            );
3730                            queries.to_vec()
3731                        }
3732                    }
3733                }
3734            };
3735            outputs.push(self.typed_output(m, &octx, file, &subset, used));
3736        }
3737        let mut seen = BTreeSet::new();
3738        for o in &outputs {
3739            if let Some(n) = &o.name
3740                && !seen.insert(n.clone())
3741            {
3742                self.diags.error(
3743                    Code::DuplicateOutput,
3744                    at.clone(),
3745                    None,
3746                    format!("{ctx}: output `{n}` is declared twice; output names must be unique"),
3747                );
3748            }
3749        }
3750        // Two unnamed outputs of one format would write the same default file.
3751        let mut exts = BTreeSet::new();
3752        for o in outputs.iter().filter(|o| o.name.is_none()) {
3753            let ext = o.extension.clone().unwrap_or_else(|| o.format.clone());
3754            if !exts.insert(ext) {
3755                self.diags.error(
3756                    Code::DuplicateOutput,
3757                    at.clone(),
3758                    None,
3759                    format!(
3760                        "{ctx}: two unnamed `{}` outputs would write the same file; give each output a `name:`",
3761                        o.format
3762                    ),
3763                );
3764            }
3765        }
3766        for q in queries.iter().filter(|q| !q.columns.is_empty()) {
3767            let formats: Vec<&str> = outputs
3768                .iter()
3769                .filter(|o| o.feeds(&q.query))
3770                .map(|o| o.format.as_str())
3771                .collect();
3772            if !formats.is_empty() && !formats.contains(&"xlsx") {
3773                self.diags.error(
3774                    Code::InvalidField,
3775                    at.clone(),
3776                    None,
3777                    format!(
3778                        "{ctx}: `columns` on query `{}` only applies to the xlsx format",
3779                        q.query
3780                    ),
3781                );
3782            }
3783        }
3784        for (i, o) in outputs.iter().enumerate() {
3785            let octx = if several {
3786                format!("{ctx}, {}", o.label(i))
3787            } else {
3788                ctx.to_string()
3789            };
3790            self.check_output_templates(o, &outputs, &octx, file, queries);
3791            for d in o.destinations.iter().filter(|d| !d.attach.is_empty()) {
3792                for a in &d.attach {
3793                    let problem = match outputs.iter().find(|x| x.name.as_deref() == Some(a.as_str())) {
3794                        _ if !o.is_message() => Some("`attach` only applies to a message output".to_string()),
3795                        None => Some(format!("no output of this report is named `{a}`")),
3796                        Some(x) if std::ptr::eq(x, o) => Some(format!("`{a}` is this output itself")),
3797                        Some(x) if x.is_message() => Some(format!(
3798                            "`{a}` is a message; attach file outputs (its `.md` is linked with `outputs.{a}.location`)"
3799                        )),
3800                        Some(_) => None,
3801                    };
3802                    if let Some(p) = problem {
3803                        self.diags.error(
3804                            Code::InvalidDestinationOption,
3805                            at.clone(),
3806                            None,
3807                            format!("{octx}: destination `{}`: `attach: {a}`: {p}", d.profile),
3808                        );
3809                    }
3810                }
3811            }
3812        }
3813        for q in queries.iter().filter(|q| q.tab) {
3814            if !outputs.iter().any(|o| o.feeds(&q.query)) {
3815                self.diags.warning(
3816                    Code::UnusedQuery,
3817                    at.clone(),
3818                    None,
3819                    format!(
3820                        "{ctx}: query `{}` feeds no output; add it to an output's `queries:`, or give it `tab: false` if it only prepares later queries",
3821                        q.query
3822                    ),
3823                );
3824            }
3825        }
3826        outputs
3827    }
3828
3829    /// A message output's options, and the templates of any output (`text`, `file`, `title`,
3830    /// `when`): they compile, `results.<x>` names one of the output's queries and `outputs.<x>`
3831    /// another output. Only attribute access is checked; dynamic access isn't seen.
3832    fn check_output_templates(
3833        &mut self,
3834        o: &Output,
3835        all: &[Output],
3836        ctx: &str,
3837        file: &Path,
3838        queries: &[QueryEntry],
3839    ) {
3840        let at = Some(file.to_path_buf());
3841        let err = |s: &mut Self, msg: String| {
3842            s.diags.error(
3843                Code::InvalidOutputOption,
3844                at.clone(),
3845                None,
3846                format!("{ctx}: {msg}"),
3847            )
3848        };
3849        // (what, source) of every template to check.
3850        let mut templates: Vec<(String, String)> = Vec::new();
3851        if let Some(w) = &o.when {
3852            templates.push(("`when`".into(), format!("{{% if {w} %}}{{% endif %}}")));
3853        }
3854        if o.is_message() {
3855            for k in o.options.keys() {
3856                if !crate::message::MESSAGE_KEYS.contains(&k.as_str()) {
3857                    err(
3858                        self,
3859                        format!(
3860                            "`{k}` isn't an option of the `message` format; its options are {}",
3861                            crate::message::MESSAGE_KEYS.join(", ")
3862                        ),
3863                    );
3864                }
3865            }
3866            if o.options.contains_key("text") && o.options.contains_key("file") {
3867                err(self, "use either `text:` or `file:`, not both".into());
3868            }
3869            for k in ["text", "title"] {
3870                match o.options.get(k) {
3871                    None => {}
3872                    Some(Json::String(t)) => templates.push((format!("`{k}`"), t.clone())),
3873                    Some(_) => err(self, format!("`{k}` must be a string")),
3874                }
3875            }
3876            match o.options.get("file") {
3877                None => {}
3878                Some(Json::String(f)) => match find_template(&self.root, f).map(std::fs::read_to_string) {
3879                    Some(Ok(t)) => templates.push((format!("`file` {f}"), t)),
3880                    Some(Err(e)) => err(self, format!("can't read message file `{f}`: {e}")),
3881                    None => err(
3882                        self,
3883                        format!(
3884                            "message file `{f}` doesn't exist (looked in the project root and templates/)"
3885                        ),
3886                    ),
3887                },
3888                Some(_) => err(self, "`file` must be a path (a string)".into()),
3889            }
3890            match o.options.get("max_rows") {
3891                None => {}
3892                Some(v) if v.as_u64().is_some_and(|n| n > 0) => {}
3893                Some(_) => err(self, "`max_rows` must be a positive whole number".into()),
3894            }
3895        }
3896        let fed: Vec<&str> = queries
3897            .iter()
3898            .filter(|q| q.tab && o.feeds(&q.query))
3899            .map(|q| q.query.as_str())
3900            .collect();
3901        for (what, src) in &templates {
3902            if let Err((line, msg)) = preflight::check_syntax("output", src) {
3903                let line = line.map(|l| format!(" (line {l})")).unwrap_or_default();
3904                err(self, format!("{what} doesn't compile{line}: {msg}"));
3905                continue;
3906            }
3907            for (name, _) in preflight::attributes(src, "results") {
3908                if !fed.contains(&name.as_str()) {
3909                    err(
3910                        self,
3911                        format!(
3912                            "{what} reads `results.{name}`, but `{name}` isn't one of this output's queries ({})",
3913                            if fed.is_empty() {
3914                                "none".to_string()
3915                            } else {
3916                                fed.join(", ")
3917                            }
3918                        ),
3919                    );
3920                }
3921            }
3922            if !o.is_message() && !preflight::attributes(src, "outputs").is_empty() {
3923                err(
3924                    self,
3925                    format!(
3926                        "{what} reads `outputs.*`, but a file output's `when` is decided before anything is delivered; use `outputs.*` in a message output"
3927                    ),
3928                );
3929                continue;
3930            }
3931            for (name, _) in preflight::attributes(src, "outputs") {
3932                let other = all.iter().find(|x| x.name.as_deref() == Some(name.as_str()));
3933                match other {
3934                    None => err(
3935                        self,
3936                        format!(
3937                            "{what} reads `outputs.{name}`, but no output of this report is named `{name}`"
3938                        ),
3939                    ),
3940                    Some(x) if std::ptr::eq(x, o) => err(
3941                        self,
3942                        format!("{what} reads `outputs.{name}`, which is this output itself"),
3943                    ),
3944                    Some(_) => {}
3945                }
3946            }
3947        }
3948    }
3949
3950    /// Convert a merged output map into a typed `Output`, validating options and references.
3951    fn typed_output(
3952        &mut self,
3953        m: &Mapping,
3954        ctx: &str,
3955        file: &Path,
3956        queries: &[QueryEntry],
3957        used: &mut Usage,
3958    ) -> Output {
3959        let file_path = file.to_path_buf();
3960        let file = Some(file_path.clone());
3961        let node = crate::yaml::to_node(&Value::Object(m.clone()));
3962        let o: OutputConfig = match de::from_node(&node) {
3963            Ok(o) => o,
3964            // Every key is `Loose`, so a map always reads.
3965            Err(e) => unreachable!("an output map reads as an output: {e}"),
3966        };
3967        let format = o
3968            .format
3969            .as_ref()
3970            .and_then(Loose::ok)
3971            .cloned()
3972            .unwrap_or_else(|| "csv".to_string());
3973        // `message` is built in; every other format is a plugin.
3974        if format != MESSAGE_FORMAT {
3975            used.format(&format, ctx, &file_path);
3976        }
3977        let mut opts: JsonMap<String, Json> = o.options.0.into_iter().map(|(k, v)| (k.value, v)).collect();
3978        // The project's defaults for this format sit under whatever the layers set.
3979        if let Some(Value::Object(d)) = self.format_options.get(format.as_str()) {
3980            for (k, v) in d {
3981                if !OUTPUT_SHARED_KEYS.contains(&k.as_str()) {
3982                    opts.entry(k.clone()).or_insert_with(|| v.clone());
3983                }
3984            }
3985        }
3986        let destinations = match o.destination {
3987            None => Vec::new(),
3988            Some(Loose::Ok(de::OneOf::A(d))) => {
3989                self.typed_destination(d, ctx, &file, used).into_iter().collect()
3990            }
3991            Some(Loose::Ok(de::OneOf::B(list))) if list.is_empty() => {
3992                self.diags.error(
3993                    Code::InvalidField,
3994                    file.clone(),
3995                    None,
3996                    format!(
3997                        "{ctx}: `output.destination` is an empty list; name at least one destination, or remove it to keep the output in target/"
3998                    ),
3999                );
4000                Vec::new()
4001            }
4002            Some(Loose::Ok(de::OneOf::B(list))) => {
4003                let mut out = Vec::new();
4004                for (i, d) in list.into_iter().enumerate() {
4005                    match d {
4006                        Loose::Ok(d) => out.extend(self.typed_destination(d, ctx, &file, used)),
4007                        Loose::Bad(_) => self.diags.error(
4008                            Code::InvalidField,
4009                            file.clone(),
4010                            None,
4011                            format!("{ctx}: `output.destination` entry {} must be a map", i + 1),
4012                        ),
4013                    }
4014                }
4015                out
4016            }
4017            Some(Loose::Bad(_)) => {
4018                self.diags.error(
4019                    Code::InvalidField,
4020                    file.clone(),
4021                    None,
4022                    format!("{ctx}: `output.destination` must be a map or a list of maps"),
4023                );
4024                Vec::new()
4025            }
4026        };
4027        let template = match o.template {
4028            None => None,
4029            Some(t) => {
4030                if format != "xlsx" {
4031                    self.diags.error(
4032                        Code::InvalidOutputOption,
4033                        file.clone(),
4034                        None,
4035                        format!("{ctx}: `output.template` only applies to the xlsx format"),
4036                    );
4037                }
4038                self.typed_template(t, ctx, &file, queries)
4039            }
4040        };
4041        let extension = match o.extension {
4042            de::Maybe::Absent => None,
4043            de::Maybe::Given(Loose::Ok(None | Some(de::OneOf::B(false)))) => Some(String::new()),
4044            de::Maybe::Given(Loose::Ok(Some(de::OneOf::A(e)))) => {
4045                let e = e.strip_prefix('.').unwrap_or(&e).to_string();
4046                if e.contains(['/', '\\']) || e.chars().any(char::is_whitespace) {
4047                    self.diags.error(
4048                        Code::InvalidOutputOption,
4049                        file.clone(),
4050                        None,
4051                        format!(
4052                            "{ctx}: `extension` must be a file extension like `aba` (no path, no spaces)"
4053                        ),
4054                    );
4055                }
4056                Some(e)
4057            }
4058            de::Maybe::Given(_) => {
4059                self.diags.error(
4060                    Code::InvalidOutputOption,
4061                    file.clone(),
4062                    None,
4063                    format!("{ctx}: `extension` must be a string (`aba`), or `\"\"`/`false` for none"),
4064                );
4065                None
4066            }
4067        };
4068        let name = match o.name {
4069            None => None,
4070            Some(Loose::Ok(n)) if is_identifier(&n) => Some(n),
4071            Some(_) => {
4072                self.diags.error(
4073                    Code::InvalidField,
4074                    file.clone(),
4075                    None,
4076                    format!("{ctx}: output `name` must be an identifier (letters, digits, `_`)"),
4077                );
4078                None
4079            }
4080        };
4081        let when = match o.when {
4082            None => None,
4083            Some(Loose::Ok(de::OneOf::A(w))) => Some(w),
4084            Some(Loose::Ok(de::OneOf::B(b))) => Some(b.to_string()),
4085            Some(Loose::Bad(_)) => {
4086                self.diags.error(
4087                    Code::InvalidField,
4088                    file.clone(),
4089                    None,
4090                    format!("{ctx}: `when` must be a Jinja expression (a string)"),
4091                );
4092                None
4093            }
4094        };
4095        let queries = match &o.queries {
4096            Some(Loose::Ok(_)) => Some(queries.iter().map(|q| q.query.clone()).collect()),
4097            _ => None,
4098        };
4099        if extension.is_some() && format == "xlsx" {
4100            self.diags.error(
4101                Code::InvalidOutputOption,
4102                file.clone(),
4103                None,
4104                format!("{ctx}: `extension` doesn't apply to xlsx (Excel only opens .xlsx workbooks)"),
4105            );
4106        }
4107        Output {
4108            name,
4109            format,
4110            queries,
4111            when,
4112            options: opts,
4113            destinations,
4114            template,
4115            extension,
4116        }
4117    }
4118
4119    /// One `output.destination` entry: `profile`, optional `path`, and plugin options.
4120    fn typed_destination(
4121        &mut self,
4122        d: config::report::Destination,
4123        ctx: &str,
4124        file: &Option<PathBuf>,
4125        used: &mut Usage,
4126    ) -> Option<Destination> {
4127        let Some(Loose::Ok(p)) = d.profile else {
4128            self.diags.error(
4129                Code::InvalidField,
4130                file.clone(),
4131                None,
4132                format!("{ctx}: `output.destination` needs a `profile:` naming a profiles.yml entry"),
4133            );
4134            return None;
4135        };
4136        let path = match d.path {
4137            None => None,
4138            Some(Loose::Ok(s)) => Some(s),
4139            Some(Loose::Bad(_)) => {
4140                self.diags.error(
4141                    Code::InvalidField,
4142                    file.clone(),
4143                    None,
4144                    format!("{ctx}: destination `{p}`: `path` must be a string"),
4145                );
4146                None
4147            }
4148        };
4149        used.destination(&p, file.clone(), None);
4150        let options = d.options.0.into_iter().map(|(k, v)| (k.value, v)).collect();
4151        let attach = match d.attach {
4152            None => Vec::new(),
4153            Some(Loose::Ok(de::OneOf::A(s))) => vec![s],
4154            Some(Loose::Ok(de::OneOf::B(list))) => list,
4155            Some(Loose::Bad(_)) => {
4156                self.diags.error(
4157                    Code::InvalidField,
4158                    file.clone(),
4159                    None,
4160                    format!("{ctx}: destination `{p}`: `attach` must be an output name or a list of them"),
4161                );
4162                Vec::new()
4163            }
4164        };
4165        Some(Destination {
4166            profile: p,
4167            path,
4168            options,
4169            attach,
4170        })
4171    }
4172
4173    fn typed_template(
4174        &mut self,
4175        t: Loose<config::report::Template>,
4176        ctx: &str,
4177        file: &Option<PathBuf>,
4178        queries: &[QueryEntry],
4179    ) -> Option<Template> {
4180        let err = |s: &mut Self, msg: String| {
4181            s.diags
4182                .error(Code::InvalidTemplate, file.clone(), None, format!("{ctx}: {msg}"))
4183        };
4184        let Some(Loose::Ok(tf)) = t.ok().and_then(|t| t.file.clone()) else {
4185            err(self, "`output.template` needs a `file:`".into());
4186            return None;
4187        };
4188        let Loose::Ok(t) = t else {
4189            unreachable!("checked above")
4190        };
4191        let mut bindings = Vec::new();
4192        let items = match t.bindings {
4193            None => Vec::new(),
4194            Some(Loose::Ok(s)) => s,
4195            Some(Loose::Bad(_)) => {
4196                err(self, "`output.template.bindings` must be a list".into());
4197                Vec::new()
4198            }
4199        };
4200        let names: Vec<&str> = queries.iter().map(|q| q.query.as_str()).collect();
4201        for (i, b) in items.into_iter().enumerate() {
4202            let n = i + 1;
4203            let Loose::Ok(m) = b else {
4204                err(self, format!("template binding {n} must be a map"));
4205                continue;
4206            };
4207            let s = |v: &Option<Loose<String>>| v.as_ref().and_then(Loose::ok).cloned();
4208            for k in &m.unknown.0 {
4209                err(self, format!("template binding {n} has unknown key `{}`", k.name));
4210            }
4211            let Some(sheet) = s(&m.sheet) else {
4212                err(self, format!("template binding {n} needs a `sheet:`"));
4213                continue;
4214            };
4215            let tb = TemplateBinding {
4216                sheet,
4217                query: s(&m.query),
4218                result_index: m.result_index.as_ref().and_then(Loose::ok).map(|x| *x as usize),
4219                anchor: s(&m.anchor),
4220                header: m.header.as_ref().and_then(Loose::ok).copied(),
4221                columns: m.columns.as_ref().and_then(Loose::ok).cloned(),
4222                cell: s(&m.cell),
4223                value: s(&m.value),
4224                column: s(&m.column),
4225            };
4226            if let Some(q) = &tb.query
4227                && !names.contains(&q.as_str())
4228            {
4229                err(
4230                    self,
4231                    format!(
4232                        "template binding {n} uses query `{q}`, which isn't one of this Binding's queries"
4233                    ),
4234                );
4235            }
4236            if let Some(ri) = &m.result_index
4237                && ri.ok().is_none_or(|x| *x == 0)
4238            {
4239                err(
4240                    self,
4241                    format!("template binding {n}: `result_index` must be 1 or more"),
4242                );
4243            }
4244            match &tb.cell {
4245                Some(c) => {
4246                    if !options::is_cell(c) {
4247                        err(
4248                            self,
4249                            format!("template binding {n}: `cell` `{c}` isn't a valid cell reference"),
4250                        );
4251                    }
4252                    let by_value = tb.value.is_some();
4253                    let by_query = tb.query.is_some() && tb.column.is_some();
4254                    if by_value == by_query || (tb.query.is_some() != tb.column.is_some()) {
4255                        err(
4256                            self,
4257                            format!(
4258                                "template binding {n}: a single-cell binding needs exactly one of `value` or `query` + `column`"
4259                            ),
4260                        );
4261                    }
4262                    if tb.anchor.is_some() || tb.columns.is_some() {
4263                        err(
4264                            self,
4265                            format!(
4266                                "template binding {n}: `anchor`/`columns` apply to table blocks, not single cells"
4267                            ),
4268                        );
4269                    }
4270                }
4271                None => {
4272                    if tb.query.is_none() {
4273                        err(
4274                            self,
4275                            format!(
4276                                "template binding {n}: a table block needs a `query` (or use `cell` for a single cell)"
4277                            ),
4278                        );
4279                    }
4280                    if let Some(a) = &tb.anchor
4281                        && !options::is_cell(a)
4282                    {
4283                        err(
4284                            self,
4285                            format!("template binding {n}: `anchor` `{a}` isn't a valid cell reference"),
4286                        );
4287                    }
4288                    if tb.value.is_some() || tb.column.is_some() {
4289                        err(
4290                            self,
4291                            format!(
4292                                "template binding {n}: `value`/`column` apply to single-cell bindings (add `cell`)"
4293                            ),
4294                        );
4295                    }
4296                }
4297            }
4298            bindings.push(tb);
4299        }
4300        Some(Template { file: tf, bindings })
4301    }
4302
4303    // -- unmanaged reports --------------------------------------------------------------------
4304
4305    fn resolve_unmanaged(
4306        &mut self,
4307        name: &str,
4308        path: &Path,
4309        project: &Project,
4310        folders: &BTreeMap<Vec<String>, FolderCfg>,
4311        used: &mut Usage,
4312    ) -> Report {
4313        let folder = folder_segments(path.parent().unwrap_or(Path::new("")));
4314        let layers = folder_layers(folders, &folder);
4315        let mut tags: Vec<String> = layers.iter().flat_map(|l| l.tags.clone()).collect();
4316        dedup(&mut tags);
4317        let profile = layers
4318            .iter()
4319            .rev()
4320            .find_map(|l| l.profile.as_ref().map(|p| p.0.clone()))
4321            .or(project.default_profile.clone());
4322        let mut vars = project.vars.clone();
4323        for l in &layers {
4324            if let Some(v) = &l.vars {
4325                vars.extend(v.clone());
4326            }
4327        }
4328        let mut output = builtin_output();
4329        let mut merge_problems = Vec::new();
4330        if let Some(o) = project_default_output(project) {
4331            merge_problems.extend(merge_output(&mut output, &o));
4332        }
4333        for l in &layers {
4334            if let Some(o) = &l.output {
4335                merge_problems.extend(merge_output(&mut output, o));
4336            }
4337        }
4338        for p in merge_problems {
4339            self.diags.error(
4340                Code::InvalidField,
4341                Some(path.to_path_buf()),
4342                None,
4343                format!("folder config: {p}"),
4344            );
4345        }
4346        self.diags.warning(
4347            Code::UnmanagedReport,
4348            Some(path.to_path_buf()),
4349            None,
4350            format!(
4351                "`{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"
4352            ),
4353        );
4354        self.check_unmanaged_sql(name, path);
4355        let query = QueryEntry {
4356            query: name.to_string(),
4357            path: path.to_path_buf(),
4358            profile: None,
4359            tab: true,
4360            tab_name: None,
4361            anchor: None,
4362            header: None,
4363            columns: BTreeMap::new(),
4364            autofit: None,
4365            style: None,
4366        };
4367        let base = BindingBase {
4368            profile: profile.clone(),
4369            profile_at: inherited_profile_at(&layers, project),
4370            vars,
4371            outputs: vec![output],
4372            locale: layers
4373                .iter()
4374                .rev()
4375                .find_map(|l| l.locale.clone())
4376                .or_else(|| project.locale.clone()),
4377        };
4378        if let Some(p) = &profile {
4379            used.connection(p, None, None);
4380        }
4381        let b = self.finish_binding(
4382            name,
4383            None,
4384            &base,
4385            None,
4386            vec![query.clone()],
4387            path.to_path_buf(),
4388            used,
4389        );
4390        Report {
4391            name: name.to_string(),
4392            description: None,
4393            meta: JsonMap::new(),
4394            meta_layers: metadata_layers(project, &layers),
4395            var_schema: BTreeMap::new(),
4396            managed: false,
4397            file: path.to_path_buf(),
4398            folder,
4399            tags,
4400            queries: vec![query],
4401            default_set: None,
4402            timezone: layers
4403                .iter()
4404                .rev()
4405                .find_map(|l| l.timezone.clone())
4406                .or_else(|| project.timezone.clone()),
4407            has_sets: false,
4408            base: b.clone(),
4409            bindings: vec![b],
4410        }
4411    }
4412
4413    /// Best-effort check on the raw text; the run path re-checks the rendered SQL.
4414    fn check_unmanaged_sql(&mut self, name: &str, path: &Path) {
4415        let Ok(text) = std::fs::read_to_string(self.root.join(path)) else {
4416            return;
4417        };
4418        let head = sqlsplit::strip_leading_comments(&text);
4419        if head.starts_with("{{") || head.starts_with("{%") || head.starts_with("{#") {
4420            return;
4421        }
4422        for st in sqlsplit::split(&text) {
4423            if !sqlsplit::classify(&st.text).is_read_only_safe() {
4424                let body = sqlsplit::strip_leading_comments(&st.text);
4425                let line = st.line + st.text[..st.text.len() - body.len()].matches('\n').count();
4426                self.diags.error(
4427                    Code::UnmanagedSideEffect,
4428                    Some(path.to_path_buf()),
4429                    Some(line),
4430                    format!(
4431                        "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",
4432                        dre_protocol::util::summarize(body, 60)
4433                    ),
4434                );
4435            }
4436        }
4437    }
4438
4439    // -- schedules ----------------------------------------------------------------------------
4440
4441    /// Schedules live only in schedules.yml (named, with vars); anywhere else is an error.
4442    fn moved_to_schedules(&mut self, file: &Path, line: Option<usize>, ctx: &str) {
4443        self.diags.error(
4444            Code::ScheduleMoved,
4445            Some(file.to_path_buf()),
4446            line,
4447            format!(
4448                "{ctx} is no longer supported; declare schedules in schedules.yml as named entries (`name`, `report:`/`select:`, `cron`/`every`/`rrule`, optional `vars`)"
4449            ),
4450        );
4451    }
4452
4453    /// timings.yml: named timings. Returns the valid ones, and the names of those with errors
4454    /// (schedules using them aren't reported again).
4455    fn parse_timings(&mut self, files: &[Rc<YamlFile>]) -> (BTreeMap<String, Timing>, BTreeSet<String>) {
4456        let mut out = BTreeMap::new();
4457        let mut broken = BTreeSet::new();
4458        let mut seen: BTreeMap<String, String> = BTreeMap::new();
4459        for yf in files {
4460            let Ok(Loose::Ok(timings)) = de::from_node::<Loose<config::schedule::TimingsFile>>(&yf.node)
4461            else {
4462                continue;
4463            };
4464            for (k, v) in timings.0.0 {
4465                let file = Some(yf.display.clone());
4466                let line = k.line();
4467                let name = k.value;
4468                let mut ok = true;
4469                if !is_identifier(&name) {
4470                    self.diags.error(
4471                        Code::InvalidTiming,
4472                        file.clone(),
4473                        line,
4474                        format!(
4475                            "timing name `{name}` must be letters, digits and `_`, not starting with a digit"
4476                        ),
4477                    );
4478                    ok = false;
4479                }
4480                if let Some(prev) = seen.get(&name) {
4481                    self.diags.error(
4482                        Code::DuplicateTimingName,
4483                        file.clone(),
4484                        line,
4485                        format!("timing `{name}` is already declared at {prev}; timing names must be unique"),
4486                    );
4487                    continue;
4488                }
4489                seen.insert(
4490                    name.clone(),
4491                    format!("{}:{}", yf.display.display(), line.unwrap_or(0)),
4492                );
4493                let Loose::Ok(t) = v else {
4494                    self.diags.error(
4495                        Code::InvalidTiming,
4496                        file.clone(),
4497                        line,
4498                        format!("timing `{name}` must be a map, e.g. `{{cron: \"0 6 1 * *\", timezone: Australia/Sydney}}`"),
4499                    );
4500                    broken.insert(name);
4501                    continue;
4502                };
4503                for k in &t.unknown.0 {
4504                    self.diags.error(
4505                        Code::InvalidTiming,
4506                        file.clone(),
4507                        line,
4508                        format!("timing `{name}`: unknown key `{}`", k.name),
4509                    );
4510                    ok = false;
4511                }
4512                let block = t.block();
4513                let shape = schedule::validate_block(&block, "a timing", "`cron`, `every` or `rrule`");
4514                for e in &shape {
4515                    self.diags.error(
4516                        Code::InvalidTiming,
4517                        file.clone(),
4518                        line,
4519                        format!("timing `{name}`: {e}"),
4520                    );
4521                    ok = false;
4522                }
4523                let timezone = match &t.timezone {
4524                    None => None,
4525                    Some(v) => {
4526                        let tz = self.timezone_str(
4527                            v.ok().map(String::as_str),
4528                            &yf.display,
4529                            line,
4530                            &format!("timing `{name}`: `timezone`"),
4531                        );
4532                        ok &= tz.is_some();
4533                        tz
4534                    }
4535                };
4536                if shape.is_empty() {
4537                    for (code, msg) in schedule::strictness(&block) {
4538                        self.diags
4539                            .error(code, file.clone(), line, format!("timing `{name}`: {msg}"));
4540                    }
4541                    if let Some(msg) = schedule::no_time(&block) {
4542                        self.diags.warning(
4543                            Code::ScheduleNoTime,
4544                            file.clone(),
4545                            line,
4546                            format!("timing `{name}`: {msg}"),
4547                        );
4548                    }
4549                }
4550                if ok {
4551                    out.insert(
4552                        name,
4553                        Timing {
4554                            schedule: block,
4555                            timezone,
4556                            location: (yf.display.clone(), line),
4557                        },
4558                    );
4559                } else {
4560                    broken.insert(name);
4561                }
4562            }
4563        }
4564        (out, broken)
4565    }
4566
4567    fn parse_schedules(
4568        &mut self,
4569        files: &[Rc<YamlFile>],
4570        project: &Project,
4571        broken_timings: &BTreeSet<String>,
4572    ) -> Vec<ScheduleEntry> {
4573        let mut out = Vec::new();
4574        let mut used_timings = BTreeSet::new();
4575        let mut seen: BTreeMap<String, String> = BTreeMap::new();
4576        for yf in files {
4577            let Ok(Loose::Ok(items)) = de::from_node::<Loose<config::schedule::SchedulesFile>>(&yf.node)
4578            else {
4579                continue;
4580            };
4581            for item in &items.0 {
4582                // Schedules files are recognised by every item being a map with a name or target.
4583                let Loose::Ok(m) = &item.value else { continue };
4584                let line = item.line();
4585                let file = Some(yf.display.clone());
4586                let s = |v: &Option<Loose<String>>| v.as_ref().and_then(Loose::ok).cloned();
4587                let (select, report, set) = (s(&m.select), s(&m.report), s(&m.set));
4588                let mut ok = true;
4589                let name = match &m.name {
4590                    Some(Loose::Ok(n)) if is_identifier(n) => {
4591                        if let Some(prev) = seen.get(n) {
4592                            self.diags.error(
4593                                Code::DuplicateScheduleName,
4594                                file.clone(),
4595                                line,
4596                                format!("schedule `{n}` is already declared at {prev}; schedule names must be unique"),
4597                            );
4598                            ok = false;
4599                        }
4600                        seen.insert(
4601                            n.clone(),
4602                            format!("{}:{}", yf.display.display(), line.unwrap_or(0)),
4603                        );
4604                        n.clone()
4605                    }
4606                    Some(Loose::Ok(n)) => {
4607                        self.diags.error(
4608                            Code::InvalidSchedule,
4609                            file.clone(),
4610                            line,
4611                            format!("schedule name `{n}` must be letters, digits and `_`, not starting with a digit"),
4612                        );
4613                        ok = false;
4614                        n.clone()
4615                    }
4616                    _ => {
4617                        self.diags.error(
4618                            Code::InvalidSchedule,
4619                            file.clone(),
4620                            line,
4621                            "every schedule needs a `name`",
4622                        );
4623                        ok = false;
4624                        String::new()
4625                    }
4626                };
4627                let vars = match &m.vars {
4628                    None => JsonMap::new(),
4629                    Some(Loose::Ok(v)) => v.clone(),
4630                    Some(Loose::Bad(_)) => {
4631                        self.diags.error(
4632                            Code::InvalidSchedule,
4633                            file.clone(),
4634                            line,
4635                            format!("schedule `{name}`: `vars` must be a map"),
4636                        );
4637                        ok = false;
4638                        JsonMap::new()
4639                    }
4640                };
4641                for k in &m.unknown.0 {
4642                    self.diags.error(
4643                        Code::InvalidSchedule,
4644                        file.clone(),
4645                        line,
4646                        format!("unknown schedule key `{}`", k.name),
4647                    );
4648                }
4649                let sched = m.block();
4650                let timing = match &m.timing {
4651                    None => None,
4652                    Some(Loose::Ok(t)) => {
4653                        used_timings.insert(t.clone());
4654                        Some(t.clone())
4655                    }
4656                    Some(Loose::Bad(_)) => {
4657                        self.diags.error(
4658                            Code::InvalidSchedule,
4659                            file.clone(),
4660                            line,
4661                            format!(
4662                                "schedule `{name}`: `timing` must be the name of a timing in timings.yml"
4663                            ),
4664                        );
4665                        ok = false;
4666                        None
4667                    }
4668                };
4669                let mut resolved = None;
4670                let shape = if let Some(t) = &timing {
4671                    let mut errs = Vec::new();
4672                    let own: Vec<String> = m.timing_keys().into_iter().map(|k| format!("`{k}`")).collect();
4673                    if !own.is_empty() {
4674                        let keys = match own.split_last() {
4675                            Some((last, [])) => last.clone(),
4676                            Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
4677                            None => String::new(),
4678                        };
4679                        errs.push(format!(
4680                            "schedule `{name}` uses timing `{t}`, so it can't set {keys} too; change the timing, or give the schedule its own timing instead"
4681                        ));
4682                    }
4683                    match project.timings.get(t) {
4684                        Some(def) => resolved = Some(def.schedule.clone()),
4685                        None if broken_timings.contains(t) => ok = false,
4686                        None => {
4687                            let names: Vec<&str> = project.timings.keys().map(String::as_str).collect();
4688                            let valid = if names.is_empty() {
4689                                "the project has no timings.yml entries".to_string()
4690                            } else {
4691                                format!("valid names: {}", names.join(", "))
4692                            };
4693                            self.diags.error(
4694                                Code::UnknownTiming,
4695                                file.clone(),
4696                                line,
4697                                format!("schedule `{name}`: no timing `{t}`; {valid}"),
4698                            );
4699                            ok = false;
4700                        }
4701                    }
4702                    errs
4703                } else {
4704                    schedule::validate_block(&sched, "a schedule", "`timing`, `cron`, `every` or `rrule`")
4705                };
4706                if shape.is_empty() && timing.is_none() {
4707                    let block = &sched;
4708                    for (code, msg) in schedule::strictness(block) {
4709                        self.diags
4710                            .error(code, file.clone(), line, format!("schedule `{name}`: {msg}"));
4711                    }
4712                    if let Some(msg) = schedule::no_time(block) {
4713                        self.diags.warning(
4714                            Code::ScheduleNoTime,
4715                            file.clone(),
4716                            line,
4717                            format!("schedule `{name}`: {msg}"),
4718                        );
4719                    }
4720                }
4721                for e in shape {
4722                    self.diags.error(Code::InvalidSchedule, file.clone(), line, e);
4723                    ok = false;
4724                }
4725                if select.is_some() && report.is_some() {
4726                    self.diags.error(
4727                        Code::InvalidSchedule,
4728                        file.clone(),
4729                        line,
4730                        "use either `select:` or `report:`, not both",
4731                    );
4732                    ok = false;
4733                }
4734                if select.is_some() && set.is_some() {
4735                    self.diags.error(
4736                        Code::InvalidSchedule,
4737                        file.clone(),
4738                        line,
4739                        "`set:` only applies with `report:`",
4740                    );
4741                    ok = false;
4742                }
4743                if let Some(sel) = &select {
4744                    match selector::resolve(project, sel) {
4745                        Ok(r) if r.is_empty() => {
4746                            self.diags.error(
4747                                Code::SelectorMatchesNothing,
4748                                file.clone(),
4749                                line,
4750                                format!("selector `{sel}` matches no report"),
4751                            );
4752                            ok = false;
4753                        }
4754                        Ok(_) => {}
4755                        Err(e) => {
4756                            self.diags.error(e.code(), file.clone(), line, e.to_string());
4757                            ok = false;
4758                        }
4759                    }
4760                }
4761                if let Some(rn) = &report {
4762                    match project.report(rn) {
4763                        None => {
4764                            self.diags.error(
4765                                Code::SelectorMatchesNothing,
4766                                file.clone(),
4767                                line,
4768                                format!("report `{rn}` doesn't exist"),
4769                            );
4770                            ok = false;
4771                        }
4772                        Some(r) => {
4773                            if let Some(sn) = &set
4774                                && r.binding(sn).is_none()
4775                            {
4776                                self.diags.error(
4777                                    Code::SelectorMatchesNothing,
4778                                    file.clone(),
4779                                    line,
4780                                    format!("report `{rn}` has no Set `{sn}`"),
4781                                );
4782                                ok = false;
4783                            }
4784                        }
4785                    }
4786                }
4787                if select.is_none() && report.is_none() {
4788                    self.diags.error(
4789                        Code::InvalidSchedule,
4790                        file.clone(),
4791                        line,
4792                        format!("schedule `{name}` needs `report:` (optionally with `set:`) or `select:`"),
4793                    );
4794                    ok = false;
4795                }
4796                let timezone = match (&m.timezone, &timing) {
4797                    (None, Some(t)) => project.timings.get(t).and_then(|d| d.timezone.clone()),
4798                    (None, None) => None,
4799                    (Some(v), _) => {
4800                        let t = self.timezone_str(
4801                            v.value.ok().map(String::as_str),
4802                            &yf.display,
4803                            line,
4804                            &format!("schedule `{name}`: `timezone`"),
4805                        );
4806                        ok &= t.is_some();
4807                        t
4808                    }
4809                };
4810                let enabled = match &m.enabled {
4811                    None => true,
4812                    Some(Loose::Ok(b)) => *b,
4813                    Some(Loose::Bad(_)) => {
4814                        self.diags.error(
4815                            Code::InvalidSchedule,
4816                            file.clone(),
4817                            line,
4818                            format!("schedule `{name}`: `enabled` must be true or false"),
4819                        );
4820                        ok = false;
4821                        true
4822                    }
4823                };
4824                if ok {
4825                    out.push(ScheduleEntry {
4826                        name,
4827                        select,
4828                        report,
4829                        set,
4830                        schedule: resolved.unwrap_or(sched),
4831                        timing,
4832                        enabled,
4833                        vars,
4834                        timezone,
4835                        location: (yf.display.clone(), line),
4836                    });
4837                }
4838            }
4839        }
4840        for (name, t) in &project.timings {
4841            if !used_timings.contains(name) {
4842                self.diags.warning(
4843                    Code::UnusedTiming,
4844                    Some(t.location.0.clone()),
4845                    t.location.1,
4846                    format!("timing `{name}` isn't used by any schedule"),
4847                );
4848            }
4849        }
4850        out
4851    }
4852
4853    /// Warn when two schedules of one Binding would deliver to the same path: each schedule's
4854    /// paths are rendered with its vars and one fixed date. Paths that can't render offline (a
4855    /// `run_query()`, a missing `env_var()`) are skipped.
4856    fn check_schedule_paths(&mut self, project: &Project) {
4857        let date = chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
4858        for report in &project.reports {
4859            for b in report.bindings.iter().filter(|b| b.schedules.len() > 1) {
4860                let paths: Vec<&String> = b.destinations().filter_map(|d| d.path.as_ref()).collect();
4861                if paths.is_empty() {
4862                    continue;
4863                }
4864                let mut rendered: Vec<(&String, Vec<String>)> = Vec::new();
4865                for name in &b.schedules {
4866                    let Some(e) = project.schedules.iter().find(|e| &e.name == name) else {
4867                        continue;
4868                    };
4869                    let mut vars = b.vars.clone();
4870                    vars.extend(e.vars.clone());
4871                    let Ok(r) = crate::render::Renderer::new(crate::render::RendererConfig {
4872                        root: &project.root,
4873                        macros: &project.macros,
4874                        context: crate::render::RunContext {
4875                            report: report.name.clone(),
4876                            set: b.set.clone(),
4877                            target: project.target_name.clone(),
4878                            schedule: Some(name.clone()),
4879                            date,
4880                            now: chrono::Utc::now(),
4881                            scheduled_at: None,
4882                            calendar: crate::dates::Calendar::default(),
4883                            locale: Default::default(),
4884                        },
4885                        vars,
4886                        cli_vars: self.opts.vars.clone(),
4887                        runner: None,
4888                        connections: None,
4889                        mode: crate::render::Mode::Run,
4890                        connection: None,
4891                        source_type: String::new(),
4892                        sources: None,
4893                        run_query_max_rows: project.run_query_max_rows,
4894                        sql: project.sql.clone(),
4895                        lookups: project.lookups.clone(),
4896                        lookup_inline_max_rows: project.lookup_inline_max_rows,
4897                        packages: project.packages.clone(),
4898                        project_name: project.name.clone(),
4899                        dispatch: project.dispatch.clone(),
4900                    }) else {
4901                        continue;
4902                    };
4903                    let out: Result<Vec<String>, _> =
4904                        paths.iter().map(|p| r.render(&report.file, p)).collect();
4905                    if let Ok(out) = out {
4906                        rendered.push((name, out));
4907                    }
4908                }
4909                for (i, (a, pa)) in rendered.iter().enumerate() {
4910                    for (bn, pb) in &rendered[i + 1..] {
4911                        if let Some(same) = pa.iter().find(|p| pb.contains(p)) {
4912                            self.diags.warning(
4913                                Code::SchedulePathClash,
4914                                Some(report.file.clone()),
4915                                None,
4916                                format!(
4917                                    "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",
4918                                    report.name,
4919                                    b.set.as_ref().map(|s| format!(", Set `{s}`,")).unwrap_or_default()
4920                                ),
4921                            );
4922                        }
4923                    }
4924                }
4925            }
4926        }
4927    }
4928
4929    /// Attach every schedules.yml entry to the Bindings it targets: `report:` + `set:` one
4930    /// Binding, `report:` alone all of that report's Bindings, `select:` all Bindings of every
4931    /// report it matches. Several schedules per Binding is the point, not a conflict.
4932    fn apply_schedules(&mut self, project: &mut Project) {
4933        let entries = project.schedules.clone();
4934        for e in &entries {
4935            let reports: Vec<String> = match (&e.select, &e.report) {
4936                (Some(sel), _) => selector::resolve(project, sel)
4937                    .map(|r| r.into_iter().map(|r| r.name.clone()).collect())
4938                    .unwrap_or_default(),
4939                (_, Some(r)) => vec![r.clone()],
4940                _ => Vec::new(),
4941            };
4942            for report in project.reports.iter_mut().filter(|r| reports.contains(&r.name)) {
4943                for b in &mut report.bindings {
4944                    if e.set.is_none() || e.set == b.set {
4945                        b.schedules.push(e.name.clone());
4946                    }
4947                }
4948            }
4949        }
4950        self.check_schedule_paths(project);
4951        self.check_schedule_timezones(project);
4952    }
4953
4954    /// Warn when a schedule fires in one timezone and a report it runs renders in another: the
4955    /// run date is then the report's date at the firing time, which may not be the day meant.
4956    fn check_schedule_timezones(&mut self, project: &Project) {
4957        for e in project.schedules.iter().filter(|e| e.timezone.is_none()) {
4958            let fires = crate::occurrences::firing_tz(project, e);
4959            let mut seen = BTreeSet::new();
4960            for (report, _) in crate::occurrences::bindings(project, &e.name) {
4961                let Some(r) = project.report(report) else { continue };
4962                let renders = r
4963                    .timezone
4964                    .as_ref()
4965                    .and_then(|t| crate::dates::parse_tz(t).ok())
4966                    .unwrap_or(chrono_tz::Tz::UTC);
4967                if renders != fires && seen.insert(report) {
4968                    self.diags.warning(
4969                        Code::ScheduleTimezoneMismatch,
4970                        Some(e.location.0.clone()),
4971                        e.location.1,
4972                        format!(
4973                            "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",
4974                            e.name,
4975                            fires.name(),
4976                            renders.name(),
4977                            renders.name()
4978                        ),
4979                    );
4980                }
4981            }
4982        }
4983    }
4984
4985    // -- profiles and plugins -----------------------------------------------------------------
4986
4987    fn check_profiles(&mut self, project: &Project, used: &Usage) {
4988        let profiles = &project.profiles;
4989        if used.connections.is_empty() && used.destinations.is_empty() {
4990            return;
4991        }
4992        if !profiles.exists() {
4993            self.diags.error(
4994                Code::ProfilesMissing,
4995                None,
4996                None,
4997                if profiles.found_by == "~/.dre" {
4998                    format!(
4999                        "the project references profiles, but no profiles.yml was found in the project directory or at {}",
5000                        profiles.path.display()
5001                    )
5002                } else {
5003                    format!(
5004                        "the project references profiles, but no profiles.yml was found at {} (from {})",
5005                        profiles.path.display(),
5006                        profiles.found_by
5007                    )
5008                },
5009            );
5010            return;
5011        }
5012        for (role, refs) in [
5013            (Role::Connection, &used.connections),
5014            (Role::Destination, &used.destinations),
5015        ] {
5016            for (name, (file, line)) in refs {
5017                if role == Role::Destination && name == LOCAL_TYPE {
5018                    continue;
5019                }
5020                if !profiles.declares(role, name) {
5021                    self.diags.error(
5022                        Code::UnknownProfile,
5023                        file.clone(),
5024                        *line,
5025                        format!(
5026                            "{} profile `{name}` isn't defined under `{}:` in {}",
5027                            role.as_str(),
5028                            profiles.section_key(role),
5029                            profiles.path.display()
5030                        ),
5031                    );
5032                }
5033            }
5034        }
5035    }
5036
5037    /// A `plugins:` entry in its map form: `{name: foo, github: acme/dre-foo, version: "^1"}`.
5038    fn plugin_entry(
5039        &mut self,
5040        m: &config::dependencies::PluginEntry,
5041        file: &Path,
5042        line: Option<usize>,
5043    ) -> Option<(String, Option<Value>, PluginSource)> {
5044        let file = Some(file.to_path_buf());
5045        let name = m.name.ok().cloned().unwrap_or_default();
5046        let err = |s: &mut Self, msg: String| {
5047            s.diags.error(
5048                Code::InvalidPluginDeclaration,
5049                file.clone(),
5050                line,
5051                format!("plugin package `{name}`: {msg}"),
5052            );
5053        };
5054        if name.is_empty() {
5055            err(self, "`name` must be a non-empty string".into());
5056            return None;
5057        }
5058        if let Some(k) = m.unknown.0.first() {
5059            err(
5060                self,
5061                format!(
5062                    "unknown key `{}`; use `name`, `version` and one of `github`, `local`, `registry`",
5063                    k.name
5064                ),
5065            );
5066            return None;
5067        }
5068        let given: Vec<(&str, &str)> = [
5069            ("github", &m.github),
5070            ("local", &m.local),
5071            ("registry", &m.registry),
5072        ]
5073        .into_iter()
5074        .filter_map(|(k, v)| v.as_ref().map(|v| (k, v.as_str().unwrap_or(""))))
5075        .collect();
5076        let source = match given.as_slice() {
5077            [] => PluginSource::Default,
5078            [(k, "")] => {
5079                err(self, format!("`{k}` must be a non-empty string"));
5080                return None;
5081            }
5082            [("github", r)] => {
5083                let ok = r.split('/').count() == 2 && r.split('/').all(|p| !p.is_empty());
5084                if !ok {
5085                    err(self, format!("`github: {r}` must be `owner/repo`"));
5086                    return None;
5087                }
5088                PluginSource::Github(r.to_string())
5089            }
5090            [("local", p)] => {
5091                if m.version.is_some() {
5092                    err(
5093                        self,
5094                        "a `local` package has no `version`: it's used as it is".into(),
5095                    );
5096                    return None;
5097                }
5098                PluginSource::Local(p.to_string())
5099            }
5100            [(_, u)] => PluginSource::Registry(u.to_string()),
5101            _ => {
5102                err(self, "give only one of `github`, `local` and `registry`".into());
5103                return None;
5104            }
5105        };
5106        Some((name, m.version.clone(), source))
5107    }
5108
5109    /// The `plugins:` declarations, merged across files; and every plugin the project uses, for
5110    /// [`crate::plugins::check_uses`] to check against what the declared packages provide.
5111    fn check_plugins(&mut self, decls: &[(Rc<YamlFile>, Mapping)], project: &mut Project, used: &Usage) {
5112        struct Decl {
5113            req: semver::VersionReq,
5114            raw: String,
5115            file: PathBuf,
5116            source: PluginSource,
5117        }
5118        let mut by_package: BTreeMap<String, Vec<Decl>> = BTreeMap::new();
5119        for (yf, _) in decls {
5120            {
5121                let Ok(config::dependencies::PluginsKey { plugins: Some(v) }) = de::from_node(&yf.node)
5122                else {
5123                    continue;
5124                };
5125                let line = v.line();
5126                let file = Some(yf.display.clone());
5127                let entries: Vec<(String, Option<Value>, PluginSource, Option<usize>)> = match v.value {
5128                    Loose::Ok(de::OneOf::A(items)) => items
5129                        .into_iter()
5130                        .filter_map(|i| {
5131                            let iline = i.line();
5132                            match i.value {
5133                                Loose::Ok(de::OneOf::A(name)) => Some((name.0, None, PluginSource::Default, iline)),
5134                                Loose::Ok(de::OneOf::B(de::OneOf::B(entry))) => self
5135                                    .plugin_entry(&entry, &yf.display, iline)
5136                                    .map(|(n, c, s)| (n, c, s, iline)),
5137                                Loose::Ok(de::OneOf::B(de::OneOf::A(pin))) => Some((
5138                                    pin.name.value,
5139                                    Some(pin.version.clone()),
5140                                    PluginSource::Default,
5141                                    iline,
5142                                )),
5143                                Loose::Bad(_) => {
5144                                    self.diags.error(
5145                                        Code::InvalidPluginDeclaration,
5146                                        file.clone(),
5147                                        iline,
5148                                        "each `plugins` entry is a package name, `name: \"<version>\"`, or a map with `name:` and one of `github:`, `local:`, `registry:`",
5149                                    );
5150                                    None
5151                                }
5152                            }
5153                        })
5154                        .collect(),
5155                    Loose::Ok(de::OneOf::B(m)) => m
5156                        .0
5157                        .into_iter()
5158                        .map(|(k, v)| {
5159                            let kline = k.line();
5160                            (k.value, Some(v), PluginSource::Default, kline)
5161                        })
5162                        .collect(),
5163                    Loose::Bad(f) if f.kind == "nothing" => Vec::new(),
5164                    Loose::Bad(_) => {
5165                        self.diags.error(
5166                            Code::InvalidPluginDeclaration,
5167                            file.clone(),
5168                            line,
5169                            "`plugins` must be a list like `- duckdb: \">=1.0\"`",
5170                        );
5171                        continue;
5172                    }
5173                };
5174                for (name, c, source, eline) in entries {
5175                    if !dre_protocol::valid_name(&name) {
5176                        self.diags.error(
5177                            Code::InvalidPluginDeclaration,
5178                            file.clone(),
5179                            eline,
5180                            format!(
5181                                "plugin package `{name}`: a package name is lowercase letters, digits and `_`"
5182                            ),
5183                        );
5184                        continue;
5185                    }
5186                    let raw = match &c {
5187                        None | Some(Value::Null) => "*".to_string(),
5188                        Some(v) => crate::yaml::scalar_str(v).unwrap_or_default(),
5189                    };
5190                    match semver::VersionReq::parse(&raw) {
5191                        Ok(req) => by_package.entry(name).or_default().push(Decl {
5192                            req,
5193                            raw,
5194                            file: yf.display.clone(),
5195                            source,
5196                        }),
5197                        Err(e) => self.diags.error(
5198                            Code::InvalidVersionConstraint,
5199                            file.clone(),
5200                            eline,
5201                            format!("plugin package `{name}`: invalid version constraint `{raw}`: {e}"),
5202                        ),
5203                    }
5204                }
5205            }
5206        }
5207        let mut out = Vec::new();
5208        for (name, ds) in &by_package {
5209            // Every declaration of one package must agree on where it comes from.
5210            let source = ds
5211                .iter()
5212                .map(|d| &d.source)
5213                .find(|s| !s.is_default())
5214                .cloned()
5215                .unwrap_or_default();
5216            if let Some(other) = ds.iter().find(|d| !d.source.is_default() && d.source != source) {
5217                self.diags.error(
5218                    Code::ConflictingPluginSources,
5219                    Some(other.file.clone()),
5220                    None,
5221                    format!(
5222                        "plugin package `{name}` is declared with two sources: {} and {}",
5223                        source.lock_key().unwrap_or_default(),
5224                        other.source.lock_key().unwrap_or_default()
5225                    ),
5226                );
5227                project.plugins_incomplete = true;
5228                continue;
5229            }
5230            let reqs: Vec<&semver::VersionReq> = ds.iter().map(|d| &d.req).collect();
5231            if !constraints::compatible(&reqs) {
5232                for (i, a) in ds.iter().enumerate() {
5233                    for b in &ds[i + 1..] {
5234                        if !constraints::compatible(&[&a.req, &b.req]) {
5235                            self.diags.error(
5236                                Code::ConflictingPluginConstraints,
5237                                Some(b.file.clone()),
5238                                None,
5239                                format!(
5240                                    "plugin package `{name}` is declared `{}` in {} and `{}` in {}; no version satisfies both",
5241                                    a.raw,
5242                                    a.file.display(),
5243                                    b.raw,
5244                                    b.file.display()
5245                                ),
5246                            );
5247                        }
5248                    }
5249                }
5250                project.plugins_incomplete = true;
5251                continue;
5252            }
5253            let combined = constraints::combine(&reqs);
5254            let mut files: Vec<PathBuf> = ds.iter().map(|d| d.file.clone()).collect();
5255            files.dedup();
5256            out.push(PluginRequirement {
5257                name: name.clone(),
5258                version: combined.to_string(),
5259                declared_in: files,
5260                source,
5261            });
5262        }
5263        project.plugins = out;
5264
5265        let mut uses = Vec::new();
5266        let profiles = &project.profiles;
5267        for (role, kind, refs) in [
5268            (Role::Connection, PluginKind::Source, &used.connections),
5269            (Role::Destination, PluginKind::Destination, &used.destinations),
5270        ] {
5271            for name in refs.keys() {
5272                let Some(p) = profiles.get(role, name) else {
5273                    continue;
5274                };
5275                let role_name = role.as_str();
5276                for out_ in p.targets.values() {
5277                    if kind == PluginKind::Destination && out_.kind == LOCAL_TYPE {
5278                        continue;
5279                    }
5280                    let id = PluginId::new(kind, out_.kind.clone());
5281                    if uses.iter().any(|u: &PluginUse| u.plugin == id) {
5282                        continue;
5283                    }
5284                    uses.push(PluginUse {
5285                        plugin: id,
5286                        file: profiles.file.clone(),
5287                        line: profiles.line_of(role, name),
5288                        what: format!("`type: {}` used by {role_name} profile `{name}`", out_.kind),
5289                    });
5290                }
5291            }
5292        }
5293        for (fmt, (ctx, file)) in &used.formats {
5294            let note = if fmt == "csv" {
5295                " (csv is the built-in default output)"
5296            } else {
5297                ""
5298            };
5299            uses.push(PluginUse {
5300                plugin: PluginId::new(PluginKind::Format, fmt.clone()),
5301                file: Some(file.clone()),
5302                line: None,
5303                what: format!("format `{fmt}` is used by {ctx}{note}"),
5304            });
5305        }
5306        project.plugin_uses = uses;
5307    }
5308
5309    /// `sources:`, `formats:` or `destinations:` where plugins were once declared.
5310    fn old_plugin_key(&mut self, yf: &YamlFile, key: &str) {
5311        self.diags.error(
5312            Code::MovedPluginDeclaration,
5313            Some(yf.display.clone()),
5314            yf.line_of(key, None),
5315            format!(
5316                "`{key}:` no longer declares plugins: list plugin packages under `plugins:` instead (e.g. `plugins: [duckdb, object_store]`)"
5317            ),
5318        );
5319    }
5320
5321    // -- Jinja pre-flight ---------------------------------------------------------------------
5322
5323    fn preflight(&mut self, project: &Project, _used: &Usage) {
5324        let cli_owned: Vec<String> = self.opts.vars.keys().cloned().collect();
5325        let cli: BTreeSet<&str> = cli_owned.iter().map(String::as_str).collect();
5326        let mut sources: BTreeMap<PathBuf, String> = BTreeMap::new();
5327        let mut read = |root: &Path, p: &Path| -> Option<String> {
5328            if let Some(s) = sources.get(p) {
5329                return Some(s.clone());
5330            }
5331            let s = std::fs::read_to_string(root.join(p)).ok()?;
5332            sources.insert(p.to_path_buf(), s.clone());
5333            Some(s)
5334        };
5335        let mut checked: BTreeSet<PathBuf> = BTreeSet::new();
5336        let root = self.root.clone();
5337        // Macros: syntax, env_var, run.*; their definitions, for var() checks per Binding.
5338        let mut defs: BTreeMap<String, (PathBuf, preflight::MacroDef)> = BTreeMap::new();
5339        for m in &project.macros {
5340            if let Some(src) = read(&root, m) {
5341                self.check_template_text(m, &src, 0, None, &cli);
5342                checked.insert(m.clone());
5343                for d in preflight::macro_defs(&src) {
5344                    defs.insert(d.name.clone(), (m.clone(), d));
5345                }
5346            }
5347        }
5348        for r in &project.reports {
5349            for b in &r.bindings {
5350                for error in crate::variables::check_values(&r.var_schema, &b.vars, &self.opts.vars, false) {
5351                    self.diags
5352                        .error(Code::InvalidField, Some(r.file.clone()), None, error);
5353                }
5354                let ctx = match &b.set {
5355                    Some(s) => format!("report `{}`, Set `{s}`", r.name),
5356                    None => format!("report `{}`", r.name),
5357                };
5358                let mut called: Vec<String> = Vec::new();
5359                let mut refs: Vec<String> = Vec::new();
5360                for q in &b.queries {
5361                    let Some(src) = read(&root, &q.path) else { continue };
5362                    let first = checked.insert(q.path.clone());
5363                    self.check_vars(&q.path, &src, 0, &b.vars, &cli, &ctx);
5364                    if first {
5365                        self.check_template_text(&q.path, &src, 0, None, &cli);
5366                    }
5367                    called.extend(preflight::called_names(&src));
5368                    refs.extend(preflight::refs(&src).into_iter().map(|(n, _)| n));
5369                }
5370                // SQL this Binding pulls in through ref(), checked in the Binding's context.
5371                let mut seen_refs = BTreeSet::new();
5372                while let Some(name) = refs.pop() {
5373                    let Some(path) = project.sql.get(&name) else {
5374                        continue;
5375                    };
5376                    if !seen_refs.insert(name) {
5377                        continue;
5378                    }
5379                    let Some(src) = read(&root, path) else { continue };
5380                    self.check_vars(path, &src, 0, &b.vars, &cli, &ctx);
5381                    if checked.insert(path.clone()) {
5382                        self.check_template_text(path, &src, 0, None, &cli);
5383                    }
5384                    called.extend(preflight::called_names(&src));
5385                    refs.extend(preflight::refs(&src).into_iter().map(|(n, _)| n));
5386                }
5387                // Macros this Binding calls, directly or through other macros.
5388                let mut seen = BTreeSet::new();
5389                while let Some(name) = called.pop() {
5390                    let Some((file, def)) = defs.get(&name) else {
5391                        continue;
5392                    };
5393                    if !seen.insert(name) {
5394                        continue;
5395                    }
5396                    self.check_vars(file, &def.body, def.line_offset, &b.vars, &cli, &ctx);
5397                    called.extend(preflight::called_names(&def.body));
5398                }
5399                // Templated output values render with the same context.
5400                let mut values: Vec<String> = Vec::new();
5401                for d in b.destinations() {
5402                    values.extend(d.path.clone());
5403                    values.extend(d.options.values().flat_map(json_strings));
5404                }
5405                for t in b.outputs.iter().filter_map(|o| o.template.as_ref()) {
5406                    values.extend(t.bindings.iter().filter_map(|tb| tb.value.clone()));
5407                }
5408                for v in values.iter().filter(|v| preflight::is_templated(v)) {
5409                    let yf = std::fs::read_to_string(root.join(&r.file)).unwrap_or_default();
5410                    let line = yf.lines().position(|l| l.contains(v.as_str())).map(|i| i + 1);
5411                    self.check_template_text(&r.file, v, line.map_or(0, |l| l - 1), line, &cli);
5412                    self.check_vars(&r.file, v, line.map_or(0, |l| l - 1), &b.vars, &cli, &ctx);
5413                }
5414            }
5415        }
5416    }
5417
5418    /// Syntax, `env_var()` and `run.*` checks for one template source.
5419    fn check_template_text(
5420        &mut self,
5421        file: &Path,
5422        src: &str,
5423        line_offset: usize,
5424        fixed_line: Option<usize>,
5425        _cli: &BTreeSet<&str>,
5426    ) {
5427        let f = Some(file.to_path_buf());
5428        if let Err((line, msg)) = preflight::check_syntax(&file.to_string_lossy(), src) {
5429            let line = fixed_line.or(line.map(|l| l + line_offset));
5430            self.diags.error(
5431                Code::JinjaSyntax,
5432                f.clone(),
5433                line,
5434                format!("Jinja syntax error: {msg}"),
5435            );
5436            return;
5437        }
5438        for c in preflight::calls(src)
5439            .into_iter()
5440            .filter(|c| c.func == "env_var" && !c.has_default)
5441        {
5442            if std::env::var_os(&c.name).is_none() {
5443                self.diags.error(
5444                    Code::UnsetEnvVar,
5445                    f.clone(),
5446                    Some(fixed_line.unwrap_or(c.line + line_offset)),
5447                    format!(
5448                        "`env_var('{}')`: environment variable `{}` is not set and no default is given",
5449                        c.name, c.name
5450                    ),
5451                );
5452            }
5453        }
5454        for (name, instead, line) in preflight::removed_names(src) {
5455            self.diags.error(
5456                Code::RemovedTemplateName,
5457                f.clone(),
5458                Some(fixed_line.unwrap_or(line + line_offset)),
5459                format!("`{name}` was removed in DRE 0.2: use {instead}"),
5460            );
5461        }
5462        for (r, line) in preflight::unknown_run_refs(src) {
5463            self.diags.error(
5464                Code::UnknownRunAttribute,
5465                f.clone(),
5466                Some(fixed_line.unwrap_or(line + line_offset)),
5467                format!(
5468                    "`{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(...)"
5469                ),
5470            );
5471        }
5472    }
5473
5474    fn check_vars(
5475        &mut self,
5476        file: &Path,
5477        src: &str,
5478        line_offset: usize,
5479        vars: &JsonMap<String, Json>,
5480        cli: &BTreeSet<&str>,
5481        ctx: &str,
5482    ) {
5483        for c in preflight::calls(src)
5484            .into_iter()
5485            .filter(|c| c.func == "var" && !c.has_default)
5486        {
5487            if !vars.contains_key(&c.name) && !cli.contains(c.name.as_str()) {
5488                self.diags.error(
5489                    Code::UnresolvedVar,
5490                    Some(file.to_path_buf()),
5491                    Some(c.line + line_offset),
5492                    format!(
5493                        "`var('{}')` has no value for {ctx} (checked --var, Set, report, folder and project vars) and no default",
5494                        c.name
5495                    ),
5496                );
5497            }
5498        }
5499    }
5500
5501    fn check_template_files(&mut self, project: &Project) {
5502        let mut seen = BTreeSet::new();
5503        for r in &project.reports {
5504            for t in r
5505                .bindings
5506                .iter()
5507                .flat_map(|b| b.outputs.iter().filter_map(|o| o.template.as_ref()))
5508            {
5509                if !seen.insert((r.name.clone(), t.file.clone(), format!("{:?}", t.bindings))) {
5510                    continue;
5511                }
5512                let Some(path) = find_template(&self.root, &t.file) else {
5513                    self.diags.error(
5514                        Code::MissingTemplate,
5515                        Some(r.file.clone()),
5516                        None,
5517                        format!("report `{}`: template file `{}` doesn't exist", r.name, t.file),
5518                    );
5519                    continue;
5520                };
5521                match template_sheets(&path) {
5522                    Ok(sheets) => {
5523                        for tb in &t.bindings {
5524                            if !sheets.contains(&tb.sheet) {
5525                                self.diags.error(
5526                                    Code::InvalidTemplate,
5527                                    Some(r.file.clone()),
5528                                    None,
5529                                    format!(
5530                                        "report `{}`: template `{}` has no sheet `{}` (sheets: {})",
5531                                        r.name,
5532                                        t.file,
5533                                        tb.sheet,
5534                                        sheets.join(", ")
5535                                    ),
5536                                );
5537                            }
5538                        }
5539                    }
5540                    Err(e) => self.diags.error(
5541                        Code::InvalidTemplate,
5542                        Some(r.file.clone()),
5543                        None,
5544                        format!("report `{}`: can't read template `{}`: {e}", r.name, t.file),
5545                    ),
5546                }
5547            }
5548        }
5549    }
5550}
5551
5552/// Sheet names in an xlsx template, read without modifying it.
5553/// Where a template file named in YAML (`template:`, a message's `file:`) is read from: the
5554/// project root, else `templates/`.
5555pub fn find_template(root: &Path, file: &str) -> Option<PathBuf> {
5556    [root.join(file), root.join("templates").join(file)]
5557        .into_iter()
5558        .find(|p| p.is_file())
5559}
5560
5561/// "output `name`", or "output 2" for an unnamed one, as messages name an output.
5562pub fn output_label(name: Option<&str>, index: usize) -> String {
5563    match name {
5564        Some(n) => format!("output `{n}`"),
5565        None => format!("output {}", index + 1),
5566    }
5567}
5568
5569pub fn template_sheets(path: &Path) -> Result<Vec<String>, String> {
5570    use calamine::Reader;
5571    let wb: calamine::Xlsx<_> =
5572        calamine::open_workbook(path).map_err(|e: calamine::XlsxError| e.to_string())?;
5573    Ok(wb.sheet_names().to_vec())
5574}
5575
5576struct RawReport {
5577    name: String,
5578    file: Rc<YamlFile>,
5579    folder: Vec<String>,
5580    /// Each key's value and the fragment it's in, for the keys not yet read as typed config.
5581    keys: BTreeMap<String, Located<Value>>,
5582    /// The line of each key, in its fragment.
5583    lines: BTreeMap<String, Option<usize>>,
5584    /// The typed keys, each from the fragment that declares it.
5585    typed: ReportFile,
5586}
5587
5588struct BindingBase {
5589    profile: Option<String>,
5590    profile_at: Option<ProfileAt>,
5591    vars: JsonMap<String, Json>,
5592    /// The merged outputs, in order: one unless a layer gave a list.
5593    outputs: Vec<Mapping>,
5594    locale: Option<String>,
5595}
5596
5597/// Everything the project references, for profile and plugin checks.
5598#[derive(Default)]
5599struct Usage {
5600    connections: BTreeMap<String, (Option<PathBuf>, Option<usize>)>,
5601    destinations: BTreeMap<String, (Option<PathBuf>, Option<usize>)>,
5602    /// format → first user, for messages.
5603    formats: BTreeMap<String, (String, PathBuf)>,
5604}
5605
5606impl Usage {
5607    /// A connection profile used by name. A value holding Jinja is recorded once the parse pass
5608    /// has rendered it.
5609    fn connection(&mut self, p: &str, file: Option<PathBuf>, line: Option<usize>) {
5610        if preflight::is_templated(p) {
5611            return;
5612        }
5613        let e = self.connections.entry(p.to_string()).or_insert((None, None));
5614        if e.0.is_none() {
5615            *e = (file, line);
5616        }
5617    }
5618    fn destination(&mut self, p: &str, file: Option<PathBuf>, line: Option<usize>) {
5619        if preflight::is_templated(p) {
5620            return;
5621        }
5622        let e = self.destinations.entry(p.to_string()).or_insert((None, None));
5623        if e.0.is_none() {
5624            *e = (file, line);
5625        }
5626    }
5627    fn format(&mut self, f: &str, ctx: &str, file: &Path) {
5628        self.formats
5629            .entry(f.to_string())
5630            .or_insert_with(|| (ctx.to_string(), file.to_path_buf()));
5631    }
5632}
5633
5634// ---------------------------------------------------------------------------------------------
5635// Helpers
5636// ---------------------------------------------------------------------------------------------
5637
5638fn builtin_output() -> Mapping {
5639    let mut m = Mapping::new();
5640    m.insert("format".into(), Value::String("csv".into()));
5641    m
5642}
5643
5644fn project_default_output(p: &Project) -> Option<Mapping> {
5645    let yf = YamlFile::parse(
5646        std::fs::read_to_string(p.root.join(PROJECT_FILE)).ok()?,
5647        PathBuf::from(PROJECT_FILE),
5648        &mut Diagnostics::default(),
5649    )?;
5650    yf.value.get("default_output").and_then(Value::as_object).cloned()
5651}
5652
5653/// Apply one report or Set `output:` layer over the inherited outputs; returns the problems.
5654///
5655/// - A list replaces whatever was inherited; each entry starts from the built-in defaults.
5656/// - A map merges ([`merge_output`]) into the single inherited output, or into the one its
5657///   `name:` names. With several inherited outputs and no `name:`, it's unclear which one to
5658///   change: the layer is ignored and the problem says why.
5659pub fn layer_outputs(base: &mut Vec<Mapping>, over: &Value) -> Vec<String> {
5660    let mut problems = Vec::new();
5661    match over {
5662        Value::Array(list) if list.is_empty() => problems.push(
5663            "`output` is an empty list; give at least one output, or remove it to use the inherited one"
5664                .into(),
5665        ),
5666        Value::Array(list) => {
5667            let mut outs = Vec::new();
5668            for (i, entry) in list.iter().enumerate() {
5669                match entry.as_object() {
5670                    Some(m) => {
5671                        let mut o = builtin_output();
5672                        problems.extend(merge_output(&mut o, m));
5673                        outs.push(o);
5674                    }
5675                    None => problems.push(format!("`output` entry {} must be a map", i + 1)),
5676                }
5677            }
5678            if problems.is_empty() {
5679                *base = outs;
5680            }
5681        }
5682        Value::Object(m) => {
5683            let name_of = |o: &Mapping| o.get("name").and_then(Value::as_str).map(str::to_string);
5684            let target = match m.get("name").and_then(Value::as_str) {
5685                Some(n) => match base.iter().position(|o| name_of(o).as_deref() == Some(n)) {
5686                    Some(i) => Some(i),
5687                    None if base.len() == 1 && name_of(&base[0]).is_none() => Some(0),
5688                    None => {
5689                        let names: Vec<String> = base
5690                            .iter()
5691                            .filter_map(name_of)
5692                            .map(|n| format!("`{n}`"))
5693                            .collect();
5694                        problems.push(format!(
5695                            "no inherited output is named `{n}`{}; override the full `output:` list to add one",
5696                            if names.is_empty() {
5697                                String::new()
5698                            } else {
5699                                format!(" (they're {})", names.join(", "))
5700                            }
5701                        ));
5702                        None
5703                    }
5704                },
5705                None if base.len() == 1 => Some(0),
5706                None => {
5707                    problems.push(format!(
5708                        "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",
5709                        base.len()
5710                    ));
5711                    None
5712                }
5713            };
5714            if let Some(i) = target {
5715                problems.extend(merge_output(&mut base[i], m));
5716            }
5717        }
5718        _ => problems.push("`output` must be a map or a list of maps".into()),
5719    }
5720    problems
5721}
5722
5723/// Merge an output layer over `base`. Changing `format` drops the lower layers' format options,
5724/// since they belong to a different format.
5725///
5726/// `destination` (a map or a list of maps):
5727/// - a list replaces whatever was inherited;
5728/// - a map naming a different `profile` replaces it too, so one plugin's options never leak into
5729///   another's;
5730/// - a map without `profile` (or with the same one) merges key by key into the inherited single
5731///   destination, so a Binding can override just `path`. With several inherited destinations
5732///   that's ambiguous: the layer's destination is ignored and the returned message says why.
5733pub fn merge_output(base: &mut Mapping, over: &Mapping) -> Option<String> {
5734    if let Some(f) = over.get("format")
5735        && base.get("format") != Some(f)
5736    {
5737        base.retain(|k, _| is_one_of(k, FORMAT_INDEPENDENT_KEYS));
5738    }
5739    let mut problem = None;
5740    for (k, v) in over {
5741        if k.as_str() == "destination"
5742            && let Value::Object(o) = v
5743        {
5744            let profile = |m: &Mapping| m.get("profile").cloned();
5745            match base.get_mut(k) {
5746                Some(Value::Array(list)) if o.get("profile").is_none() && list.len() > 1 => {
5747                    problem = Some(format!(
5748                        "this overrides `destination` with no `profile:`, but it inherits {} destinations, so it's unclear which one to change; override the full list instead",
5749                        list.len()
5750                    ));
5751                    continue;
5752                }
5753                Some(Value::Array(list))
5754                    if list.len() == 1
5755                        && list[0]
5756                            .as_object()
5757                            .is_some_and(|b| o.get("profile").is_none() || profile(b) == profile(o)) =>
5758                {
5759                    let mut b = list[0].as_object().cloned().unwrap_or_default();
5760                    b.extend(o.clone());
5761                    base.insert(k.clone(), Value::Object(b));
5762                    continue;
5763                }
5764                Some(Value::Object(b)) if o.get("profile").is_none() || profile(b) == profile(o) => {
5765                    b.extend(o.clone());
5766                    continue;
5767                }
5768                _ => {}
5769            }
5770        }
5771        base.insert(k.clone(), v.clone());
5772    }
5773    problem
5774}
5775
5776/// Where an inherited `profile` comes from when the report sets none: the deepest folder's
5777/// `+profile`, else `default_profile`.
5778fn inherited_profile_at(layers: &[&FolderCfg], project: &Project) -> Option<ProfileAt> {
5779    if let Some((_, line)) = layers.iter().rev().find_map(|l| l.profile.as_ref()) {
5780        return Some(ProfileAt {
5781            file: PathBuf::from(PROJECT_FILE),
5782            line: *line,
5783            key: "`+profile`".into(),
5784        });
5785    }
5786    project.default_profile.as_ref().map(|_| ProfileAt {
5787        file: PathBuf::from(PROJECT_FILE),
5788        line: project.default_profile_line,
5789        key: "`default_profile`".into(),
5790    })
5791}
5792
5793fn metadata_layers(project: &Project, folders: &[&FolderCfg]) -> Vec<MetaLayer> {
5794    let mut layers = Vec::new();
5795    if !project.meta.is_empty() {
5796        layers.push(MetaLayer {
5797            file: PROJECT_FILE.into(),
5798            key: "meta".into(),
5799            meta: project.meta.clone(),
5800        });
5801    }
5802    layers.extend(folders.iter().filter_map(|f| f.meta.clone()));
5803    layers
5804}
5805
5806fn folder_layers<'a>(folders: &'a BTreeMap<Vec<String>, FolderCfg>, folder: &[String]) -> Vec<&'a FolderCfg> {
5807    (0..=folder.len())
5808        .filter_map(|n| folders.get(&folder[..n].to_vec()))
5809        .collect()
5810}
5811
5812fn folder_segments(rel: &Path) -> Vec<String> {
5813    rel.components()
5814        .skip(1)
5815        .map(|c| c.as_os_str().to_string_lossy().to_string())
5816        .collect()
5817}
5818
5819pub fn dotted(path: &[String]) -> String {
5820    path.join(".")
5821}
5822
5823fn pick(v: &Value, keys: &[&str]) -> Mapping {
5824    v.as_object()
5825        .map(|m| {
5826            m.iter()
5827                .filter(|(k, _)| is_one_of(k, keys))
5828                .map(|(k, v)| (k.clone(), v.clone()))
5829                .collect()
5830        })
5831        .unwrap_or_default()
5832}
5833
5834/// Every string inside a JSON value (the templated values of a destination option).
5835fn json_strings(v: &Json) -> Vec<String> {
5836    match v {
5837        Json::String(s) => vec![s.clone()],
5838        Json::Array(a) => a.iter().flat_map(json_strings).collect(),
5839        Json::Object(o) => o.values().flat_map(json_strings).collect(),
5840        _ => Vec::new(),
5841    }
5842}
5843
5844fn is_one_of(k: &str, keys: &[&str]) -> bool {
5845    keys.contains(&k)
5846}
5847
5848/// A map of names to timings: every value holds `cron`, `every` or `rrule`.
5849fn is_timing_registry(m: &Mapping) -> bool {
5850    !m.is_empty()
5851        && m.values().all(|v| {
5852            v.as_object()
5853                .is_some_and(|t| ["cron", "every", "rrule"].iter().any(|k| t.contains_key(*k)))
5854        })
5855}
5856
5857/// Every entry is a Set: a map of `profile`/`vars`, which may be empty (`plain: {}`, the
5858/// report's defaults) or left blank.
5859fn is_set_registry(m: &Mapping) -> bool {
5860    m.values().any(Value::is_object)
5861        && m.values().all(|v| {
5862            v.is_null()
5863                || v.as_object()
5864                    .is_some_and(|e| e.keys().all(|k| is_one_of(k, &["profile", "vars", "locale"])))
5865        })
5866}
5867
5868fn string_list(v: &Value) -> Option<Vec<String>> {
5869    v.as_array()?
5870        .iter()
5871        .map(|i| i.as_str().map(str::to_string))
5872        .collect()
5873}
5874
5875/// The query a `queries:` item names, if it names one.
5876fn query_item_name(item: &QueryItem) -> Option<&str> {
5877    match &item.value {
5878        Loose::Ok(de::OneOf::A(s)) => Some(s),
5879        Loose::Ok(de::OneOf::B(e)) => e.query.as_ref().and_then(Loose::ok).map(String::as_str),
5880        Loose::Bad(_) => None,
5881    }
5882}
5883
5884fn entry_name(v: &Value) -> Option<String> {
5885    match v {
5886        Value::String(s) => Some(s.clone()),
5887        Value::Object(m) => m.get("query").and_then(Value::as_str).map(str::to_string),
5888        _ => None,
5889    }
5890}
5891
5892fn dedup(v: &mut Vec<String>) {
5893    let mut seen = BTreeSet::new();
5894    v.retain(|t| seen.insert(t.clone()));
5895}
5896
5897/// Letters, digits and `_`, not starting with a digit.
5898fn is_identifier(s: &str) -> bool {
5899    !s.is_empty()
5900        && !s.starts_with(|c: char| c.is_ascii_digit())
5901        && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
5902}