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