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