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