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