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