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