Skip to main content

dre_core/
project.rs

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