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