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