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, required flag, pattern and max_length");
3089 BTreeMap::new()
3090 }
3091 };
3092 for (name, definition) in &var_schema {
3093 for problem in definition.problems(name) {
3094 self.diags
3095 .error(Code::InvalidField, Some(r.file.display.clone()), None, problem);
3096 }
3097 if let Some(value) = &definition.default
3098 && let Err(error) = definition.check(name, value)
3099 {
3100 self.diags
3101 .error(Code::InvalidField, Some(r.file.display.clone()), None, error);
3102 }
3103 if name.trim().is_empty() {
3104 self.diags.error(
3105 Code::InvalidField,
3106 Some(r.file.display.clone()),
3107 None,
3108 "variable names must be non-empty",
3109 );
3110 }
3111 }
3112 let description = self.typed_string(
3113 &located("description").map_or_else(|| r.file.display.clone(), |(f, _)| f),
3114 typed.description.clone(),
3115 "description",
3116 );
3117 let meta_file = located("meta").map_or_else(|| r.file.display.clone(), |(f, _)| f);
3118 let meta = self.typed_metadata(&meta_file, typed.meta.clone(), "meta");
3119 let mut meta_layers = metadata_layers(project, &layers);
3120 if typed.meta.is_some() {
3121 meta_layers.push(MetaLayer {
3122 file: crate::slash(&meta_file).to_string_lossy().into_owned(),
3123 key: "meta".into(),
3124 meta: meta.clone(),
3125 });
3126 }
3127
3128 let mut tags: Vec<String> = layers.iter().flat_map(|l| l.tags.clone()).collect();
3129 if let Some(t) = &typed.tags {
3130 match &t.value {
3131 Loose::Ok(l) => tags.extend(l.iter().cloned()),
3132 Loose::Bad(_) => {
3133 let (f, l) = located("tags").unwrap();
3134 self.diags.error(
3135 Code::InvalidField,
3136 Some(f),
3137 l,
3138 format!("report `{name}`: `tags` must be a list of strings"),
3139 );
3140 }
3141 }
3142 }
3143 dedup(&mut tags);
3144
3145 if key("profile").is_some() && key("sets").is_some() {
3146 let (f, l) = located("sets").unwrap();
3147 self.diags.error(
3148 Code::ProfileAndSets,
3149 Some(f),
3150 l,
3151 format!("report `{name}` declares both `profile:` and `sets:`; use one or the other"),
3152 );
3153 }
3154
3155 let report_profile = match &typed.profile {
3156 Some(p) => match &p.value {
3157 Loose::Ok(s) => {
3158 let (f, l) = located("profile").unwrap();
3159 used.connection(s, Some(f), l);
3160 Some(s.to_string())
3161 }
3162 Loose::Bad(_) => {
3163 let (f, l) = located("profile").unwrap();
3164 self.diags.error(
3165 Code::InvalidField,
3166 Some(f),
3167 l,
3168 format!("report `{name}`: `profile` must be a string"),
3169 );
3170 None
3171 }
3172 },
3173 None => None,
3174 };
3175 let folder_profile = layers
3176 .iter()
3177 .rev()
3178 .find_map(|l| l.profile.as_ref().map(|p| p.0.clone()));
3179 let report_timezone = match &typed.timezone {
3180 Some(t) => {
3181 let (f, l) = located("timezone").unwrap();
3182 self.timezone_str(
3183 t.value.ok().map(String::as_str),
3184 &f,
3185 l,
3186 &format!("report `{name}`: `timezone`"),
3187 )
3188 }
3189 None => None,
3190 };
3191 let timezone = report_timezone
3192 .or_else(|| layers.iter().rev().find_map(|l| l.timezone.clone()))
3193 .or_else(|| project.timezone.clone());
3194 let report_locale = match &typed.locale {
3195 Some(t) => {
3196 let (f, l) = located("locale").unwrap();
3197 self.locale_str(
3198 t.value.ok().map(String::as_str),
3199 &f,
3200 l,
3201 &format!("report `{name}`: `locale`"),
3202 )
3203 }
3204 None => None,
3205 };
3206 let locale = report_locale
3207 .or_else(|| layers.iter().rev().find_map(|l| l.locale.clone()))
3208 .or_else(|| project.locale.clone());
3209 let profile_at = if report_profile.is_some() {
3210 located("profile").map(|(file, line)| ProfileAt {
3211 file,
3212 line,
3213 key: "`profile`".into(),
3214 })
3215 } else {
3216 inherited_profile_at(&layers, project)
3217 };
3218 let base_profile = report_profile
3219 .or(folder_profile)
3220 .or(project.default_profile.clone());
3221
3222 let mut vars = project.vars.clone();
3223 for l in &layers {
3224 if let Some(v) = &l.vars {
3225 vars.extend(v.clone());
3226 }
3227 }
3228 if let Some(v) = &typed.vars {
3229 match &v.value {
3230 Loose::Ok(m) => vars.extend(m.clone()),
3231 Loose::Bad(_) => {
3232 let (f, l) = located("vars").unwrap();
3233 self.diags.error(
3234 Code::InvalidField,
3235 Some(f),
3236 l,
3237 format!("report `{name}`: `vars` must be a map"),
3238 );
3239 }
3240 }
3241 }
3242
3243 let mut output = builtin_output();
3244 for (name, definition) in &var_schema {
3245 if let Some(value) = &definition.default {
3246 vars.entry(name.clone()).or_insert_with(|| value.clone());
3247 }
3248 }
3249 let mut merge_problems = Vec::new();
3250 if let Some(o) = project_default_output(project) {
3251 merge_problems.extend(merge_output(&mut output, &o));
3252 }
3253 for l in &layers {
3254 if let Some(o) = &l.output {
3255 merge_problems.extend(merge_output(&mut output, o));
3256 }
3257 }
3258 for p in merge_problems {
3259 self.diags.error(
3260 Code::InvalidField,
3261 Some(r.file.display.clone()),
3262 None,
3263 format!("report `{name}`: folder config: {p}"),
3264 );
3265 }
3266 let mut outputs = vec![output];
3267 if let Some(o) = key("output") {
3268 for p in layer_outputs(&mut outputs, &o.value) {
3269 let (f, l) = located("output").unwrap();
3270 self.diags
3271 .error(Code::InvalidField, Some(f), l, format!("report `{name}`: {p}"));
3272 }
3273 }
3274
3275 if typed.schedule.is_some() {
3276 let (f, l) = located("schedule").unwrap();
3277 self.moved_to_schedules(&f, l, &format!("report `{name}`: `schedule`"));
3278 }
3279
3280 let default_set = typed.default_set.as_ref().and_then(|d| d.value.ok().cloned());
3281
3282 let base = BindingBase {
3283 profile: base_profile,
3284 profile_at,
3285 vars,
3286 outputs,
3287 locale,
3288 };
3289 let has_sets = key("sets").is_some();
3290 let report_base = self.silent_binding(&name, &base, &queries, &r.file.display);
3291 let mut bindings = Vec::new();
3292 if let (Some(s), Some(at)) = (&typed.sets, key("sets")) {
3293 bindings = self.resolve_sets(&name, s, &at.file.display, &queries, &base, project, used);
3294 let declared: Vec<&str> = bindings.iter().filter_map(|b| b.set.as_deref()).collect();
3295 if let Some(d) = &default_set
3296 && !declared.contains(&d.as_str())
3297 {
3298 let (f, l) = located("default_set").unwrap();
3299 self.diags.error(
3300 Code::UnknownDefaultSet,
3301 Some(f),
3302 l,
3303 format!("report `{name}`: `default_set` `{d}` isn't one of the report's `sets:`"),
3304 );
3305 }
3306 } else {
3307 if default_set.is_some() {
3308 let (f, l) = located("default_set").unwrap();
3309 self.diags.error(
3310 Code::UnknownDefaultSet,
3311 Some(f),
3312 l,
3313 format!("report `{name}`: `default_set` needs a `sets:` list"),
3314 );
3315 }
3316 let b = self.finish_binding(
3317 &name,
3318 None,
3319 &base,
3320 None,
3321 queries.clone(),
3322 r.file.display.clone(),
3323 used,
3324 );
3325 bindings.push(b);
3326 }
3327
3328 Some(Report {
3329 name,
3330 var_schema,
3331 description,
3332 meta,
3333 meta_layers,
3334 managed: true,
3335 file: r.file.display.clone(),
3336 folder: r.folder.clone(),
3337 tags,
3338 queries,
3339 default_set,
3340 timezone,
3341 has_sets,
3342 bindings,
3343 base: report_base,
3344 })
3345 }
3346
3347 #[allow(clippy::too_many_arguments)]
3348 fn resolve_sets(
3349 &mut self,
3350 report: &str,
3351 sets: &de::Located<Loose<Vec<SetItem>>>,
3352 display: &Path,
3353 queries: &[QueryEntry],
3354 base: &BindingBase,
3355 project: &Project,
3356 used: &mut Usage,
3357 ) -> Vec<Binding> {
3358 let file = Some(display.to_path_buf());
3359 let sets_line = sets.line();
3360 let Loose::Ok(items) = &sets.value else {
3361 self.diags.error(
3362 Code::InvalidField,
3363 file,
3364 sets_line,
3365 format!("report `{report}`: `sets` must be a list"),
3366 );
3367 return Vec::new();
3368 };
3369 let mut seen = BTreeSet::new();
3370 let mut out = Vec::new();
3371 for item in items {
3372 let line = item.line();
3373 let (name, inline): (String, Option<&SetEntry>) = match &item.value {
3374 Loose::Ok(de::OneOf::A(s)) => (s.clone(), None),
3375 Loose::Ok(de::OneOf::B(m)) => match &m.name {
3376 Some(Loose::Ok(n)) => (n.clone(), Some(m)),
3377 _ => {
3378 self.diags.error(
3379 Code::InvalidField,
3380 file.clone(),
3381 line,
3382 format!("report `{report}`: a `sets:` map entry needs a `name:`"),
3383 );
3384 continue;
3385 }
3386 },
3387 Loose::Bad(_) => {
3388 self.diags.error(
3389 Code::InvalidField,
3390 file.clone(),
3391 line,
3392 format!("report `{report}`: `sets:` entries must be names or maps"),
3393 );
3394 continue;
3395 }
3396 };
3397 if !seen.insert(name.clone()) {
3398 self.diags.error(
3399 Code::DuplicateSet,
3400 file.clone(),
3401 line,
3402 format!("report `{report}` lists Set `{name}` more than once"),
3403 );
3404 continue;
3405 }
3406 let registry = project.sets.get(&name);
3407 if inline.is_none() && registry.is_none() {
3408 self.diags.error(
3409 Code::UnknownSet,
3410 file.clone(),
3411 line,
3412 format!("report `{report}`: Set `{name}` isn't declared in sets.yml"),
3413 );
3414 continue;
3415 }
3416 let mut b = BindingBase {
3417 profile: base.profile.clone(),
3418 profile_at: base.profile_at.clone(),
3419 vars: base.vars.clone(),
3420 outputs: base.outputs.clone(),
3421 locale: base.locale.clone(),
3422 };
3423 if let Some(reg) = registry {
3424 if let Some(p) = ®.profile {
3425 b.profile = Some(p.clone());
3426 b.profile_at = Some(ProfileAt {
3427 file: reg.file.clone(),
3428 line: reg.line,
3429 key: format!("`profile` of Set `{name}`"),
3430 });
3431 used.connection(p, None, None);
3432 }
3433 b.vars.extend(reg.vars.clone());
3434 if reg.locale.is_some() {
3435 b.locale = reg.locale.clone();
3436 }
3437 }
3438 let mut qs = queries.to_vec();
3439 let mut tab_names: Option<Mapping> = None;
3440 if let Some(m) = inline {
3441 let ctx = format!("report `{report}`, Set `{name}`");
3442 for k in &m.unknown.0 {
3443 self.diags.error(
3444 Code::UnknownKey,
3445 file.clone(),
3446 Some(k.line),
3447 format!("{ctx}: unknown key `{}`", k.name),
3448 );
3449 }
3450 if let Some(p) = &m.profile {
3451 let pline = p.line();
3452 match &p.value {
3453 Loose::Ok(p) => {
3454 b.profile = Some(p.to_string());
3455 b.profile_at = Some(ProfileAt {
3456 file: display.to_path_buf(),
3457 line: pline,
3458 key: format!("`profile` of Set `{name}`"),
3459 });
3460 used.connection(p, file.clone(), pline);
3461 }
3462 Loose::Bad(_) => self.diags.error(
3463 Code::InvalidField,
3464 file.clone(),
3465 pline,
3466 format!("{ctx}: `profile` must be a string"),
3467 ),
3468 }
3469 }
3470 match &m.vars {
3471 Some(Loose::Ok(v)) => b.vars.extend(v.clone()),
3472 None => {}
3473 Some(Loose::Bad(_)) => self.diags.error(
3474 Code::InvalidField,
3475 file.clone(),
3476 line,
3477 format!("{ctx}: `vars` must be a map"),
3478 ),
3479 }
3480 if let Some(o) = &m.output {
3481 for p in layer_outputs(&mut b.outputs, o) {
3482 self.diags
3483 .error(Code::InvalidField, file.clone(), line, format!("{ctx}: {p}"));
3484 }
3485 }
3486 if m.schedule.is_some() {
3487 self.moved_to_schedules(display, line, &format!("{ctx}: `schedule`"));
3488 }
3489 if let Some(l) = &m.locale
3490 && let Some(l) = self.locale_str(
3491 l.ok().map(String::as_str),
3492 display,
3493 line,
3494 &format!("{ctx}: `locale`"),
3495 )
3496 {
3497 b.locale = Some(l);
3498 }
3499 let listed: Vec<&str> = queries.iter().map(|q| q.query.as_str()).collect();
3500 match (&m.exclude, &m.queries) {
3501 (Some(_), Some(_)) => self.diags.error(
3502 Code::ExcludeAndQueries,
3503 file.clone(),
3504 line,
3505 format!("{ctx}: use either `exclude:` or `queries:`, not both"),
3506 ),
3507 (Some(ex), None) => match &ex.value {
3508 Loose::Ok(names) => {
3509 for q in names {
3510 if !listed.contains(&q.as_str()) {
3511 self.diags.error(
3512 Code::UnknownQuery,
3513 file.clone(),
3514 ex.line(),
3515 format!("{ctx}: `exclude` names `{q}`, which isn't in the report's `queries:`"),
3516 );
3517 }
3518 }
3519 qs.retain(|q| !names.contains(&q.query));
3520 }
3521 Loose::Bad(_) => self.diags.error(
3522 Code::InvalidField,
3523 file.clone(),
3524 line,
3525 format!("{ctx}: `exclude` must be a list of query names"),
3526 ),
3527 },
3528 (None, Some(ov)) => match &ov.value {
3529 Loose::Ok(items) => {
3530 let mut sub = Vec::new();
3531 for it in items {
3532 let (qn, settings) = match it {
3533 Loose::Ok(de::OneOf::A(s)) => (s.clone(), None),
3534 Loose::Ok(de::OneOf::B(e)) => {
3535 match e.query.as_ref().and_then(Loose::ok) {
3536 Some(q) => (q.clone(), Some(e)),
3537 None => continue,
3538 }
3539 }
3540 Loose::Bad(_) => continue,
3541 };
3542 match queries.iter().find(|q| q.query == qn) {
3543 Some(q) => {
3544 let mut q = q.clone();
3545 if let Some(e) = settings {
3546 match &e.tab_name {
3547 None => {}
3548 Some(Loose::Ok(s)) => q.tab_name = Some(s.clone()),
3549 Some(Loose::Bad(_)) => self.diags.error(
3550 Code::InvalidField,
3551 file.clone(),
3552 line,
3553 format!("{ctx}: {}", one_tab_per_file(&qn)),
3554 ),
3555 }
3556 if let Some(Loose::Ok(b)) = &e.tab {
3557 q.tab = *b;
3558 }
3559 }
3560 sub.push(q);
3561 }
3562 None => self.diags.error(
3563 Code::UnknownQuery,
3564 file.clone(),
3565 ov.line(),
3566 format!("{ctx}: `queries` names `{qn}`, which isn't in the report's `queries:`"),
3567 ),
3568 }
3569 }
3570 qs = sub;
3571 }
3572 Loose::Bad(_) => self.diags.error(
3573 Code::InvalidField,
3574 file.clone(),
3575 line,
3576 format!("{ctx}: `queries` must be a list"),
3577 ),
3578 },
3579 (None, None) => {}
3580 }
3581 tab_names = match &m.tab_names {
3582 None => None,
3583 Some(Loose::Ok(t)) => Some(t.clone()),
3584 Some(Loose::Bad(_)) => {
3585 self.diags.error(
3586 Code::InvalidField,
3587 file.clone(),
3588 line,
3589 format!("{ctx}: `tab_names` must be a map of query name to tab name"),
3590 );
3591 None
3592 }
3593 };
3594 }
3595 let bind = self.finish_binding(
3596 report,
3597 Some(name),
3598 &b,
3599 tab_names.as_ref(),
3600 qs,
3601 display.to_path_buf(),
3602 used,
3603 );
3604 out.push(bind);
3605 }
3606 out
3607 }
3608
3609 fn silent_binding(
3611 &mut self,
3612 report: &str,
3613 base: &BindingBase,
3614 queries: &[QueryEntry],
3615 file: &Path,
3616 ) -> Binding {
3617 let saved = std::mem::take(&mut self.diags);
3618 let mut scratch = Usage::default();
3619 let b = self.finish_binding(
3620 report,
3621 None,
3622 base,
3623 None,
3624 queries.to_vec(),
3625 file.to_path_buf(),
3626 &mut scratch,
3627 );
3628 self.diags = saved;
3629 b
3630 }
3631
3632 #[allow(clippy::too_many_arguments)]
3633 fn finish_binding(
3634 &mut self,
3635 report: &str,
3636 set: Option<String>,
3637 base: &BindingBase,
3638 tab_names: Option<&Mapping>,
3639 mut queries: Vec<QueryEntry>,
3640 file: PathBuf,
3641 used: &mut Usage,
3642 ) -> Binding {
3643 let ctx = match &set {
3644 Some(s) => format!("report `{report}`, Set `{s}`"),
3645 None => format!("report `{report}`"),
3646 };
3647 if let Some(t) = tab_names {
3648 for (k, v) in t {
3649 let q = k.as_str();
3650 match queries.iter_mut().find(|e| e.query == q) {
3651 Some(e) => match v {
3652 Value::String(s) => e.tab_name = Some(s.clone()),
3653 _ => self.diags.error(
3654 Code::InvalidField,
3655 Some(file.clone()),
3656 None,
3657 format!("{ctx}: {}", one_tab_per_file(q)),
3658 ),
3659 },
3660 None => self.diags.error(
3661 Code::UnknownQuery,
3662 Some(file.clone()),
3663 None,
3664 format!("{ctx}: `tab_names` names `{q}`, which isn't one of this Binding's queries"),
3665 ),
3666 }
3667 }
3668 }
3669 let outputs = self.typed_outputs(&base.outputs, &ctx, &file, &queries, used);
3670 if let Some(p) = &base.profile {
3671 used.connection(p, None, None);
3672 }
3673 Binding {
3674 set,
3675 profile: base.profile.clone(),
3676 vars: base.vars.clone(),
3677 queries: std::mem::take(&mut queries),
3678 locale: base.locale.clone(),
3679 outputs,
3680 schedules: Vec::new(),
3681 parsed: None,
3682 profile_at: base.profile_at.clone(),
3683 }
3684 }
3685
3686 fn typed_outputs(
3689 &mut self,
3690 maps: &[Mapping],
3691 ctx: &str,
3692 file: &Path,
3693 queries: &[QueryEntry],
3694 used: &mut Usage,
3695 ) -> Vec<Output> {
3696 let at = Some(file.to_path_buf());
3697 let several = maps.len() > 1;
3698 let mut outputs = Vec::new();
3699 for (i, m) in maps.iter().enumerate() {
3700 let octx = if several {
3701 format!(
3702 "{ctx}, {}",
3703 output_label(m.get("name").and_then(Value::as_str), i)
3704 )
3705 } else {
3706 ctx.to_string()
3707 };
3708 let subset: Vec<QueryEntry> = match m.get("queries") {
3709 None | Some(Value::Null) => queries.to_vec(),
3710 Some(v) => {
3711 match string_list(v) {
3712 Some(names) => {
3713 for n in names.iter().filter(|n| !queries.iter().any(|q| &&q.query == n)) {
3714 self.diags.error(
3715 Code::UnknownQuery,
3716 at.clone(),
3717 None,
3718 format!("{octx}: `queries` names `{n}`, which isn't one of this Binding's queries"),
3719 );
3720 }
3721 queries
3722 .iter()
3723 .filter(|q| names.contains(&q.query))
3724 .cloned()
3725 .collect()
3726 }
3727 None => {
3728 self.diags.error(
3729 Code::InvalidField,
3730 at.clone(),
3731 None,
3732 format!("{octx}: `queries` must be a list of query names"),
3733 );
3734 queries.to_vec()
3735 }
3736 }
3737 }
3738 };
3739 outputs.push(self.typed_output(m, &octx, file, &subset, used));
3740 }
3741 let mut seen = BTreeSet::new();
3742 for o in &outputs {
3743 if let Some(n) = &o.name
3744 && !seen.insert(n.clone())
3745 {
3746 self.diags.error(
3747 Code::DuplicateOutput,
3748 at.clone(),
3749 None,
3750 format!("{ctx}: output `{n}` is declared twice; output names must be unique"),
3751 );
3752 }
3753 }
3754 let mut exts = BTreeSet::new();
3756 for o in outputs.iter().filter(|o| o.name.is_none()) {
3757 let ext = o.extension.clone().unwrap_or_else(|| o.format.clone());
3758 if !exts.insert(ext) {
3759 self.diags.error(
3760 Code::DuplicateOutput,
3761 at.clone(),
3762 None,
3763 format!(
3764 "{ctx}: two unnamed `{}` outputs would write the same file; give each output a `name:`",
3765 o.format
3766 ),
3767 );
3768 }
3769 }
3770 for q in queries.iter().filter(|q| !q.columns.is_empty()) {
3771 let formats: Vec<&str> = outputs
3772 .iter()
3773 .filter(|o| o.feeds(&q.query))
3774 .map(|o| o.format.as_str())
3775 .collect();
3776 if !formats.is_empty() && !formats.contains(&"xlsx") {
3777 self.diags.error(
3778 Code::InvalidField,
3779 at.clone(),
3780 None,
3781 format!(
3782 "{ctx}: `columns` on query `{}` only applies to the xlsx format",
3783 q.query
3784 ),
3785 );
3786 }
3787 }
3788 for (i, o) in outputs.iter().enumerate() {
3789 let octx = if several {
3790 format!("{ctx}, {}", o.label(i))
3791 } else {
3792 ctx.to_string()
3793 };
3794 self.check_output_templates(o, &outputs, &octx, file, queries);
3795 for d in o.destinations.iter().filter(|d| !d.attach.is_empty()) {
3796 for a in &d.attach {
3797 let problem = match outputs.iter().find(|x| x.name.as_deref() == Some(a.as_str())) {
3798 _ if !o.is_message() => Some("`attach` only applies to a message output".to_string()),
3799 None => Some(format!("no output of this report is named `{a}`")),
3800 Some(x) if std::ptr::eq(x, o) => Some(format!("`{a}` is this output itself")),
3801 Some(x) if x.is_message() => Some(format!(
3802 "`{a}` is a message; attach file outputs (its `.md` is linked with `outputs.{a}.location`)"
3803 )),
3804 Some(_) => None,
3805 };
3806 if let Some(p) = problem {
3807 self.diags.error(
3808 Code::InvalidDestinationOption,
3809 at.clone(),
3810 None,
3811 format!("{octx}: destination `{}`: `attach: {a}`: {p}", d.profile),
3812 );
3813 }
3814 }
3815 }
3816 }
3817 for q in queries.iter().filter(|q| q.tab) {
3818 if !outputs.iter().any(|o| o.feeds(&q.query)) {
3819 self.diags.warning(
3820 Code::UnusedQuery,
3821 at.clone(),
3822 None,
3823 format!(
3824 "{ctx}: query `{}` feeds no output; add it to an output's `queries:`, or give it `tab: false` if it only prepares later queries",
3825 q.query
3826 ),
3827 );
3828 }
3829 }
3830 outputs
3831 }
3832
3833 fn check_output_templates(
3837 &mut self,
3838 o: &Output,
3839 all: &[Output],
3840 ctx: &str,
3841 file: &Path,
3842 queries: &[QueryEntry],
3843 ) {
3844 let at = Some(file.to_path_buf());
3845 let err = |s: &mut Self, msg: String| {
3846 s.diags.error(
3847 Code::InvalidOutputOption,
3848 at.clone(),
3849 None,
3850 format!("{ctx}: {msg}"),
3851 )
3852 };
3853 let mut templates: Vec<(String, String)> = Vec::new();
3855 if let Some(w) = &o.when {
3856 templates.push(("`when`".into(), format!("{{% if {w} %}}{{% endif %}}")));
3857 }
3858 if o.is_message() {
3859 for k in o.options.keys() {
3860 if !crate::message::MESSAGE_KEYS.contains(&k.as_str()) {
3861 err(
3862 self,
3863 format!(
3864 "`{k}` isn't an option of the `message` format; its options are {}",
3865 crate::message::MESSAGE_KEYS.join(", ")
3866 ),
3867 );
3868 }
3869 }
3870 if o.options.contains_key("text") && o.options.contains_key("file") {
3871 err(self, "use either `text:` or `file:`, not both".into());
3872 }
3873 for k in ["text", "title"] {
3874 match o.options.get(k) {
3875 None => {}
3876 Some(Json::String(t)) => templates.push((format!("`{k}`"), t.clone())),
3877 Some(_) => err(self, format!("`{k}` must be a string")),
3878 }
3879 }
3880 match o.options.get("file") {
3881 None => {}
3882 Some(Json::String(f)) => match find_template(&self.root, f).map(std::fs::read_to_string) {
3883 Some(Ok(t)) => templates.push((format!("`file` {f}"), t)),
3884 Some(Err(e)) => err(self, format!("can't read message file `{f}`: {e}")),
3885 None => err(
3886 self,
3887 format!(
3888 "message file `{f}` doesn't exist (looked in the project root and templates/)"
3889 ),
3890 ),
3891 },
3892 Some(_) => err(self, "`file` must be a path (a string)".into()),
3893 }
3894 match o.options.get("max_rows") {
3895 None => {}
3896 Some(v) if v.as_u64().is_some_and(|n| n > 0) => {}
3897 Some(_) => err(self, "`max_rows` must be a positive whole number".into()),
3898 }
3899 }
3900 let fed: Vec<&str> = queries
3901 .iter()
3902 .filter(|q| q.tab && o.feeds(&q.query))
3903 .map(|q| q.query.as_str())
3904 .collect();
3905 for (what, src) in &templates {
3906 if let Err((line, msg)) = preflight::check_syntax("output", src) {
3907 let line = line.map(|l| format!(" (line {l})")).unwrap_or_default();
3908 err(self, format!("{what} doesn't compile{line}: {msg}"));
3909 continue;
3910 }
3911 for (name, _) in preflight::attributes(src, "results") {
3912 if !fed.contains(&name.as_str()) {
3913 err(
3914 self,
3915 format!(
3916 "{what} reads `results.{name}`, but `{name}` isn't one of this output's queries ({})",
3917 if fed.is_empty() {
3918 "none".to_string()
3919 } else {
3920 fed.join(", ")
3921 }
3922 ),
3923 );
3924 }
3925 }
3926 if !o.is_message() && !preflight::attributes(src, "outputs").is_empty() {
3927 err(
3928 self,
3929 format!(
3930 "{what} reads `outputs.*`, but a file output's `when` is decided before anything is delivered; use `outputs.*` in a message output"
3931 ),
3932 );
3933 continue;
3934 }
3935 for (name, _) in preflight::attributes(src, "outputs") {
3936 let other = all.iter().find(|x| x.name.as_deref() == Some(name.as_str()));
3937 match other {
3938 None => err(
3939 self,
3940 format!(
3941 "{what} reads `outputs.{name}`, but no output of this report is named `{name}`"
3942 ),
3943 ),
3944 Some(x) if std::ptr::eq(x, o) => err(
3945 self,
3946 format!("{what} reads `outputs.{name}`, which is this output itself"),
3947 ),
3948 Some(_) => {}
3949 }
3950 }
3951 }
3952 }
3953
3954 fn typed_output(
3956 &mut self,
3957 m: &Mapping,
3958 ctx: &str,
3959 file: &Path,
3960 queries: &[QueryEntry],
3961 used: &mut Usage,
3962 ) -> Output {
3963 let file_path = file.to_path_buf();
3964 let file = Some(file_path.clone());
3965 let node = crate::yaml::to_node(&Value::Object(m.clone()));
3966 let o: OutputConfig = match de::from_node(&node) {
3967 Ok(o) => o,
3968 Err(e) => unreachable!("an output map reads as an output: {e}"),
3970 };
3971 let format = o
3972 .format
3973 .as_ref()
3974 .and_then(Loose::ok)
3975 .cloned()
3976 .unwrap_or_else(|| "csv".to_string());
3977 if format != MESSAGE_FORMAT {
3979 used.format(&format, ctx, &file_path);
3980 }
3981 let mut opts: JsonMap<String, Json> = o.options.0.into_iter().map(|(k, v)| (k.value, v)).collect();
3982 if let Some(Value::Object(d)) = self.format_options.get(format.as_str()) {
3984 for (k, v) in d {
3985 if !OUTPUT_SHARED_KEYS.contains(&k.as_str()) {
3986 opts.entry(k.clone()).or_insert_with(|| v.clone());
3987 }
3988 }
3989 }
3990 let destinations = match o.destination {
3991 None => Vec::new(),
3992 Some(Loose::Ok(de::OneOf::A(d))) => {
3993 self.typed_destination(d, ctx, &file, used).into_iter().collect()
3994 }
3995 Some(Loose::Ok(de::OneOf::B(list))) if list.is_empty() => {
3996 self.diags.error(
3997 Code::InvalidField,
3998 file.clone(),
3999 None,
4000 format!(
4001 "{ctx}: `output.destination` is an empty list; name at least one destination, or remove it to keep the output in target/"
4002 ),
4003 );
4004 Vec::new()
4005 }
4006 Some(Loose::Ok(de::OneOf::B(list))) => {
4007 let mut out = Vec::new();
4008 for (i, d) in list.into_iter().enumerate() {
4009 match d {
4010 Loose::Ok(d) => out.extend(self.typed_destination(d, ctx, &file, used)),
4011 Loose::Bad(_) => self.diags.error(
4012 Code::InvalidField,
4013 file.clone(),
4014 None,
4015 format!("{ctx}: `output.destination` entry {} must be a map", i + 1),
4016 ),
4017 }
4018 }
4019 out
4020 }
4021 Some(Loose::Bad(_)) => {
4022 self.diags.error(
4023 Code::InvalidField,
4024 file.clone(),
4025 None,
4026 format!("{ctx}: `output.destination` must be a map or a list of maps"),
4027 );
4028 Vec::new()
4029 }
4030 };
4031 let template = match o.template {
4032 None => None,
4033 Some(t) => {
4034 if format != "xlsx" {
4035 self.diags.error(
4036 Code::InvalidOutputOption,
4037 file.clone(),
4038 None,
4039 format!("{ctx}: `output.template` only applies to the xlsx format"),
4040 );
4041 }
4042 self.typed_template(t, ctx, &file, queries)
4043 }
4044 };
4045 let extension = match o.extension {
4046 de::Maybe::Absent => None,
4047 de::Maybe::Given(Loose::Ok(None | Some(de::OneOf::B(false)))) => Some(String::new()),
4048 de::Maybe::Given(Loose::Ok(Some(de::OneOf::A(e)))) => {
4049 let e = e.strip_prefix('.').unwrap_or(&e).to_string();
4050 if e.contains(['/', '\\']) || e.chars().any(char::is_whitespace) {
4051 self.diags.error(
4052 Code::InvalidOutputOption,
4053 file.clone(),
4054 None,
4055 format!(
4056 "{ctx}: `extension` must be a file extension like `aba` (no path, no spaces)"
4057 ),
4058 );
4059 }
4060 Some(e)
4061 }
4062 de::Maybe::Given(_) => {
4063 self.diags.error(
4064 Code::InvalidOutputOption,
4065 file.clone(),
4066 None,
4067 format!("{ctx}: `extension` must be a string (`aba`), or `\"\"`/`false` for none"),
4068 );
4069 None
4070 }
4071 };
4072 let name = match o.name {
4073 None => None,
4074 Some(Loose::Ok(n)) if is_identifier(&n) => Some(n),
4075 Some(_) => {
4076 self.diags.error(
4077 Code::InvalidField,
4078 file.clone(),
4079 None,
4080 format!("{ctx}: output `name` must be an identifier (letters, digits, `_`)"),
4081 );
4082 None
4083 }
4084 };
4085 let when = match o.when {
4086 None => None,
4087 Some(Loose::Ok(de::OneOf::A(w))) => Some(w),
4088 Some(Loose::Ok(de::OneOf::B(b))) => Some(b.to_string()),
4089 Some(Loose::Bad(_)) => {
4090 self.diags.error(
4091 Code::InvalidField,
4092 file.clone(),
4093 None,
4094 format!("{ctx}: `when` must be a Jinja expression (a string)"),
4095 );
4096 None
4097 }
4098 };
4099 let queries = match &o.queries {
4100 Some(Loose::Ok(_)) => Some(queries.iter().map(|q| q.query.clone()).collect()),
4101 _ => None,
4102 };
4103 if extension.is_some() && format == "xlsx" {
4104 self.diags.error(
4105 Code::InvalidOutputOption,
4106 file.clone(),
4107 None,
4108 format!("{ctx}: `extension` doesn't apply to xlsx (Excel only opens .xlsx workbooks)"),
4109 );
4110 }
4111 Output {
4112 name,
4113 format,
4114 queries,
4115 when,
4116 options: opts,
4117 destinations,
4118 template,
4119 extension,
4120 }
4121 }
4122
4123 fn typed_destination(
4125 &mut self,
4126 d: config::report::Destination,
4127 ctx: &str,
4128 file: &Option<PathBuf>,
4129 used: &mut Usage,
4130 ) -> Option<Destination> {
4131 let Some(Loose::Ok(p)) = d.profile else {
4132 self.diags.error(
4133 Code::InvalidField,
4134 file.clone(),
4135 None,
4136 format!("{ctx}: `output.destination` needs a `profile:` naming a profiles.yml entry"),
4137 );
4138 return None;
4139 };
4140 let path = match d.path {
4141 None => None,
4142 Some(Loose::Ok(s)) => Some(s),
4143 Some(Loose::Bad(_)) => {
4144 self.diags.error(
4145 Code::InvalidField,
4146 file.clone(),
4147 None,
4148 format!("{ctx}: destination `{p}`: `path` must be a string"),
4149 );
4150 None
4151 }
4152 };
4153 used.destination(&p, file.clone(), None);
4154 let options = d.options.0.into_iter().map(|(k, v)| (k.value, v)).collect();
4155 let attach = match d.attach {
4156 None => Vec::new(),
4157 Some(Loose::Ok(de::OneOf::A(s))) => vec![s],
4158 Some(Loose::Ok(de::OneOf::B(list))) => list,
4159 Some(Loose::Bad(_)) => {
4160 self.diags.error(
4161 Code::InvalidField,
4162 file.clone(),
4163 None,
4164 format!("{ctx}: destination `{p}`: `attach` must be an output name or a list of them"),
4165 );
4166 Vec::new()
4167 }
4168 };
4169 Some(Destination {
4170 profile: p,
4171 path,
4172 options,
4173 attach,
4174 })
4175 }
4176
4177 fn typed_template(
4178 &mut self,
4179 t: Loose<config::report::Template>,
4180 ctx: &str,
4181 file: &Option<PathBuf>,
4182 queries: &[QueryEntry],
4183 ) -> Option<Template> {
4184 let err = |s: &mut Self, msg: String| {
4185 s.diags
4186 .error(Code::InvalidTemplate, file.clone(), None, format!("{ctx}: {msg}"))
4187 };
4188 let Some(Loose::Ok(tf)) = t.ok().and_then(|t| t.file.clone()) else {
4189 err(self, "`output.template` needs a `file:`".into());
4190 return None;
4191 };
4192 let Loose::Ok(t) = t else {
4193 unreachable!("checked above")
4194 };
4195 let mut bindings = Vec::new();
4196 let items = match t.bindings {
4197 None => Vec::new(),
4198 Some(Loose::Ok(s)) => s,
4199 Some(Loose::Bad(_)) => {
4200 err(self, "`output.template.bindings` must be a list".into());
4201 Vec::new()
4202 }
4203 };
4204 let names: Vec<&str> = queries.iter().map(|q| q.query.as_str()).collect();
4205 for (i, b) in items.into_iter().enumerate() {
4206 let n = i + 1;
4207 let Loose::Ok(m) = b else {
4208 err(self, format!("template binding {n} must be a map"));
4209 continue;
4210 };
4211 let s = |v: &Option<Loose<String>>| v.as_ref().and_then(Loose::ok).cloned();
4212 for k in &m.unknown.0 {
4213 err(self, format!("template binding {n} has unknown key `{}`", k.name));
4214 }
4215 let Some(sheet) = s(&m.sheet) else {
4216 err(self, format!("template binding {n} needs a `sheet:`"));
4217 continue;
4218 };
4219 let tb = TemplateBinding {
4220 sheet,
4221 query: s(&m.query),
4222 result_index: m.result_index.as_ref().and_then(Loose::ok).map(|x| *x as usize),
4223 anchor: s(&m.anchor),
4224 header: m.header.as_ref().and_then(Loose::ok).copied(),
4225 columns: m.columns.as_ref().and_then(Loose::ok).cloned(),
4226 cell: s(&m.cell),
4227 value: s(&m.value),
4228 column: s(&m.column),
4229 };
4230 if let Some(q) = &tb.query
4231 && !names.contains(&q.as_str())
4232 {
4233 err(
4234 self,
4235 format!(
4236 "template binding {n} uses query `{q}`, which isn't one of this Binding's queries"
4237 ),
4238 );
4239 }
4240 if let Some(ri) = &m.result_index
4241 && ri.ok().is_none_or(|x| *x == 0)
4242 {
4243 err(
4244 self,
4245 format!("template binding {n}: `result_index` must be 1 or more"),
4246 );
4247 }
4248 match &tb.cell {
4249 Some(c) => {
4250 if !options::is_cell(c) {
4251 err(
4252 self,
4253 format!("template binding {n}: `cell` `{c}` isn't a valid cell reference"),
4254 );
4255 }
4256 let by_value = tb.value.is_some();
4257 let by_query = tb.query.is_some() && tb.column.is_some();
4258 if by_value == by_query || (tb.query.is_some() != tb.column.is_some()) {
4259 err(
4260 self,
4261 format!(
4262 "template binding {n}: a single-cell binding needs exactly one of `value` or `query` + `column`"
4263 ),
4264 );
4265 }
4266 if tb.anchor.is_some() || tb.columns.is_some() {
4267 err(
4268 self,
4269 format!(
4270 "template binding {n}: `anchor`/`columns` apply to table blocks, not single cells"
4271 ),
4272 );
4273 }
4274 }
4275 None => {
4276 if tb.query.is_none() {
4277 err(
4278 self,
4279 format!(
4280 "template binding {n}: a table block needs a `query` (or use `cell` for a single cell)"
4281 ),
4282 );
4283 }
4284 if let Some(a) = &tb.anchor
4285 && !options::is_cell(a)
4286 {
4287 err(
4288 self,
4289 format!("template binding {n}: `anchor` `{a}` isn't a valid cell reference"),
4290 );
4291 }
4292 if tb.value.is_some() || tb.column.is_some() {
4293 err(
4294 self,
4295 format!(
4296 "template binding {n}: `value`/`column` apply to single-cell bindings (add `cell`)"
4297 ),
4298 );
4299 }
4300 }
4301 }
4302 bindings.push(tb);
4303 }
4304 Some(Template { file: tf, bindings })
4305 }
4306
4307 fn resolve_unmanaged(
4310 &mut self,
4311 name: &str,
4312 path: &Path,
4313 project: &Project,
4314 folders: &BTreeMap<Vec<String>, FolderCfg>,
4315 used: &mut Usage,
4316 ) -> Report {
4317 let folder = folder_segments(path.parent().unwrap_or(Path::new("")));
4318 let layers = folder_layers(folders, &folder);
4319 let mut tags: Vec<String> = layers.iter().flat_map(|l| l.tags.clone()).collect();
4320 dedup(&mut tags);
4321 let profile = layers
4322 .iter()
4323 .rev()
4324 .find_map(|l| l.profile.as_ref().map(|p| p.0.clone()))
4325 .or(project.default_profile.clone());
4326 let mut vars = project.vars.clone();
4327 for l in &layers {
4328 if let Some(v) = &l.vars {
4329 vars.extend(v.clone());
4330 }
4331 }
4332 let mut output = builtin_output();
4333 let mut merge_problems = Vec::new();
4334 if let Some(o) = project_default_output(project) {
4335 merge_problems.extend(merge_output(&mut output, &o));
4336 }
4337 for l in &layers {
4338 if let Some(o) = &l.output {
4339 merge_problems.extend(merge_output(&mut output, o));
4340 }
4341 }
4342 for p in merge_problems {
4343 self.diags.error(
4344 Code::InvalidField,
4345 Some(path.to_path_buf()),
4346 None,
4347 format!("folder config: {p}"),
4348 );
4349 }
4350 self.diags.warning(
4351 Code::UnmanagedReport,
4352 Some(path.to_path_buf()),
4353 None,
4354 format!(
4355 "`{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"
4356 ),
4357 );
4358 self.check_unmanaged_sql(name, path);
4359 let query = QueryEntry {
4360 query: name.to_string(),
4361 path: path.to_path_buf(),
4362 profile: None,
4363 tab: true,
4364 tab_name: None,
4365 anchor: None,
4366 header: None,
4367 columns: BTreeMap::new(),
4368 autofit: None,
4369 style: None,
4370 };
4371 let base = BindingBase {
4372 profile: profile.clone(),
4373 profile_at: inherited_profile_at(&layers, project),
4374 vars,
4375 outputs: vec![output],
4376 locale: layers
4377 .iter()
4378 .rev()
4379 .find_map(|l| l.locale.clone())
4380 .or_else(|| project.locale.clone()),
4381 };
4382 if let Some(p) = &profile {
4383 used.connection(p, None, None);
4384 }
4385 let b = self.finish_binding(
4386 name,
4387 None,
4388 &base,
4389 None,
4390 vec![query.clone()],
4391 path.to_path_buf(),
4392 used,
4393 );
4394 Report {
4395 name: name.to_string(),
4396 description: None,
4397 meta: JsonMap::new(),
4398 meta_layers: metadata_layers(project, &layers),
4399 var_schema: BTreeMap::new(),
4400 managed: false,
4401 file: path.to_path_buf(),
4402 folder,
4403 tags,
4404 queries: vec![query],
4405 default_set: None,
4406 timezone: layers
4407 .iter()
4408 .rev()
4409 .find_map(|l| l.timezone.clone())
4410 .or_else(|| project.timezone.clone()),
4411 has_sets: false,
4412 base: b.clone(),
4413 bindings: vec![b],
4414 }
4415 }
4416
4417 fn check_unmanaged_sql(&mut self, name: &str, path: &Path) {
4419 let Ok(text) = std::fs::read_to_string(self.root.join(path)) else {
4420 return;
4421 };
4422 let head = sqlsplit::strip_leading_comments(&text);
4423 if head.starts_with("{{") || head.starts_with("{%") || head.starts_with("{#") {
4424 return;
4425 }
4426 for st in sqlsplit::split(&text) {
4427 if !sqlsplit::classify(&st.text).is_read_only_safe() {
4428 let body = sqlsplit::strip_leading_comments(&st.text);
4429 let line = st.line + st.text[..st.text.len() - body.len()].matches('\n').count();
4430 self.diags.error(
4431 Code::UnmanagedSideEffect,
4432 Some(path.to_path_buf()),
4433 Some(line),
4434 format!(
4435 "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",
4436 dre_protocol::util::summarize(body, 60)
4437 ),
4438 );
4439 }
4440 }
4441 }
4442
4443 fn moved_to_schedules(&mut self, file: &Path, line: Option<usize>, ctx: &str) {
4447 self.diags.error(
4448 Code::ScheduleMoved,
4449 Some(file.to_path_buf()),
4450 line,
4451 format!(
4452 "{ctx} is no longer supported; declare schedules in schedules.yml as named entries (`name`, `report:`/`select:`, `cron`/`every`/`rrule`, optional `vars`)"
4453 ),
4454 );
4455 }
4456
4457 fn parse_timings(&mut self, files: &[Rc<YamlFile>]) -> (BTreeMap<String, Timing>, BTreeSet<String>) {
4460 let mut out = BTreeMap::new();
4461 let mut broken = BTreeSet::new();
4462 let mut seen: BTreeMap<String, String> = BTreeMap::new();
4463 for yf in files {
4464 let Ok(Loose::Ok(timings)) = de::from_node::<Loose<config::schedule::TimingsFile>>(&yf.node)
4465 else {
4466 continue;
4467 };
4468 for (k, v) in timings.0.0 {
4469 let file = Some(yf.display.clone());
4470 let line = k.line();
4471 let name = k.value;
4472 let mut ok = true;
4473 if !is_identifier(&name) {
4474 self.diags.error(
4475 Code::InvalidTiming,
4476 file.clone(),
4477 line,
4478 format!(
4479 "timing name `{name}` must be letters, digits and `_`, not starting with a digit"
4480 ),
4481 );
4482 ok = false;
4483 }
4484 if let Some(prev) = seen.get(&name) {
4485 self.diags.error(
4486 Code::DuplicateTimingName,
4487 file.clone(),
4488 line,
4489 format!("timing `{name}` is already declared at {prev}; timing names must be unique"),
4490 );
4491 continue;
4492 }
4493 seen.insert(
4494 name.clone(),
4495 format!("{}:{}", yf.display.display(), line.unwrap_or(0)),
4496 );
4497 let Loose::Ok(t) = v else {
4498 self.diags.error(
4499 Code::InvalidTiming,
4500 file.clone(),
4501 line,
4502 format!("timing `{name}` must be a map, e.g. `{{cron: \"0 6 1 * *\", timezone: Australia/Sydney}}`"),
4503 );
4504 broken.insert(name);
4505 continue;
4506 };
4507 for k in &t.unknown.0 {
4508 self.diags.error(
4509 Code::InvalidTiming,
4510 file.clone(),
4511 line,
4512 format!("timing `{name}`: unknown key `{}`", k.name),
4513 );
4514 ok = false;
4515 }
4516 let block = t.block();
4517 let shape = schedule::validate_block(&block, "a timing", "`cron`, `every` or `rrule`");
4518 for e in &shape {
4519 self.diags.error(
4520 Code::InvalidTiming,
4521 file.clone(),
4522 line,
4523 format!("timing `{name}`: {e}"),
4524 );
4525 ok = false;
4526 }
4527 let timezone = match &t.timezone {
4528 None => None,
4529 Some(v) => {
4530 let tz = self.timezone_str(
4531 v.ok().map(String::as_str),
4532 &yf.display,
4533 line,
4534 &format!("timing `{name}`: `timezone`"),
4535 );
4536 ok &= tz.is_some();
4537 tz
4538 }
4539 };
4540 if shape.is_empty() {
4541 for (code, msg) in schedule::strictness(&block) {
4542 self.diags
4543 .error(code, file.clone(), line, format!("timing `{name}`: {msg}"));
4544 }
4545 if let Some(msg) = schedule::no_time(&block) {
4546 self.diags.warning(
4547 Code::ScheduleNoTime,
4548 file.clone(),
4549 line,
4550 format!("timing `{name}`: {msg}"),
4551 );
4552 }
4553 }
4554 if ok {
4555 out.insert(
4556 name,
4557 Timing {
4558 schedule: block,
4559 timezone,
4560 location: (yf.display.clone(), line),
4561 },
4562 );
4563 } else {
4564 broken.insert(name);
4565 }
4566 }
4567 }
4568 (out, broken)
4569 }
4570
4571 fn parse_schedules(
4572 &mut self,
4573 files: &[Rc<YamlFile>],
4574 project: &Project,
4575 broken_timings: &BTreeSet<String>,
4576 ) -> Vec<ScheduleEntry> {
4577 let mut out = Vec::new();
4578 let mut used_timings = BTreeSet::new();
4579 let mut seen: BTreeMap<String, String> = BTreeMap::new();
4580 for yf in files {
4581 let Ok(Loose::Ok(items)) = de::from_node::<Loose<config::schedule::SchedulesFile>>(&yf.node)
4582 else {
4583 continue;
4584 };
4585 for item in &items.0 {
4586 let Loose::Ok(m) = &item.value else { continue };
4588 let line = item.line();
4589 let file = Some(yf.display.clone());
4590 let s = |v: &Option<Loose<String>>| v.as_ref().and_then(Loose::ok).cloned();
4591 let (select, report, set) = (s(&m.select), s(&m.report), s(&m.set));
4592 let mut ok = true;
4593 let name = match &m.name {
4594 Some(Loose::Ok(n)) if is_identifier(n) => {
4595 if let Some(prev) = seen.get(n) {
4596 self.diags.error(
4597 Code::DuplicateScheduleName,
4598 file.clone(),
4599 line,
4600 format!("schedule `{n}` is already declared at {prev}; schedule names must be unique"),
4601 );
4602 ok = false;
4603 }
4604 seen.insert(
4605 n.clone(),
4606 format!("{}:{}", yf.display.display(), line.unwrap_or(0)),
4607 );
4608 n.clone()
4609 }
4610 Some(Loose::Ok(n)) => {
4611 self.diags.error(
4612 Code::InvalidSchedule,
4613 file.clone(),
4614 line,
4615 format!("schedule name `{n}` must be letters, digits and `_`, not starting with a digit"),
4616 );
4617 ok = false;
4618 n.clone()
4619 }
4620 _ => {
4621 self.diags.error(
4622 Code::InvalidSchedule,
4623 file.clone(),
4624 line,
4625 "every schedule needs a `name`",
4626 );
4627 ok = false;
4628 String::new()
4629 }
4630 };
4631 let vars = match &m.vars {
4632 None => JsonMap::new(),
4633 Some(Loose::Ok(v)) => v.clone(),
4634 Some(Loose::Bad(_)) => {
4635 self.diags.error(
4636 Code::InvalidSchedule,
4637 file.clone(),
4638 line,
4639 format!("schedule `{name}`: `vars` must be a map"),
4640 );
4641 ok = false;
4642 JsonMap::new()
4643 }
4644 };
4645 for k in &m.unknown.0 {
4646 self.diags.error(
4647 Code::InvalidSchedule,
4648 file.clone(),
4649 line,
4650 format!("unknown schedule key `{}`", k.name),
4651 );
4652 }
4653 let sched = m.block();
4654 let timing = match &m.timing {
4655 None => None,
4656 Some(Loose::Ok(t)) => {
4657 used_timings.insert(t.clone());
4658 Some(t.clone())
4659 }
4660 Some(Loose::Bad(_)) => {
4661 self.diags.error(
4662 Code::InvalidSchedule,
4663 file.clone(),
4664 line,
4665 format!(
4666 "schedule `{name}`: `timing` must be the name of a timing in timings.yml"
4667 ),
4668 );
4669 ok = false;
4670 None
4671 }
4672 };
4673 let mut resolved = None;
4674 let shape = if let Some(t) = &timing {
4675 let mut errs = Vec::new();
4676 let own: Vec<String> = m.timing_keys().into_iter().map(|k| format!("`{k}`")).collect();
4677 if !own.is_empty() {
4678 let keys = match own.split_last() {
4679 Some((last, [])) => last.clone(),
4680 Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
4681 None => String::new(),
4682 };
4683 errs.push(format!(
4684 "schedule `{name}` uses timing `{t}`, so it can't set {keys} too; change the timing, or give the schedule its own timing instead"
4685 ));
4686 }
4687 match project.timings.get(t) {
4688 Some(def) => resolved = Some(def.schedule.clone()),
4689 None if broken_timings.contains(t) => ok = false,
4690 None => {
4691 let names: Vec<&str> = project.timings.keys().map(String::as_str).collect();
4692 let valid = if names.is_empty() {
4693 "the project has no timings.yml entries".to_string()
4694 } else {
4695 format!("valid names: {}", names.join(", "))
4696 };
4697 self.diags.error(
4698 Code::UnknownTiming,
4699 file.clone(),
4700 line,
4701 format!("schedule `{name}`: no timing `{t}`; {valid}"),
4702 );
4703 ok = false;
4704 }
4705 }
4706 errs
4707 } else {
4708 schedule::validate_block(&sched, "a schedule", "`timing`, `cron`, `every` or `rrule`")
4709 };
4710 if shape.is_empty() && timing.is_none() {
4711 let block = &sched;
4712 for (code, msg) in schedule::strictness(block) {
4713 self.diags
4714 .error(code, file.clone(), line, format!("schedule `{name}`: {msg}"));
4715 }
4716 if let Some(msg) = schedule::no_time(block) {
4717 self.diags.warning(
4718 Code::ScheduleNoTime,
4719 file.clone(),
4720 line,
4721 format!("schedule `{name}`: {msg}"),
4722 );
4723 }
4724 }
4725 for e in shape {
4726 self.diags.error(Code::InvalidSchedule, file.clone(), line, e);
4727 ok = false;
4728 }
4729 if select.is_some() && report.is_some() {
4730 self.diags.error(
4731 Code::InvalidSchedule,
4732 file.clone(),
4733 line,
4734 "use either `select:` or `report:`, not both",
4735 );
4736 ok = false;
4737 }
4738 if select.is_some() && set.is_some() {
4739 self.diags.error(
4740 Code::InvalidSchedule,
4741 file.clone(),
4742 line,
4743 "`set:` only applies with `report:`",
4744 );
4745 ok = false;
4746 }
4747 if let Some(sel) = &select {
4748 match selector::resolve(project, sel) {
4749 Ok(r) if r.is_empty() => {
4750 self.diags.error(
4751 Code::SelectorMatchesNothing,
4752 file.clone(),
4753 line,
4754 format!("selector `{sel}` matches no report"),
4755 );
4756 ok = false;
4757 }
4758 Ok(_) => {}
4759 Err(e) => {
4760 self.diags.error(e.code(), file.clone(), line, e.to_string());
4761 ok = false;
4762 }
4763 }
4764 }
4765 if let Some(rn) = &report {
4766 match project.report(rn) {
4767 None => {
4768 self.diags.error(
4769 Code::SelectorMatchesNothing,
4770 file.clone(),
4771 line,
4772 format!("report `{rn}` doesn't exist"),
4773 );
4774 ok = false;
4775 }
4776 Some(r) => {
4777 if let Some(sn) = &set
4778 && r.binding(sn).is_none()
4779 {
4780 self.diags.error(
4781 Code::SelectorMatchesNothing,
4782 file.clone(),
4783 line,
4784 format!("report `{rn}` has no Set `{sn}`"),
4785 );
4786 ok = false;
4787 }
4788 }
4789 }
4790 }
4791 if select.is_none() && report.is_none() {
4792 self.diags.error(
4793 Code::InvalidSchedule,
4794 file.clone(),
4795 line,
4796 format!("schedule `{name}` needs `report:` (optionally with `set:`) or `select:`"),
4797 );
4798 ok = false;
4799 }
4800 let timezone = match (&m.timezone, &timing) {
4801 (None, Some(t)) => project.timings.get(t).and_then(|d| d.timezone.clone()),
4802 (None, None) => None,
4803 (Some(v), _) => {
4804 let t = self.timezone_str(
4805 v.value.ok().map(String::as_str),
4806 &yf.display,
4807 line,
4808 &format!("schedule `{name}`: `timezone`"),
4809 );
4810 ok &= t.is_some();
4811 t
4812 }
4813 };
4814 let enabled = match &m.enabled {
4815 None => true,
4816 Some(Loose::Ok(b)) => *b,
4817 Some(Loose::Bad(_)) => {
4818 self.diags.error(
4819 Code::InvalidSchedule,
4820 file.clone(),
4821 line,
4822 format!("schedule `{name}`: `enabled` must be true or false"),
4823 );
4824 ok = false;
4825 true
4826 }
4827 };
4828 if ok {
4829 out.push(ScheduleEntry {
4830 name,
4831 select,
4832 report,
4833 set,
4834 schedule: resolved.unwrap_or(sched),
4835 timing,
4836 enabled,
4837 vars,
4838 timezone,
4839 location: (yf.display.clone(), line),
4840 });
4841 }
4842 }
4843 }
4844 for (name, t) in &project.timings {
4845 if !used_timings.contains(name) {
4846 self.diags.warning(
4847 Code::UnusedTiming,
4848 Some(t.location.0.clone()),
4849 t.location.1,
4850 format!("timing `{name}` isn't used by any schedule"),
4851 );
4852 }
4853 }
4854 out
4855 }
4856
4857 fn check_schedule_paths(&mut self, project: &Project) {
4861 let date = chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
4862 for report in &project.reports {
4863 for b in report.bindings.iter().filter(|b| b.schedules.len() > 1) {
4864 let paths: Vec<&String> = b.destinations().filter_map(|d| d.path.as_ref()).collect();
4865 if paths.is_empty() {
4866 continue;
4867 }
4868 let mut rendered: Vec<(&String, Vec<String>)> = Vec::new();
4869 for name in &b.schedules {
4870 let Some(e) = project.schedules.iter().find(|e| &e.name == name) else {
4871 continue;
4872 };
4873 let mut vars = b.vars.clone();
4874 vars.extend(e.vars.clone());
4875 let Ok(r) = crate::render::Renderer::new(crate::render::RendererConfig {
4876 root: &project.root,
4877 macros: &project.macros,
4878 context: crate::render::RunContext {
4879 report: report.name.clone(),
4880 set: b.set.clone(),
4881 target: project.target_name.clone(),
4882 schedule: Some(name.clone()),
4883 date,
4884 now: chrono::Utc::now(),
4885 scheduled_at: None,
4886 calendar: crate::dates::Calendar::default(),
4887 locale: Default::default(),
4888 },
4889 vars,
4890 cli_vars: self.opts.vars.clone(),
4891 runner: None,
4892 connections: None,
4893 mode: crate::render::Mode::Run,
4894 connection: None,
4895 source_type: String::new(),
4896 sources: None,
4897 run_query_max_rows: project.run_query_max_rows,
4898 sql: project.sql.clone(),
4899 lookups: project.lookups.clone(),
4900 lookup_inline_max_rows: project.lookup_inline_max_rows,
4901 packages: project.packages.clone(),
4902 project_name: project.name.clone(),
4903 dispatch: project.dispatch.clone(),
4904 }) else {
4905 continue;
4906 };
4907 let out: Result<Vec<String>, _> =
4908 paths.iter().map(|p| r.render(&report.file, p)).collect();
4909 if let Ok(out) = out {
4910 rendered.push((name, out));
4911 }
4912 }
4913 for (i, (a, pa)) in rendered.iter().enumerate() {
4914 for (bn, pb) in &rendered[i + 1..] {
4915 if let Some(same) = pa.iter().find(|p| pb.contains(p)) {
4916 self.diags.warning(
4917 Code::SchedulePathClash,
4918 Some(report.file.clone()),
4919 None,
4920 format!(
4921 "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",
4922 report.name,
4923 b.set.as_ref().map(|s| format!(", Set `{s}`,")).unwrap_or_default()
4924 ),
4925 );
4926 }
4927 }
4928 }
4929 }
4930 }
4931 }
4932
4933 fn apply_schedules(&mut self, project: &mut Project) {
4937 let entries = project.schedules.clone();
4938 for e in &entries {
4939 let reports: Vec<String> = match (&e.select, &e.report) {
4940 (Some(sel), _) => selector::resolve(project, sel)
4941 .map(|r| r.into_iter().map(|r| r.name.clone()).collect())
4942 .unwrap_or_default(),
4943 (_, Some(r)) => vec![r.clone()],
4944 _ => Vec::new(),
4945 };
4946 for report in project.reports.iter_mut().filter(|r| reports.contains(&r.name)) {
4947 for b in &mut report.bindings {
4948 if e.set.is_none() || e.set == b.set {
4949 b.schedules.push(e.name.clone());
4950 }
4951 }
4952 }
4953 }
4954 self.check_schedule_paths(project);
4955 self.check_schedule_timezones(project);
4956 }
4957
4958 fn check_schedule_timezones(&mut self, project: &Project) {
4961 for e in project.schedules.iter().filter(|e| e.timezone.is_none()) {
4962 let fires = crate::occurrences::firing_tz(project, e);
4963 let mut seen = BTreeSet::new();
4964 for (report, _) in crate::occurrences::bindings(project, &e.name) {
4965 let Some(r) = project.report(report) else { continue };
4966 let renders = r
4967 .timezone
4968 .as_ref()
4969 .and_then(|t| crate::dates::parse_tz(t).ok())
4970 .unwrap_or(chrono_tz::Tz::UTC);
4971 if renders != fires && seen.insert(report) {
4972 self.diags.warning(
4973 Code::ScheduleTimezoneMismatch,
4974 Some(e.location.0.clone()),
4975 e.location.1,
4976 format!(
4977 "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",
4978 e.name,
4979 fires.name(),
4980 renders.name(),
4981 renders.name()
4982 ),
4983 );
4984 }
4985 }
4986 }
4987 }
4988
4989 fn check_profiles(&mut self, project: &Project, used: &Usage) {
4992 let profiles = &project.profiles;
4993 if used.connections.is_empty() && used.destinations.is_empty() {
4994 return;
4995 }
4996 if !profiles.exists() {
4997 self.diags.error(
4998 Code::ProfilesMissing,
4999 None,
5000 None,
5001 if profiles.found_by == "~/.dre" {
5002 format!(
5003 "the project references profiles, but no profiles.yml was found in the project directory or at {}",
5004 profiles.path.display()
5005 )
5006 } else {
5007 format!(
5008 "the project references profiles, but no profiles.yml was found at {} (from {})",
5009 profiles.path.display(),
5010 profiles.found_by
5011 )
5012 },
5013 );
5014 return;
5015 }
5016 for (role, refs) in [
5017 (Role::Connection, &used.connections),
5018 (Role::Destination, &used.destinations),
5019 ] {
5020 for (name, (file, line)) in refs {
5021 if role == Role::Destination && name == LOCAL_TYPE {
5022 continue;
5023 }
5024 if !profiles.declares(role, name) {
5025 self.diags.error(
5026 Code::UnknownProfile,
5027 file.clone(),
5028 *line,
5029 format!(
5030 "{} profile `{name}` isn't defined under `{}:` in {}",
5031 role.as_str(),
5032 profiles.section_key(role),
5033 profiles.path.display()
5034 ),
5035 );
5036 }
5037 }
5038 }
5039 }
5040
5041 fn plugin_entry(
5043 &mut self,
5044 m: &config::dependencies::PluginEntry,
5045 file: &Path,
5046 line: Option<usize>,
5047 ) -> Option<(String, Option<Value>, PluginSource)> {
5048 let file = Some(file.to_path_buf());
5049 let name = m.name.ok().cloned().unwrap_or_default();
5050 let err = |s: &mut Self, msg: String| {
5051 s.diags.error(
5052 Code::InvalidPluginDeclaration,
5053 file.clone(),
5054 line,
5055 format!("plugin package `{name}`: {msg}"),
5056 );
5057 };
5058 if name.is_empty() {
5059 err(self, "`name` must be a non-empty string".into());
5060 return None;
5061 }
5062 if let Some(k) = m.unknown.0.first() {
5063 err(
5064 self,
5065 format!(
5066 "unknown key `{}`; use `name`, `version` and one of `github`, `local`, `registry`",
5067 k.name
5068 ),
5069 );
5070 return None;
5071 }
5072 let given: Vec<(&str, &str)> = [
5073 ("github", &m.github),
5074 ("local", &m.local),
5075 ("registry", &m.registry),
5076 ]
5077 .into_iter()
5078 .filter_map(|(k, v)| v.as_ref().map(|v| (k, v.as_str().unwrap_or(""))))
5079 .collect();
5080 let source = match given.as_slice() {
5081 [] => PluginSource::Default,
5082 [(k, "")] => {
5083 err(self, format!("`{k}` must be a non-empty string"));
5084 return None;
5085 }
5086 [("github", r)] => {
5087 let ok = r.split('/').count() == 2 && r.split('/').all(|p| !p.is_empty());
5088 if !ok {
5089 err(self, format!("`github: {r}` must be `owner/repo`"));
5090 return None;
5091 }
5092 PluginSource::Github(r.to_string())
5093 }
5094 [("local", p)] => {
5095 if m.version.is_some() {
5096 err(
5097 self,
5098 "a `local` package has no `version`: it's used as it is".into(),
5099 );
5100 return None;
5101 }
5102 PluginSource::Local(p.to_string())
5103 }
5104 [(_, u)] => PluginSource::Registry(u.to_string()),
5105 _ => {
5106 err(self, "give only one of `github`, `local` and `registry`".into());
5107 return None;
5108 }
5109 };
5110 Some((name, m.version.clone(), source))
5111 }
5112
5113 fn check_plugins(&mut self, decls: &[(Rc<YamlFile>, Mapping)], project: &mut Project, used: &Usage) {
5116 struct Decl {
5117 req: semver::VersionReq,
5118 raw: String,
5119 file: PathBuf,
5120 source: PluginSource,
5121 }
5122 let mut by_package: BTreeMap<String, Vec<Decl>> = BTreeMap::new();
5123 for (yf, _) in decls {
5124 {
5125 let Ok(config::dependencies::PluginsKey { plugins: Some(v) }) = de::from_node(&yf.node)
5126 else {
5127 continue;
5128 };
5129 let line = v.line();
5130 let file = Some(yf.display.clone());
5131 let entries: Vec<(String, Option<Value>, PluginSource, Option<usize>)> = match v.value {
5132 Loose::Ok(de::OneOf::A(items)) => items
5133 .into_iter()
5134 .filter_map(|i| {
5135 let iline = i.line();
5136 match i.value {
5137 Loose::Ok(de::OneOf::A(name)) => Some((name.0, None, PluginSource::Default, iline)),
5138 Loose::Ok(de::OneOf::B(de::OneOf::B(entry))) => self
5139 .plugin_entry(&entry, &yf.display, iline)
5140 .map(|(n, c, s)| (n, c, s, iline)),
5141 Loose::Ok(de::OneOf::B(de::OneOf::A(pin))) => Some((
5142 pin.name.value,
5143 Some(pin.version.clone()),
5144 PluginSource::Default,
5145 iline,
5146 )),
5147 Loose::Bad(_) => {
5148 self.diags.error(
5149 Code::InvalidPluginDeclaration,
5150 file.clone(),
5151 iline,
5152 "each `plugins` entry is a package name, `name: \"<version>\"`, or a map with `name:` and one of `github:`, `local:`, `registry:`",
5153 );
5154 None
5155 }
5156 }
5157 })
5158 .collect(),
5159 Loose::Ok(de::OneOf::B(m)) => m
5160 .0
5161 .into_iter()
5162 .map(|(k, v)| {
5163 let kline = k.line();
5164 (k.value, Some(v), PluginSource::Default, kline)
5165 })
5166 .collect(),
5167 Loose::Bad(f) if f.kind == "nothing" => Vec::new(),
5168 Loose::Bad(_) => {
5169 self.diags.error(
5170 Code::InvalidPluginDeclaration,
5171 file.clone(),
5172 line,
5173 "`plugins` must be a list like `- duckdb: \">=1.0\"`",
5174 );
5175 continue;
5176 }
5177 };
5178 for (name, c, source, eline) in entries {
5179 if !dre_protocol::valid_name(&name) {
5180 self.diags.error(
5181 Code::InvalidPluginDeclaration,
5182 file.clone(),
5183 eline,
5184 format!(
5185 "plugin package `{name}`: a package name is lowercase letters, digits and `_`"
5186 ),
5187 );
5188 continue;
5189 }
5190 let raw = match &c {
5191 None | Some(Value::Null) => "*".to_string(),
5192 Some(v) => crate::yaml::scalar_str(v).unwrap_or_default(),
5193 };
5194 match semver::VersionReq::parse(&raw) {
5195 Ok(req) => by_package.entry(name).or_default().push(Decl {
5196 req,
5197 raw,
5198 file: yf.display.clone(),
5199 source,
5200 }),
5201 Err(e) => self.diags.error(
5202 Code::InvalidVersionConstraint,
5203 file.clone(),
5204 eline,
5205 format!("plugin package `{name}`: invalid version constraint `{raw}`: {e}"),
5206 ),
5207 }
5208 }
5209 }
5210 }
5211 let mut out = Vec::new();
5212 for (name, ds) in &by_package {
5213 let source = ds
5215 .iter()
5216 .map(|d| &d.source)
5217 .find(|s| !s.is_default())
5218 .cloned()
5219 .unwrap_or_default();
5220 if let Some(other) = ds.iter().find(|d| !d.source.is_default() && d.source != source) {
5221 self.diags.error(
5222 Code::ConflictingPluginSources,
5223 Some(other.file.clone()),
5224 None,
5225 format!(
5226 "plugin package `{name}` is declared with two sources: {} and {}",
5227 source.lock_key().unwrap_or_default(),
5228 other.source.lock_key().unwrap_or_default()
5229 ),
5230 );
5231 project.plugins_incomplete = true;
5232 continue;
5233 }
5234 let reqs: Vec<&semver::VersionReq> = ds.iter().map(|d| &d.req).collect();
5235 if !constraints::compatible(&reqs) {
5236 for (i, a) in ds.iter().enumerate() {
5237 for b in &ds[i + 1..] {
5238 if !constraints::compatible(&[&a.req, &b.req]) {
5239 self.diags.error(
5240 Code::ConflictingPluginConstraints,
5241 Some(b.file.clone()),
5242 None,
5243 format!(
5244 "plugin package `{name}` is declared `{}` in {} and `{}` in {}; no version satisfies both",
5245 a.raw,
5246 a.file.display(),
5247 b.raw,
5248 b.file.display()
5249 ),
5250 );
5251 }
5252 }
5253 }
5254 project.plugins_incomplete = true;
5255 continue;
5256 }
5257 let combined = constraints::combine(&reqs);
5258 let mut files: Vec<PathBuf> = ds.iter().map(|d| d.file.clone()).collect();
5259 files.dedup();
5260 out.push(PluginRequirement {
5261 name: name.clone(),
5262 version: combined.to_string(),
5263 declared_in: files,
5264 source,
5265 });
5266 }
5267 project.plugins = out;
5268
5269 let mut uses = Vec::new();
5270 let profiles = &project.profiles;
5271 for (role, kind, refs) in [
5272 (Role::Connection, PluginKind::Source, &used.connections),
5273 (Role::Destination, PluginKind::Destination, &used.destinations),
5274 ] {
5275 for name in refs.keys() {
5276 let Some(p) = profiles.get(role, name) else {
5277 continue;
5278 };
5279 let role_name = role.as_str();
5280 for out_ in p.targets.values() {
5281 if kind == PluginKind::Destination && out_.kind == LOCAL_TYPE {
5282 continue;
5283 }
5284 let id = PluginId::new(kind, out_.kind.clone());
5285 if uses.iter().any(|u: &PluginUse| u.plugin == id) {
5286 continue;
5287 }
5288 uses.push(PluginUse {
5289 plugin: id,
5290 file: profiles.file.clone(),
5291 line: profiles.line_of(role, name),
5292 what: format!("`type: {}` used by {role_name} profile `{name}`", out_.kind),
5293 });
5294 }
5295 }
5296 }
5297 for (fmt, (ctx, file)) in &used.formats {
5298 let note = if fmt == "csv" {
5299 " (csv is the built-in default output)"
5300 } else {
5301 ""
5302 };
5303 uses.push(PluginUse {
5304 plugin: PluginId::new(PluginKind::Format, fmt.clone()),
5305 file: Some(file.clone()),
5306 line: None,
5307 what: format!("format `{fmt}` is used by {ctx}{note}"),
5308 });
5309 }
5310 project.plugin_uses = uses;
5311 }
5312
5313 fn old_plugin_key(&mut self, yf: &YamlFile, key: &str) {
5315 self.diags.error(
5316 Code::MovedPluginDeclaration,
5317 Some(yf.display.clone()),
5318 yf.line_of(key, None),
5319 format!(
5320 "`{key}:` no longer declares plugins: list plugin packages under `plugins:` instead (e.g. `plugins: [duckdb, object_store]`)"
5321 ),
5322 );
5323 }
5324
5325 fn preflight(&mut self, project: &Project, _used: &Usage) {
5328 let cli_owned: Vec<String> = self.opts.vars.keys().cloned().collect();
5329 let cli: BTreeSet<&str> = cli_owned.iter().map(String::as_str).collect();
5330 let mut sources: BTreeMap<PathBuf, String> = BTreeMap::new();
5331 let mut read = |root: &Path, p: &Path| -> Option<String> {
5332 if let Some(s) = sources.get(p) {
5333 return Some(s.clone());
5334 }
5335 let s = std::fs::read_to_string(root.join(p)).ok()?;
5336 sources.insert(p.to_path_buf(), s.clone());
5337 Some(s)
5338 };
5339 let mut checked: BTreeSet<PathBuf> = BTreeSet::new();
5340 let root = self.root.clone();
5341 let mut defs: BTreeMap<String, (PathBuf, preflight::MacroDef)> = BTreeMap::new();
5343 for m in &project.macros {
5344 if let Some(src) = read(&root, m) {
5345 self.check_template_text(m, &src, 0, None, &cli);
5346 checked.insert(m.clone());
5347 for d in preflight::macro_defs(&src) {
5348 defs.insert(d.name.clone(), (m.clone(), d));
5349 }
5350 }
5351 }
5352 for r in &project.reports {
5353 for b in &r.bindings {
5354 for error in crate::variables::check_given_values(&r.var_schema, &b.vars, &self.opts.vars) {
5355 self.diags
5356 .error(Code::InvalidField, Some(r.file.clone()), None, error);
5357 }
5358 let ctx = match &b.set {
5359 Some(s) => format!("report `{}`, Set `{s}`", r.name),
5360 None => format!("report `{}`", r.name),
5361 };
5362 let mut called: Vec<String> = Vec::new();
5363 let mut refs: Vec<String> = Vec::new();
5364 for q in &b.queries {
5365 let Some(src) = read(&root, &q.path) else { continue };
5366 let first = checked.insert(q.path.clone());
5367 self.check_vars(&q.path, &src, 0, &b.vars, &cli, &ctx);
5368 if first {
5369 self.check_template_text(&q.path, &src, 0, None, &cli);
5370 }
5371 called.extend(preflight::called_names(&src));
5372 refs.extend(preflight::refs(&src).into_iter().map(|(n, _)| n));
5373 }
5374 let mut seen_refs = BTreeSet::new();
5376 while let Some(name) = refs.pop() {
5377 let Some(path) = project.sql.get(&name) else {
5378 continue;
5379 };
5380 if !seen_refs.insert(name) {
5381 continue;
5382 }
5383 let Some(src) = read(&root, path) else { continue };
5384 self.check_vars(path, &src, 0, &b.vars, &cli, &ctx);
5385 if checked.insert(path.clone()) {
5386 self.check_template_text(path, &src, 0, None, &cli);
5387 }
5388 called.extend(preflight::called_names(&src));
5389 refs.extend(preflight::refs(&src).into_iter().map(|(n, _)| n));
5390 }
5391 let mut seen = BTreeSet::new();
5393 while let Some(name) = called.pop() {
5394 let Some((file, def)) = defs.get(&name) else {
5395 continue;
5396 };
5397 if !seen.insert(name) {
5398 continue;
5399 }
5400 self.check_vars(file, &def.body, def.line_offset, &b.vars, &cli, &ctx);
5401 called.extend(preflight::called_names(&def.body));
5402 }
5403 let mut values: Vec<String> = Vec::new();
5405 for d in b.destinations() {
5406 values.extend(d.path.clone());
5407 values.extend(d.options.values().flat_map(json_strings));
5408 }
5409 for t in b.outputs.iter().filter_map(|o| o.template.as_ref()) {
5410 values.extend(t.bindings.iter().filter_map(|tb| tb.value.clone()));
5411 }
5412 for v in values.iter().filter(|v| preflight::is_templated(v)) {
5413 let yf = std::fs::read_to_string(root.join(&r.file)).unwrap_or_default();
5414 let line = yf.lines().position(|l| l.contains(v.as_str())).map(|i| i + 1);
5415 self.check_template_text(&r.file, v, line.map_or(0, |l| l - 1), line, &cli);
5416 self.check_vars(&r.file, v, line.map_or(0, |l| l - 1), &b.vars, &cli, &ctx);
5417 }
5418 }
5419 }
5420 }
5421
5422 fn check_template_text(
5424 &mut self,
5425 file: &Path,
5426 src: &str,
5427 line_offset: usize,
5428 fixed_line: Option<usize>,
5429 _cli: &BTreeSet<&str>,
5430 ) {
5431 let f = Some(file.to_path_buf());
5432 if let Err((line, msg)) = preflight::check_syntax(&file.to_string_lossy(), src) {
5433 let line = fixed_line.or(line.map(|l| l + line_offset));
5434 self.diags.error(
5435 Code::JinjaSyntax,
5436 f.clone(),
5437 line,
5438 format!("Jinja syntax error: {msg}"),
5439 );
5440 return;
5441 }
5442 for c in preflight::calls(src)
5443 .into_iter()
5444 .filter(|c| c.func == "env_var" && !c.has_default)
5445 {
5446 if std::env::var_os(&c.name).is_none() {
5447 self.diags.error(
5448 Code::UnsetEnvVar,
5449 f.clone(),
5450 Some(fixed_line.unwrap_or(c.line + line_offset)),
5451 format!(
5452 "`env_var('{}')`: environment variable `{}` is not set and no default is given",
5453 c.name, c.name
5454 ),
5455 );
5456 }
5457 }
5458 for (name, instead, line) in preflight::removed_names(src) {
5459 self.diags.error(
5460 Code::RemovedTemplateName,
5461 f.clone(),
5462 Some(fixed_line.unwrap_or(line + line_offset)),
5463 format!("`{name}` was removed in DRE 0.2: use {instead}"),
5464 );
5465 }
5466 for (r, line) in preflight::unknown_run_refs(src) {
5467 self.diags.error(
5468 Code::UnknownRunAttribute,
5469 f.clone(),
5470 Some(fixed_line.unwrap_or(line + line_offset)),
5471 format!(
5472 "`{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(...)"
5473 ),
5474 );
5475 }
5476 }
5477
5478 fn check_vars(
5479 &mut self,
5480 file: &Path,
5481 src: &str,
5482 line_offset: usize,
5483 vars: &JsonMap<String, Json>,
5484 cli: &BTreeSet<&str>,
5485 ctx: &str,
5486 ) {
5487 for c in preflight::calls(src)
5488 .into_iter()
5489 .filter(|c| c.func == "var" && !c.has_default)
5490 {
5491 if !vars.contains_key(&c.name) && !cli.contains(c.name.as_str()) {
5492 self.diags.error(
5493 Code::UnresolvedVar,
5494 Some(file.to_path_buf()),
5495 Some(c.line + line_offset),
5496 format!(
5497 "`var('{}')` has no value for {ctx} (checked --var, Set, report, folder and project vars) and no default",
5498 c.name
5499 ),
5500 );
5501 }
5502 }
5503 }
5504
5505 fn check_template_files(&mut self, project: &Project) {
5506 let mut seen = BTreeSet::new();
5507 for r in &project.reports {
5508 for t in r
5509 .bindings
5510 .iter()
5511 .flat_map(|b| b.outputs.iter().filter_map(|o| o.template.as_ref()))
5512 {
5513 if !seen.insert((r.name.clone(), t.file.clone(), format!("{:?}", t.bindings))) {
5514 continue;
5515 }
5516 let Some(path) = find_template(&self.root, &t.file) else {
5517 self.diags.error(
5518 Code::MissingTemplate,
5519 Some(r.file.clone()),
5520 None,
5521 format!("report `{}`: template file `{}` doesn't exist", r.name, t.file),
5522 );
5523 continue;
5524 };
5525 match template_sheets(&path) {
5526 Ok(sheets) => {
5527 for tb in &t.bindings {
5528 if !sheets.contains(&tb.sheet) {
5529 self.diags.error(
5530 Code::InvalidTemplate,
5531 Some(r.file.clone()),
5532 None,
5533 format!(
5534 "report `{}`: template `{}` has no sheet `{}` (sheets: {})",
5535 r.name,
5536 t.file,
5537 tb.sheet,
5538 sheets.join(", ")
5539 ),
5540 );
5541 }
5542 }
5543 }
5544 Err(e) => self.diags.error(
5545 Code::InvalidTemplate,
5546 Some(r.file.clone()),
5547 None,
5548 format!("report `{}`: can't read template `{}`: {e}", r.name, t.file),
5549 ),
5550 }
5551 }
5552 }
5553 }
5554}
5555
5556pub fn find_template(root: &Path, file: &str) -> Option<PathBuf> {
5560 [root.join(file), root.join("templates").join(file)]
5561 .into_iter()
5562 .find(|p| p.is_file())
5563}
5564
5565pub fn output_label(name: Option<&str>, index: usize) -> String {
5567 match name {
5568 Some(n) => format!("output `{n}`"),
5569 None => format!("output {}", index + 1),
5570 }
5571}
5572
5573pub fn template_sheets(path: &Path) -> Result<Vec<String>, String> {
5574 use calamine::Reader;
5575 let wb: calamine::Xlsx<_> =
5576 calamine::open_workbook(path).map_err(|e: calamine::XlsxError| e.to_string())?;
5577 Ok(wb.sheet_names().to_vec())
5578}
5579
5580struct RawReport {
5581 name: String,
5582 file: Rc<YamlFile>,
5583 folder: Vec<String>,
5584 keys: BTreeMap<String, Located<Value>>,
5586 lines: BTreeMap<String, Option<usize>>,
5588 typed: ReportFile,
5590}
5591
5592struct BindingBase {
5593 profile: Option<String>,
5594 profile_at: Option<ProfileAt>,
5595 vars: JsonMap<String, Json>,
5596 outputs: Vec<Mapping>,
5598 locale: Option<String>,
5599}
5600
5601#[derive(Default)]
5603struct Usage {
5604 connections: BTreeMap<String, (Option<PathBuf>, Option<usize>)>,
5605 destinations: BTreeMap<String, (Option<PathBuf>, Option<usize>)>,
5606 formats: BTreeMap<String, (String, PathBuf)>,
5608}
5609
5610impl Usage {
5611 fn connection(&mut self, p: &str, file: Option<PathBuf>, line: Option<usize>) {
5614 if preflight::is_templated(p) {
5615 return;
5616 }
5617 let e = self.connections.entry(p.to_string()).or_insert((None, None));
5618 if e.0.is_none() {
5619 *e = (file, line);
5620 }
5621 }
5622 fn destination(&mut self, p: &str, file: Option<PathBuf>, line: Option<usize>) {
5623 if preflight::is_templated(p) {
5624 return;
5625 }
5626 let e = self.destinations.entry(p.to_string()).or_insert((None, None));
5627 if e.0.is_none() {
5628 *e = (file, line);
5629 }
5630 }
5631 fn format(&mut self, f: &str, ctx: &str, file: &Path) {
5632 self.formats
5633 .entry(f.to_string())
5634 .or_insert_with(|| (ctx.to_string(), file.to_path_buf()));
5635 }
5636}
5637
5638fn builtin_output() -> Mapping {
5643 let mut m = Mapping::new();
5644 m.insert("format".into(), Value::String("csv".into()));
5645 m
5646}
5647
5648fn project_default_output(p: &Project) -> Option<Mapping> {
5649 let yf = YamlFile::parse(
5650 std::fs::read_to_string(p.root.join(PROJECT_FILE)).ok()?,
5651 PathBuf::from(PROJECT_FILE),
5652 &mut Diagnostics::default(),
5653 )?;
5654 yf.value.get("default_output").and_then(Value::as_object).cloned()
5655}
5656
5657pub fn layer_outputs(base: &mut Vec<Mapping>, over: &Value) -> Vec<String> {
5664 let mut problems = Vec::new();
5665 match over {
5666 Value::Array(list) if list.is_empty() => problems.push(
5667 "`output` is an empty list; give at least one output, or remove it to use the inherited one"
5668 .into(),
5669 ),
5670 Value::Array(list) => {
5671 let mut outs = Vec::new();
5672 for (i, entry) in list.iter().enumerate() {
5673 match entry.as_object() {
5674 Some(m) => {
5675 let mut o = builtin_output();
5676 problems.extend(merge_output(&mut o, m));
5677 outs.push(o);
5678 }
5679 None => problems.push(format!("`output` entry {} must be a map", i + 1)),
5680 }
5681 }
5682 if problems.is_empty() {
5683 *base = outs;
5684 }
5685 }
5686 Value::Object(m) => {
5687 let name_of = |o: &Mapping| o.get("name").and_then(Value::as_str).map(str::to_string);
5688 let target = match m.get("name").and_then(Value::as_str) {
5689 Some(n) => match base.iter().position(|o| name_of(o).as_deref() == Some(n)) {
5690 Some(i) => Some(i),
5691 None if base.len() == 1 && name_of(&base[0]).is_none() => Some(0),
5692 None => {
5693 let names: Vec<String> = base
5694 .iter()
5695 .filter_map(name_of)
5696 .map(|n| format!("`{n}`"))
5697 .collect();
5698 problems.push(format!(
5699 "no inherited output is named `{n}`{}; override the full `output:` list to add one",
5700 if names.is_empty() {
5701 String::new()
5702 } else {
5703 format!(" (they're {})", names.join(", "))
5704 }
5705 ));
5706 None
5707 }
5708 },
5709 None if base.len() == 1 => Some(0),
5710 None => {
5711 problems.push(format!(
5712 "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",
5713 base.len()
5714 ));
5715 None
5716 }
5717 };
5718 if let Some(i) = target {
5719 problems.extend(merge_output(&mut base[i], m));
5720 }
5721 }
5722 _ => problems.push("`output` must be a map or a list of maps".into()),
5723 }
5724 problems
5725}
5726
5727pub fn merge_output(base: &mut Mapping, over: &Mapping) -> Option<String> {
5738 if let Some(f) = over.get("format")
5739 && base.get("format") != Some(f)
5740 {
5741 base.retain(|k, _| is_one_of(k, FORMAT_INDEPENDENT_KEYS));
5742 }
5743 let mut problem = None;
5744 for (k, v) in over {
5745 if k.as_str() == "destination"
5746 && let Value::Object(o) = v
5747 {
5748 let profile = |m: &Mapping| m.get("profile").cloned();
5749 match base.get_mut(k) {
5750 Some(Value::Array(list)) if o.get("profile").is_none() && list.len() > 1 => {
5751 problem = Some(format!(
5752 "this overrides `destination` with no `profile:`, but it inherits {} destinations, so it's unclear which one to change; override the full list instead",
5753 list.len()
5754 ));
5755 continue;
5756 }
5757 Some(Value::Array(list))
5758 if list.len() == 1
5759 && list[0]
5760 .as_object()
5761 .is_some_and(|b| o.get("profile").is_none() || profile(b) == profile(o)) =>
5762 {
5763 let mut b = list[0].as_object().cloned().unwrap_or_default();
5764 b.extend(o.clone());
5765 base.insert(k.clone(), Value::Object(b));
5766 continue;
5767 }
5768 Some(Value::Object(b)) if o.get("profile").is_none() || profile(b) == profile(o) => {
5769 b.extend(o.clone());
5770 continue;
5771 }
5772 _ => {}
5773 }
5774 }
5775 base.insert(k.clone(), v.clone());
5776 }
5777 problem
5778}
5779
5780fn inherited_profile_at(layers: &[&FolderCfg], project: &Project) -> Option<ProfileAt> {
5783 if let Some((_, line)) = layers.iter().rev().find_map(|l| l.profile.as_ref()) {
5784 return Some(ProfileAt {
5785 file: PathBuf::from(PROJECT_FILE),
5786 line: *line,
5787 key: "`+profile`".into(),
5788 });
5789 }
5790 project.default_profile.as_ref().map(|_| ProfileAt {
5791 file: PathBuf::from(PROJECT_FILE),
5792 line: project.default_profile_line,
5793 key: "`default_profile`".into(),
5794 })
5795}
5796
5797fn metadata_layers(project: &Project, folders: &[&FolderCfg]) -> Vec<MetaLayer> {
5798 let mut layers = Vec::new();
5799 if !project.meta.is_empty() {
5800 layers.push(MetaLayer {
5801 file: PROJECT_FILE.into(),
5802 key: "meta".into(),
5803 meta: project.meta.clone(),
5804 });
5805 }
5806 layers.extend(folders.iter().filter_map(|f| f.meta.clone()));
5807 layers
5808}
5809
5810fn folder_layers<'a>(folders: &'a BTreeMap<Vec<String>, FolderCfg>, folder: &[String]) -> Vec<&'a FolderCfg> {
5811 (0..=folder.len())
5812 .filter_map(|n| folders.get(&folder[..n].to_vec()))
5813 .collect()
5814}
5815
5816fn folder_segments(rel: &Path) -> Vec<String> {
5817 rel.components()
5818 .skip(1)
5819 .map(|c| c.as_os_str().to_string_lossy().to_string())
5820 .collect()
5821}
5822
5823pub fn dotted(path: &[String]) -> String {
5824 path.join(".")
5825}
5826
5827fn pick(v: &Value, keys: &[&str]) -> Mapping {
5828 v.as_object()
5829 .map(|m| {
5830 m.iter()
5831 .filter(|(k, _)| is_one_of(k, keys))
5832 .map(|(k, v)| (k.clone(), v.clone()))
5833 .collect()
5834 })
5835 .unwrap_or_default()
5836}
5837
5838fn json_strings(v: &Json) -> Vec<String> {
5840 match v {
5841 Json::String(s) => vec![s.clone()],
5842 Json::Array(a) => a.iter().flat_map(json_strings).collect(),
5843 Json::Object(o) => o.values().flat_map(json_strings).collect(),
5844 _ => Vec::new(),
5845 }
5846}
5847
5848fn is_one_of(k: &str, keys: &[&str]) -> bool {
5849 keys.contains(&k)
5850}
5851
5852fn is_timing_registry(m: &Mapping) -> bool {
5854 !m.is_empty()
5855 && m.values().all(|v| {
5856 v.as_object()
5857 .is_some_and(|t| ["cron", "every", "rrule"].iter().any(|k| t.contains_key(*k)))
5858 })
5859}
5860
5861fn is_set_registry(m: &Mapping) -> bool {
5864 m.values().any(Value::is_object)
5865 && m.values().all(|v| {
5866 v.is_null()
5867 || v.as_object()
5868 .is_some_and(|e| e.keys().all(|k| is_one_of(k, &["profile", "vars", "locale"])))
5869 })
5870}
5871
5872fn string_list(v: &Value) -> Option<Vec<String>> {
5873 v.as_array()?
5874 .iter()
5875 .map(|i| i.as_str().map(str::to_string))
5876 .collect()
5877}
5878
5879fn query_item_name(item: &QueryItem) -> Option<&str> {
5881 match &item.value {
5882 Loose::Ok(de::OneOf::A(s)) => Some(s),
5883 Loose::Ok(de::OneOf::B(e)) => e.query.as_ref().and_then(Loose::ok).map(String::as_str),
5884 Loose::Bad(_) => None,
5885 }
5886}
5887
5888fn entry_name(v: &Value) -> Option<String> {
5889 match v {
5890 Value::String(s) => Some(s.clone()),
5891 Value::Object(m) => m.get("query").and_then(Value::as_str).map(str::to_string),
5892 _ => None,
5893 }
5894}
5895
5896fn dedup(v: &mut Vec<String>) {
5897 let mut seen = BTreeSet::new();
5898 v.retain(|t| seen.insert(t.clone()));
5899}
5900
5901fn is_identifier(s: &str) -> bool {
5903 !s.is_empty()
5904 && !s.starts_with(|c: char| c.is_ascii_digit())
5905 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
5906}