Skip to main content

dre_core/
project.rs

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