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