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