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