1use std::collections::{BTreeMap, BTreeSet};
8use std::path::{Path, PathBuf};
9use std::rc::Rc;
10
11use serde::Serialize;
12use serde_json::{Map as JsonMap, Value as Json};
13use serde_yaml_ng::{Mapping, Value};
14
15use crate::dates::{WeekNumbering, WeekStart};
16use crate::diag::Diagnostics;
17use crate::lookups::{self, DEFAULT_INLINE_MAX_ROWS, LOOKUPS_DIR, Lookup};
18use crate::packages::{self, DispatchOrder, Package};
19
20const RESERVED_NAMES: &[&str] = &[
22 "run",
23 "var",
24 "env_var",
25 "run_query",
26 "columns",
27 "ref",
28 "lookup",
29 "dispatch",
30 "target",
31 "profile",
32 "date",
33 "datetime",
34 "date_range",
35 "month_of",
36 "quarter_of",
37 "year_of",
38 "week_of",
39 "period",
40 "raise_error",
41 "source",
42 "connection",
43 "destination",
44];
45use crate::profiles::{LOCAL_TYPE, Profiles, Role};
46use crate::yaml::YamlFile;
47use crate::{constraints, options, preflight, schedule, selector, sqlsplit};
48
49pub const PROJECT_FILE: &str = "dre_project.yml";
50pub const REPORTS_DIR: &str = "reports";
51pub const MACROS_DIR: &str = "macros";
52pub const TARGET_DIR: &str = "target";
53pub const LOGS_DIR: &str = "logs";
54pub use crate::plugins::DEPS_DIR;
55pub const DEFAULT_RUN_QUERY_MAX_ROWS: u64 = 10_000;
56
57pub const REPORT_KEYS: &[&str] = &[
58 "name",
59 "tags",
60 "queries",
61 "output",
62 "profile",
63 "sets",
64 "default_set",
65 "schedule",
66 "vars",
67 "timezone",
68];
69const PLUGINS_KEY: &str = "plugins";
71pub const PLUGIN_KEYS: &[&str] = &[PLUGINS_KEY];
72const OLD_PLUGIN_KEYS: &[&str] = &["destinations", "formats"];
75pub const SOURCES_KEY: &str = "sources";
77pub const PROJECT_KEYS: &[&str] = &[
78 "name",
79 "default_profile",
80 "default_output",
81 "format_options",
82 "default_set",
83 "vars",
84 "run_query_max_rows",
85 "lookup_inline_max_rows",
86 "dispatch",
87 "mask_secrets",
88 "timezone",
89 "week_start",
90 "week_numbering",
91 "reports",
92 "target",
93 SOURCES_KEY,
94 crate::target::KEY,
95];
96pub const SCHEDULE_ENTRY_KEYS: &[&str] = &[
98 "name", "select", "report", "set", "vars", "timezone", "enabled", "timing",
99];
100pub const TIMINGS_FILE: &str = "timings.yml";
102pub const FOLDER_CONFIG_KEYS: &[&str] = &["+tags", "+output", "+profile", "+schedule", "+vars", "+timezone"];
103pub const SET_ENTRY_KEYS: &[&str] = &[
104 "name",
105 "profile",
106 "vars",
107 "exclude",
108 "queries",
109 "tab_names",
110 "output",
111 "schedule",
112];
113pub const QUERY_ENTRY_KEYS: &[&str] = &[
114 "query", "profile", "tab", "tab_name", "anchor", "header", "columns",
115];
116pub const OUTPUT_SHARED_KEYS: &[&str] = &["format", "destination", "template", "extension"];
117pub const TEMPLATE_BINDING_KEYS: &[&str] = &[
119 "sheet",
120 "query",
121 "result_index",
122 "anchor",
123 "header",
124 "columns",
125 "cell",
126 "value",
127 "column",
128];
129pub const PLUGIN_ENTRY_KEYS: &[&str] = &["name", "version", "github", "local", "registry"];
131
132#[derive(Debug, Clone, Serialize)]
137pub struct Project {
138 pub name: String,
139 #[serde(skip)]
140 pub root: PathBuf,
141 #[serde(skip)]
144 pub target_dir: PathBuf,
145 #[serde(skip)]
147 pub target_source: crate::target::Source,
148 #[serde(skip)]
151 pub target_name: String,
152 #[serde(skip)]
154 pub target_from: crate::profiles::TargetSource,
155 #[serde(skip_serializing_if = "Option::is_none")]
156 pub default_profile: Option<String>,
157 #[serde(skip)]
159 pub default_profile_line: Option<usize>,
160 #[serde(skip_serializing_if = "Option::is_none")]
161 pub default_set: Option<String>,
162 pub vars: JsonMap<String, Json>,
163 pub run_query_max_rows: u64,
164 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
166 pub format_options: BTreeMap<String, JsonMap<String, Json>>,
167 pub reports: Vec<Report>,
168 pub sets: BTreeMap<String, SetDef>,
169 pub plugins: Vec<PluginRequirement>,
171 #[serde(skip)]
174 pub plugin_uses: Vec<PluginUse>,
175 #[serde(skip)]
177 pub plugins_incomplete: bool,
178 #[serde(skip_serializing_if = "Vec::is_empty")]
179 pub schedules: Vec<ScheduleEntry>,
180 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
182 pub timings: BTreeMap<String, Timing>,
183 pub macros: Vec<PathBuf>,
185 pub packages: Vec<Package>,
187 pub mask_secrets: bool,
189 #[serde(skip_serializing_if = "Option::is_none")]
191 pub timezone: Option<String>,
192 #[serde(skip)]
194 pub week_start: WeekStart,
195 #[serde(skip)]
196 pub week_numbering: WeekNumbering,
197 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
199 pub dispatch: DispatchOrder,
200 #[serde(skip)]
202 pub sql: BTreeMap<String, PathBuf>,
203 #[serde(skip)]
205 pub lookups: BTreeMap<String, Lookup>,
206 pub lookup_inline_max_rows: u64,
208 #[serde(skip)]
210 pub folders: Vec<Vec<String>>,
211 #[serde(skip)]
215 pub files: Vec<PathBuf>,
216 #[serde(skip)]
219 pub parse_errors: BTreeMap<String, Vec<crate::Diagnostic>>,
220 #[serde(skip)]
222 pub inputs: crate::parse::Inputs,
223 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
225 pub sources: BTreeMap<String, SourceDef>,
226 #[serde(skip)]
227 pub profiles: Profiles,
228}
229
230impl Project {
231 pub fn report(&self, name: &str) -> Option<&Report> {
232 self.reports.iter().find(|r| r.name == name)
233 }
234}
235
236#[derive(Debug, Clone, Serialize)]
237pub struct Report {
238 pub name: String,
239 pub managed: bool,
240 pub file: PathBuf,
242 pub folder: Vec<String>,
244 pub tags: Vec<String>,
245 pub queries: Vec<QueryEntry>,
246 #[serde(skip_serializing_if = "Option::is_none")]
247 pub default_set: Option<String>,
248 #[serde(skip_serializing_if = "Option::is_none")]
250 pub timezone: Option<String>,
251 pub has_sets: bool,
253 pub bindings: Vec<Binding>,
254 #[serde(skip)]
256 pub base: Binding,
257}
258
259impl Report {
260 pub fn binding(&self, set: &str) -> Option<&Binding> {
261 self.bindings.iter().find(|b| b.set.as_deref() == Some(set))
262 }
263}
264
265#[derive(Debug, Clone, PartialEq, Serialize)]
266pub struct QueryEntry {
267 pub query: String,
268 pub path: PathBuf,
270 #[serde(skip_serializing_if = "Option::is_none")]
272 pub profile: Option<String>,
273 #[serde(skip_serializing_if = "is_true")]
277 pub tab: bool,
278 #[serde(skip_serializing_if = "Option::is_none")]
279 pub tab_name: Option<String>,
280 #[serde(skip_serializing_if = "Option::is_none")]
281 pub anchor: Option<String>,
282 #[serde(skip_serializing_if = "Option::is_none")]
283 pub header: Option<bool>,
284 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
286 pub columns: BTreeMap<String, ColumnOptions>,
287}
288
289fn is_true(b: &bool) -> bool {
290 *b
291}
292
293fn one_tab_per_file(name: &str) -> String {
295 format!(
296 "`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:`"
297 )
298}
299
300#[derive(Debug, Clone, Default, Serialize)]
302pub struct Binding {
303 pub set: Option<String>,
305 #[serde(skip_serializing_if = "Option::is_none")]
308 pub profile: Option<String>,
309 pub vars: JsonMap<String, Json>,
311 pub queries: Vec<QueryEntry>,
312 pub output: Output,
313 #[serde(skip_serializing_if = "Vec::is_empty")]
315 pub schedules: Vec<String>,
316 #[serde(skip)]
319 pub parsed: Option<std::sync::Arc<crate::parse::ParsedBinding>>,
320 #[serde(skip)]
322 pub profile_at: Option<ProfileAt>,
323}
324
325#[derive(Debug, Clone, PartialEq, Eq)]
327pub struct ProfileAt {
328 pub file: PathBuf,
329 pub line: Option<usize>,
330 pub key: String,
332}
333
334impl Binding {
335 pub fn dir_name(&self) -> &str {
337 self.set.as_deref().unwrap_or("default")
338 }
339}
340
341#[derive(Debug, Clone, Default, PartialEq, Serialize)]
342pub struct Output {
343 pub format: String,
344 pub options: JsonMap<String, Json>,
346 #[serde(skip_serializing_if = "Vec::is_empty")]
348 pub destinations: Vec<Destination>,
349 #[serde(skip_serializing_if = "Option::is_none")]
350 pub template: Option<Template>,
351 #[serde(skip_serializing_if = "Option::is_none")]
354 pub extension: Option<String>,
355}
356
357#[derive(Debug, Clone, PartialEq, Serialize)]
358pub struct Destination {
359 pub profile: String,
360 #[serde(skip_serializing_if = "Option::is_none")]
361 pub path: Option<String>,
362 #[serde(skip_serializing_if = "JsonMap::is_empty")]
365 pub options: JsonMap<String, Json>,
366}
367
368#[derive(Debug, Clone, PartialEq, Serialize)]
369pub struct Template {
370 pub file: String,
371 pub bindings: Vec<TemplateBinding>,
372}
373
374#[derive(Debug, Clone, PartialEq, Serialize)]
375pub struct TemplateBinding {
376 pub sheet: String,
377 #[serde(skip_serializing_if = "Option::is_none")]
378 pub query: Option<String>,
379 #[serde(skip_serializing_if = "Option::is_none")]
380 pub result_index: Option<usize>,
381 #[serde(skip_serializing_if = "Option::is_none")]
382 pub anchor: Option<String>,
383 #[serde(skip_serializing_if = "Option::is_none")]
384 pub header: Option<bool>,
385 #[serde(skip_serializing_if = "Option::is_none")]
386 pub columns: Option<Vec<String>>,
387 #[serde(skip_serializing_if = "Option::is_none")]
388 pub cell: Option<String>,
389 #[serde(skip_serializing_if = "Option::is_none")]
390 pub value: Option<String>,
391 #[serde(skip_serializing_if = "Option::is_none")]
392 pub column: Option<String>,
393}
394
395#[derive(Debug, Clone, Serialize)]
396pub struct SetDef {
397 #[serde(skip_serializing_if = "Option::is_none")]
398 pub profile: Option<String>,
399 pub vars: JsonMap<String, Json>,
400 #[serde(skip)]
402 pub file: PathBuf,
403 #[serde(skip)]
404 pub line: Option<usize>,
405}
406
407#[derive(Debug, Clone, Serialize)]
409pub struct SourceDef {
410 pub name: String,
411 #[serde(skip_serializing_if = "Option::is_none")]
412 pub description: Option<String>,
413 #[serde(skip_serializing_if = "Option::is_none")]
415 pub database: Option<String>,
416 #[serde(skip_serializing_if = "Option::is_none")]
417 pub schema: Option<String>,
418 #[serde(skip_serializing_if = "Option::is_none")]
420 pub profile: Option<String>,
421 pub quoting: QuotingDef,
422 pub tags: Vec<String>,
423 pub meta: JsonMap<String, Json>,
424 pub tables: Vec<SourceTableDef>,
425 #[serde(skip)]
427 pub file: PathBuf,
428 #[serde(skip)]
429 pub line: Option<usize>,
430}
431
432impl SourceDef {
433 pub fn table(&self, name: &str) -> Option<&SourceTableDef> {
434 self.tables.iter().find(|t| t.name == name)
435 }
436}
437
438#[derive(Debug, Clone, Serialize)]
439pub struct SourceTableDef {
440 pub name: String,
441 #[serde(skip_serializing_if = "Option::is_none")]
442 pub identifier: Option<String>,
443 #[serde(skip_serializing_if = "Option::is_none")]
444 pub description: Option<String>,
445 pub quoting: QuotingDef,
447 pub tags: Vec<String>,
448 pub meta: JsonMap<String, Json>,
449 pub columns: Vec<SourceColumn>,
450}
451
452#[derive(Debug, Clone, Serialize)]
453pub struct SourceColumn {
454 pub name: String,
455 #[serde(skip_serializing_if = "Option::is_none")]
456 pub description: Option<String>,
457 #[serde(skip_serializing_if = "Option::is_none")]
458 pub data_type: Option<String>,
459}
460
461#[derive(Debug, Clone, Copy, Default, Serialize)]
463pub struct QuotingDef {
464 #[serde(skip_serializing_if = "Option::is_none")]
465 pub database: Option<bool>,
466 #[serde(skip_serializing_if = "Option::is_none")]
467 pub schema: Option<bool>,
468 #[serde(skip_serializing_if = "Option::is_none")]
469 pub identifier: Option<bool>,
470}
471
472impl QuotingDef {
473 pub fn over(&self, under: &QuotingDef) -> crate::render::Quoting {
475 crate::render::Quoting {
476 database: self.database.or(under.database).unwrap_or(false),
477 schema: self.schema.or(under.schema).unwrap_or(false),
478 identifier: self.identifier.or(under.identifier).unwrap_or(false),
479 }
480 }
481}
482
483pub const SOURCE_KEYS: &[&str] = &[
485 "name",
486 "description",
487 "database",
488 "schema",
489 "quoting",
490 "tags",
491 "meta",
492 "tables",
493 "profile",
494];
495pub const SOURCE_TABLE_KEYS: &[&str] = &[
496 "name",
497 "identifier",
498 "description",
499 "quoting",
500 "tags",
501 "meta",
502 "columns",
503];
504pub const SOURCE_COLUMN_KEYS: &[&str] = &["name", "description", "data_type"];
505pub const DBT_SOURCE_KEYS: &[&str] = &[
507 "loader",
508 "loaded_at_field",
509 "loaded_at_query",
510 "config",
511 "overrides",
512 "freshness",
513 "docs",
514];
515pub const DBT_SOURCE_TABLE_KEYS: &[&str] = &[
516 "loaded_at_field",
517 "loaded_at_query",
518 "tests",
519 "data_tests",
520 "freshness",
521 "external",
522 "config",
523 "docs",
524];
525pub const DBT_SOURCE_COLUMN_KEYS: &[&str] = &[
526 "meta",
527 "tags",
528 "quote",
529 "tests",
530 "data_tests",
531 "constraints",
532 "config",
533 "docs",
534 "granularity",
535];
536
537pub use dre_protocol::Kind as PluginKind;
539pub use dre_protocol::PluginId;
540pub use dre_protocol::msg::ColumnOptions;
541
542#[derive(Debug, Clone, Serialize)]
544pub struct PluginRequirement {
545 pub name: String,
547 pub version: String,
549 pub declared_in: Vec<PathBuf>,
551 #[serde(skip_serializing_if = "PluginSource::is_default")]
553 pub source: PluginSource,
554}
555
556#[derive(Debug, Clone, Serialize)]
558pub struct PluginUse {
559 pub plugin: PluginId,
560 pub file: Option<PathBuf>,
561 pub line: Option<usize>,
562 pub what: String,
564}
565
566#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
568#[serde(rename_all = "snake_case")]
569pub enum PluginSource {
570 #[default]
572 Default,
573 Registry(String),
575 Github(String),
577 Local(String),
579}
580
581impl PluginSource {
582 pub fn is_default(&self) -> bool {
583 *self == PluginSource::Default
584 }
585
586 pub fn lock_key(&self) -> Option<String> {
588 match self {
589 PluginSource::Default => None,
590 PluginSource::Registry(u) => Some(format!("registry:{u}")),
591 PluginSource::Github(r) => Some(format!("github:{r}")),
592 PluginSource::Local(p) => Some(format!("local:{p}")),
593 }
594 }
595}
596
597impl PluginRequirement {
598 pub fn req(&self) -> semver::VersionReq {
599 semver::VersionReq::parse(&self.version).unwrap_or(semver::VersionReq::STAR)
600 }
601}
602
603#[derive(Debug, Clone, Serialize)]
604pub struct ScheduleEntry {
605 pub name: String,
606 #[serde(skip_serializing_if = "Option::is_none")]
607 pub select: Option<String>,
608 #[serde(skip_serializing_if = "Option::is_none")]
609 pub report: Option<String>,
610 #[serde(skip_serializing_if = "Option::is_none")]
611 pub set: Option<String>,
612 pub schedule: JsonMap<String, Json>,
614 #[serde(skip_serializing_if = "Option::is_none")]
616 pub timing: Option<String>,
617 pub enabled: bool,
619 #[serde(skip_serializing_if = "JsonMap::is_empty")]
621 pub vars: JsonMap<String, Json>,
622 #[serde(skip_serializing_if = "Option::is_none")]
624 pub timezone: Option<String>,
625 #[serde(skip)]
627 pub location: (PathBuf, Option<usize>),
628}
629
630#[derive(Debug, Clone, Serialize)]
632pub struct Timing {
633 pub schedule: JsonMap<String, Json>,
635 #[serde(skip_serializing_if = "Option::is_none")]
636 pub timezone: Option<String>,
637 #[serde(skip)]
639 pub location: (PathBuf, Option<usize>),
640}
641
642#[derive(Debug, Clone, Default)]
647pub struct LoadOptions {
648 pub profiles_dir: Option<PathBuf>,
650 pub target: Option<String>,
652 pub date: Option<chrono::NaiveDate>,
655 pub scheduled_at: Option<chrono::DateTime<chrono::Utc>>,
656 pub timezone: Option<String>,
657 pub vars: BTreeMap<String, String>,
659 pub target_path: Option<String>,
661}
662
663pub fn load(root: &Path, opts: &LoadOptions) -> (Option<Project>, Diagnostics) {
666 let mut l = Loader {
667 root: root.to_path_buf(),
668 target: crate::target::TargetPath::default_for(root),
669 target_inside: Some(PathBuf::from(TARGET_DIR)),
670 opts: opts.clone(),
671 diags: Diagnostics::default(),
672 format_options: Mapping::new(),
673 };
674 let project = l.run();
675 (project, l.diags)
676}
677
678struct Loader {
679 root: PathBuf,
680 target: crate::target::TargetPath,
682 target_inside: Option<PathBuf>,
684 opts: LoadOptions,
685 diags: Diagnostics,
686 format_options: Mapping,
688}
689
690struct Fragment {
692 file: Rc<YamlFile>,
693 name: String,
694 explicit_name: bool,
695 folder: Vec<String>,
696 map: Mapping,
697}
698
699#[derive(Default, Clone)]
701struct FolderCfg {
702 tags: Vec<String>,
703 output: Option<Mapping>,
704 profile: Option<(String, Option<usize>)>,
705 vars: Option<Mapping>,
706 timezone: Option<String>,
707}
708
709struct Discovered {
710 yaml: Vec<PathBuf>,
711 sql: Vec<PathBuf>,
713 macros: Vec<PathBuf>,
714 lookups: Vec<PathBuf>,
716 folders: Vec<Vec<String>>,
717 sources: Vec<PathBuf>,
719}
720
721struct Located<T> {
723 value: T,
724 file: Rc<YamlFile>,
725}
726
727impl Loader {
728 fn rel(&self, p: &Path) -> PathBuf {
729 crate::slash(p.strip_prefix(&self.root).unwrap_or(p))
730 }
731
732 fn run(&mut self) -> Option<Project> {
733 let pfile = self.root.join(PROJECT_FILE);
734 if !pfile.is_file() {
735 self.diags.error(
736 "project-file-missing",
737 Some(PathBuf::from(PROJECT_FILE)),
738 None,
739 format!(
740 "no {PROJECT_FILE} found in {}",
741 crate::slash(&self.root).display()
742 ),
743 );
744 return None;
745 }
746 let pyaml = Rc::new(YamlFile::load(
747 &pfile,
748 PathBuf::from(PROJECT_FILE),
749 &mut self.diags,
750 )?);
751 let project_target = pyaml.value.get(crate::target::KEY).and_then(Value::as_str);
752 if let Some(v) = pyaml.value.get(crate::target::KEY)
753 && !v.is_string()
754 {
755 self.diags.error(
756 "invalid-field",
757 Some(PathBuf::from(PROJECT_FILE)),
758 pyaml.line_of(crate::target::KEY, None),
759 "`target_path` must be a path",
760 );
761 return None;
762 }
763 match crate::target::resolve(&self.root, self.opts.target_path.as_deref(), project_target) {
764 Ok(t) => {
765 self.target_inside = crate::target::inside(&self.root, &t.dir);
766 self.target = t;
767 }
768 Err(e) => {
769 let from_file = self.opts.target_path.is_none()
770 && std::env::var(crate::target::ENV).map_or(true, |v| v.is_empty());
771 self.diags.error(
772 "invalid-target-path",
773 from_file.then(|| PathBuf::from(PROJECT_FILE)),
774 from_file
775 .then(|| pyaml.line_of(crate::target::KEY, None))
776 .flatten(),
777 e,
778 );
779 return None;
780 }
781 }
782 let mut project = self.parse_project_file(&pyaml)?;
783
784 let found = self.discover();
785 project.folders = found.folders.clone();
786 project.macros = found.macros.clone();
787 project.files = found.sources.clone();
788 let declared = packages::declared(&self.root, &mut self.diags);
789 project.packages = packages::resolve(&self.root, &declared, &mut self.diags);
790 self.check_macro_namespaces(&project);
791
792 let (profiles_dir, found_by) =
793 crate::profiles::locate(self.opts.profiles_dir.as_deref(), Some(&self.root));
794 project.profiles = Profiles::load(&profiles_dir, found_by, &mut self.diags);
795
796 let folder_cfg = self.parse_folder_config(&pyaml, &project.folders);
798 let mut used = Usage::default();
799 if let Some(p) = &project.default_profile {
800 used.connection(
801 p,
802 Some(pyaml.display.clone()),
803 pyaml.line_of("default_profile", None),
804 );
805 }
806 for cfg in folder_cfg.values() {
807 if let Some((p, line)) = &cfg.profile {
808 used.connection(p, Some(pyaml.display.clone()), *line);
809 }
810 }
811
812 let mut fragments = Vec::new();
814 let mut plugin_decls: Vec<(Rc<YamlFile>, Mapping)> = Vec::new();
815 let mut set_files = Vec::new();
816 let mut schedule_files = Vec::new();
817 let mut timing_files = Vec::new();
818 let mut source_decls: Vec<Rc<YamlFile>> = Vec::new();
819 plugin_decls.push((pyaml.clone(), pick(&pyaml.value, PLUGIN_KEYS)));
820 if pyaml.value.get(SOURCES_KEY).is_some() {
821 source_decls.push(pyaml.clone());
822 }
823 for path in &found.yaml {
824 let display = self.rel(path);
825 let Some(yf) = YamlFile::load(path, display.clone(), &mut self.diags) else {
826 continue;
827 };
828 let yf = Rc::new(yf);
829 if yf.value.get(SOURCES_KEY).is_some() {
830 source_decls.push(yf.clone());
831 }
832 self.classify(
833 yf,
834 &mut fragments,
835 &mut plugin_decls,
836 &mut set_files,
837 &mut schedule_files,
838 &mut timing_files,
839 );
840 }
841
842 project.sources = self.parse_sources(&source_decls);
843 project.sets = self.parse_sets(&set_files);
844 let sql_index = self.index_sql(&found.sql);
845 project.sql = sql_index
846 .iter()
847 .filter(|(_, paths)| paths.len() == 1)
848 .map(|(name, paths)| (name.clone(), paths[0].clone()))
849 .collect();
850 let mut referenced: BTreeSet<String> = fragments
852 .iter()
853 .filter_map(|f| f.map.get("queries").and_then(Value::as_sequence))
854 .flatten()
855 .filter_map(entry_name)
856 .collect();
857 project.lookups = lookups::discover(&self.root, &found.lookups, &mut self.diags);
858 for (name, l) in &project.lookups {
859 if let Some(sql) = sql_index.get(name) {
860 self.diags.error(
861 "duplicate-ref-name",
862 Some(l.file.clone()),
863 None,
864 format!(
865 "lookup `{name}` has the same name as {}; `ref()` names must be unique",
866 sql[0].display()
867 ),
868 );
869 }
870 if let Err(e) = lookups::read(&self.root, l) {
871 self.diags.error("invalid-lookup", Some(l.file.clone()), None, e);
872 }
873 }
874 referenced.extend(self.check_refs(&found.sql, &found.macros, &sql_index, &project.lookups));
876 let mut reports = self.merge_reports(fragments);
877
878 let mut built = Vec::new();
879 for r in reports.iter_mut() {
880 let queries = self.resolve_queries(r, &sql_index, &mut referenced);
881 built.push((queries, r));
882 }
883 let mut resolved: Vec<Report> = Vec::new();
884 for (queries, raw) in built {
885 if let Some(rep) = self.resolve_managed(raw, queries, &project, &folder_cfg, &mut used) {
886 resolved.push(rep);
887 }
888 }
889 let managed_names: BTreeMap<String, PathBuf> = resolved
890 .iter()
891 .map(|r| (r.name.clone(), r.file.clone()))
892 .collect();
893 for (name, path) in &sql_index {
894 if referenced.contains(name) || path.len() != 1 {
895 continue;
896 }
897 let path = &path[0];
898 if let Some(other) = managed_names.get(name) {
899 self.diags.error(
900 "duplicate-report-name",
901 Some(path.clone()),
902 None,
903 format!(
904 "unmanaged report `{name}` has the same name as the report declared in {}; report names must be unique project-wide",
905 other.display()
906 ),
907 );
908 continue;
909 }
910 resolved.push(self.resolve_unmanaged(name, path, &project, &folder_cfg, &mut used));
911 }
912 resolved.sort_by(|a, b| a.name.cmp(&b.name));
913 project.reports = resolved;
914
915 self.preflight(&project, &used);
917 self.parse_pass(&mut project, &mut used);
918 self.check_profiles(&project, &used);
919 self.check_plugins(&plugin_decls, &mut project, &used);
920 let broken_timings;
921 (project.timings, broken_timings) = self.parse_timings(&timing_files);
922 project.schedules = self.parse_schedules(&schedule_files, &project, &broken_timings);
923 self.apply_schedules(&mut project);
924 self.check_template_files(&project);
925
926 Some(project)
927 }
928
929 fn parse_project_file(&mut self, yf: &Rc<YamlFile>) -> Option<Project> {
932 let file = Some(yf.display.clone());
933 let Some(m) = yf.value.as_mapping() else {
934 self.diags.error(
935 "invalid-project",
936 file,
937 None,
938 format!("{PROJECT_FILE} must be a map"),
939 );
940 return None;
941 };
942 for k in m.keys().filter_map(Value::as_str) {
943 if OLD_PLUGIN_KEYS.contains(&k) {
944 self.old_plugin_key(yf, k);
945 } else if !PROJECT_KEYS.contains(&k) && !PLUGIN_KEYS.contains(&k) {
946 self.diags.error(
947 "unknown-key",
948 file.clone(),
949 yf.line_of(k, None),
950 format!("unknown key `{k}`"),
951 );
952 }
953 }
954 let name = match m.get("name") {
955 Some(Value::String(s)) if !s.trim().is_empty() => Some(s.clone()),
956 Some(_) => {
957 self.diags.error(
958 "invalid-field",
959 file.clone(),
960 yf.line_of("name", None),
961 "`name` must be a non-empty string",
962 );
963 None
964 }
965 None => {
966 self.diags.error(
967 "missing-field",
968 file.clone(),
969 None,
970 "missing required field `name`",
971 );
972 None
973 }
974 };
975 let default_profile = self.opt_string(yf, m, "default_profile");
976 let project_target = self.opt_string(yf, m, "target");
977 let (target_name, target_from) =
978 crate::profiles::resolve_target(self.opts.target.as_deref(), project_target.as_deref());
979 let default_set = self.opt_string(yf, m, "default_set");
980 let vars = self.opt_vars(yf, m.get("vars"), "vars");
981 let run_query_max_rows = match m.get("run_query_max_rows") {
982 None => DEFAULT_RUN_QUERY_MAX_ROWS,
983 Some(v) => match v.as_u64() {
984 Some(n) if n > 0 => n,
985 _ => {
986 self.diags.error(
987 "invalid-field",
988 file.clone(),
989 yf.line_of("run_query_max_rows", None),
990 "`run_query_max_rows` must be a positive whole number",
991 );
992 DEFAULT_RUN_QUERY_MAX_ROWS
993 }
994 },
995 };
996 let dispatch = self.parse_dispatch(yf, m.get("dispatch"));
997 match m.get("format_options") {
998 None | Some(Value::Null) => {}
999 Some(Value::Mapping(f)) if f.values().all(|v| v.is_mapping()) => self.format_options = f.clone(),
1000 Some(_) => self.diags.error(
1001 "invalid-field",
1002 file.clone(),
1003 yf.line_of("format_options", None),
1004 "`format_options` must map format names to their options, e.g. `delimited: {delimiter: \"|\"}`",
1005 ),
1006 }
1007 let mask_secrets = match m.get("mask_secrets") {
1008 None => true,
1009 Some(Value::Bool(b)) => *b,
1010 Some(_) => {
1011 self.diags.error(
1012 "invalid-field",
1013 file.clone(),
1014 yf.line_of("mask_secrets", None),
1015 "`mask_secrets` must be true or false",
1016 );
1017 true
1018 }
1019 };
1020 let timezone = match m.get("timezone") {
1021 None => None,
1022 Some(v) => self.timezone_value(v, &yf.display, yf.line_of("timezone", None), "`timezone`"),
1023 };
1024 let week_start = match m.get("week_start") {
1025 None => WeekStart::Monday,
1026 Some(v) => v.as_str().and_then(WeekStart::parse).unwrap_or_else(|| {
1027 self.diags.error(
1028 "invalid-field",
1029 file.clone(),
1030 yf.line_of("week_start", None),
1031 "`week_start` must be `monday` or `sunday`",
1032 );
1033 WeekStart::Monday
1034 }),
1035 };
1036 let week_numbering = match m.get("week_numbering") {
1037 None => WeekNumbering::Iso,
1038 Some(v) => v.as_str().and_then(WeekNumbering::parse).unwrap_or_else(|| {
1039 self.diags.error(
1040 "invalid-field",
1041 file.clone(),
1042 yf.line_of("week_numbering", None),
1043 "`week_numbering` must be `iso` or `us`",
1044 );
1045 WeekNumbering::Iso
1046 }),
1047 };
1048 let lookup_inline_max_rows = match m.get("lookup_inline_max_rows") {
1049 None => DEFAULT_INLINE_MAX_ROWS,
1050 Some(v) => match v.as_u64() {
1051 Some(n) => n,
1052 _ => {
1053 self.diags.error(
1054 "invalid-field",
1055 file.clone(),
1056 yf.line_of("lookup_inline_max_rows", None),
1057 "`lookup_inline_max_rows` must be a whole number",
1058 );
1059 DEFAULT_INLINE_MAX_ROWS
1060 }
1061 },
1062 };
1063 Some(Project {
1064 name: name?,
1065 root: self.root.clone(),
1066 target_dir: self.target.dir.clone(),
1067 target_source: self.target.source,
1068 target_name,
1069 target_from,
1070 default_profile_line: yf.line_of("default_profile", None),
1071 default_profile,
1072 default_set,
1073 vars,
1074 run_query_max_rows,
1075 reports: Vec::new(),
1076 sets: BTreeMap::new(),
1077 plugins: Vec::new(),
1078 plugin_uses: Vec::new(),
1079 plugins_incomplete: false,
1080 schedules: Vec::new(),
1081 timings: BTreeMap::new(),
1082 macros: Vec::new(),
1083 packages: Vec::new(),
1084 mask_secrets,
1085 timezone,
1086 week_start,
1087 week_numbering,
1088 dispatch,
1089 sql: BTreeMap::new(),
1090 lookups: BTreeMap::new(),
1091 lookup_inline_max_rows,
1092 format_options: self
1093 .format_options
1094 .iter()
1095 .filter_map(|(k, v)| Some((k.as_str()?.to_string(), yaml_map_to_json(v.as_mapping()?))))
1096 .collect(),
1097 folders: Vec::new(),
1098 files: Vec::new(),
1099 inputs: crate::parse::Inputs::default(),
1100 parse_errors: BTreeMap::new(),
1101 sources: BTreeMap::new(),
1102 profiles: Profiles::default(),
1103 })
1104 }
1105
1106 fn timezone_value(&mut self, v: &Value, file: &Path, line: Option<usize>, what: &str) -> Option<String> {
1108 let msg = match v.as_str() {
1109 Some(s) => match crate::dates::parse_tz(s) {
1110 Ok(_) => return Some(s.to_string()),
1111 Err(e) => format!("{what}: {e}"),
1112 },
1113 None => format!("{what} must be a string, an IANA timezone name such as `Australia/Sydney`"),
1114 };
1115 self.diags
1116 .error("invalid-timezone", Some(file.to_path_buf()), line, msg);
1117 None
1118 }
1119
1120 fn opt_string(&mut self, yf: &YamlFile, m: &Mapping, key: &str) -> Option<String> {
1121 match m.get(key)? {
1122 Value::String(s) => Some(s.clone()),
1123 _ => {
1124 self.diags.error(
1125 "invalid-field",
1126 Some(yf.display.clone()),
1127 yf.line_of(key, None),
1128 format!("`{key}` must be a string"),
1129 );
1130 None
1131 }
1132 }
1133 }
1134
1135 fn parse_dispatch(&mut self, yf: &YamlFile, v: Option<&Value>) -> DispatchOrder {
1137 let mut out = DispatchOrder::new();
1138 let Some(v) = v else { return out };
1139 let line = yf.line_of("dispatch", None);
1140 let Some(list) = v.as_sequence() else {
1141 self.diags.error(
1142 "invalid-field",
1143 Some(yf.display.clone()),
1144 line,
1145 "`dispatch` must be a list of `{macro_namespace, search_order}`",
1146 );
1147 return out;
1148 };
1149 for e in list {
1150 let ns = e.get("macro_namespace").and_then(Value::as_str);
1151 let order: Option<Vec<String>> = e
1152 .get("search_order")
1153 .and_then(Value::as_sequence)
1154 .map(|s| s.iter().filter_map(|x| x.as_str().map(str::to_string)).collect());
1155 match (ns, order) {
1156 (Some(ns), Some(order)) if !order.is_empty() => {
1157 out.insert(ns.to_string(), order);
1158 }
1159 _ => self.diags.error(
1160 "invalid-field",
1161 Some(yf.display.clone()),
1162 line,
1163 "each `dispatch` entry needs `macro_namespace` and a non-empty `search_order` list",
1164 ),
1165 }
1166 }
1167 out
1168 }
1169
1170 fn check_macro_namespaces(&mut self, project: &Project) {
1173 let own: BTreeSet<String> = project
1174 .macros
1175 .iter()
1176 .filter_map(|m| std::fs::read_to_string(self.root.join(m)).ok())
1177 .flat_map(|src| preflight::macro_defs(&src).into_iter().map(|d| d.name))
1178 .collect();
1179 for p in &project.packages {
1180 let clash = if RESERVED_NAMES.contains(&p.name.as_str()) {
1181 Some("a DRE function or variable".to_string())
1182 } else if own.contains(&p.name) {
1183 Some("a macro in macros/".to_string())
1184 } else if p.name == project.name {
1185 Some("this project".to_string())
1186 } else {
1187 None
1188 };
1189 if let Some(c) = clash {
1190 self.diags.error(
1191 "package-name-clash",
1192 None,
1193 None,
1194 format!(
1195 "package `{}` has the same name as {c}; macros couldn't be called through it",
1196 p.name
1197 ),
1198 );
1199 }
1200 }
1201 for (ns, order) in &project.dispatch {
1202 for n in order {
1203 if n != &project.name && !project.packages.iter().any(|p| &p.name == n) {
1204 self.diags.error(
1205 "invalid-field",
1206 Some(PathBuf::from(PROJECT_FILE)),
1207 None,
1208 format!("`dispatch` for `{ns}` searches `{n}`, which is neither this project nor an installed package"),
1209 );
1210 }
1211 }
1212 }
1213 }
1214
1215 fn opt_vars(&mut self, yf: &YamlFile, v: Option<&Value>, key: &str) -> JsonMap<String, Json> {
1216 match v {
1217 None | Some(Value::Null) => JsonMap::new(),
1218 Some(Value::Mapping(m)) => yaml_map_to_json(m),
1219 Some(_) => {
1220 self.diags.error(
1221 "invalid-field",
1222 Some(yf.display.clone()),
1223 yf.line_of(key, None),
1224 format!("`{key}` must be a map"),
1225 );
1226 JsonMap::new()
1227 }
1228 }
1229 }
1230
1231 fn parse_folder_config(
1232 &mut self,
1233 yf: &Rc<YamlFile>,
1234 folders: &[Vec<String>],
1235 ) -> BTreeMap<Vec<String>, FolderCfg> {
1236 let mut out = BTreeMap::new();
1237 let Some(tree) = yf.value.get("reports") else {
1238 return out;
1239 };
1240 let mut stack = vec![(Vec::<String>::new(), tree.clone(), yf.line_of("reports", None))];
1241 while let Some((path, node, line)) = stack.pop() {
1242 let Some(m) = node.as_mapping() else {
1243 self.diags.error(
1244 "invalid-folder-config",
1245 Some(yf.display.clone()),
1246 line,
1247 format!("folder config for `{}` must be a map", dotted(&path)),
1248 );
1249 continue;
1250 };
1251 let mut cfg = FolderCfg::default();
1252 let mut any = false;
1253 for (k, v) in m {
1254 let Some(k) = k.as_str() else { continue };
1255 let kline = yf.line_of(k, line);
1256 if let Some(key) = k.strip_prefix('+') {
1257 any = true;
1258 if !FOLDER_CONFIG_KEYS.contains(&k) {
1259 self.diags.error(
1260 "unknown-key",
1261 Some(yf.display.clone()),
1262 kline,
1263 format!(
1264 "unknown folder config `{k}`; folder config keys are {}",
1265 FOLDER_CONFIG_KEYS.join(", ")
1266 ),
1267 );
1268 continue;
1269 }
1270 let bad = |s: &mut Self, what: &str| {
1271 s.diags.error(
1272 "invalid-field",
1273 Some(yf.display.clone()),
1274 kline,
1275 format!("`{k}` must be {what}"),
1276 )
1277 };
1278 match key {
1279 "tags" => match string_list(v) {
1280 Some(t) => cfg.tags = t,
1281 None => bad(self, "a list of strings"),
1282 },
1283 "output" => match v.as_mapping() {
1284 Some(o) => cfg.output = Some(o.clone()),
1285 None => bad(self, "a map"),
1286 },
1287 "profile" => match v.as_str() {
1288 Some(p) => cfg.profile = Some((p.to_string(), kline)),
1289 None => bad(self, "a string"),
1290 },
1291 "schedule" => {
1292 let ctx = format!("folder `{}`: `+schedule`", dotted(&path));
1293 self.moved_to_schedules(&yf.display, kline, &ctx);
1294 }
1295 "timezone" => {
1296 cfg.timezone = self.timezone_value(v, &yf.display, kline, "`+timezone`");
1297 }
1298 _ => match v.as_mapping() {
1299 Some(s) => cfg.vars = Some(s.clone()),
1300 None => bad(self, "a map"),
1301 },
1302 }
1303 } else {
1304 let mut p = path.clone();
1305 p.push(k.to_string());
1306 stack.push((p, v.clone(), kline));
1307 }
1308 }
1309 if any && !path.is_empty() {
1310 if !folders.contains(&path) {
1311 self.diags.warning(
1312 "unknown-folder",
1313 Some(yf.display.clone()),
1314 line,
1315 format!(
1316 "folder config for `{}` matches no folder under reports/",
1317 dotted(&path)
1318 ),
1319 );
1320 }
1321 out.insert(path, cfg);
1322 } else if any {
1323 out.insert(path, cfg);
1324 }
1325 }
1326 out
1327 }
1328
1329 fn discover(&mut self) -> Discovered {
1332 let mut d = Discovered {
1333 yaml: Vec::new(),
1334 sql: Vec::new(),
1335 macros: Vec::new(),
1336 lookups: Vec::new(),
1337 folders: Vec::new(),
1338 sources: Vec::new(),
1339 };
1340 let target_inside = self.target_inside.clone();
1341 let root = self.root.clone();
1342 let walker = walkdir::WalkDir::new(&self.root)
1343 .sort_by_file_name()
1344 .into_iter()
1345 .filter_entry(|e| {
1346 let name = e.file_name().to_string_lossy();
1347 e.depth() == 0
1349 || !(name.starts_with('.')
1350 || target_inside
1351 .as_deref()
1352 .is_some_and(|t| e.path().strip_prefix(&root).is_ok_and(|r| r == t))
1353 || (e.depth() == 1 && [TARGET_DIR, DEPS_DIR, LOGS_DIR].contains(&name.as_ref())))
1354 });
1355 for e in walker.filter_map(Result::ok) {
1356 let rel = self.rel(e.path());
1357 let in_reports = rel.starts_with(REPORTS_DIR);
1358 if e.file_type().is_dir() {
1359 if in_reports && rel != Path::new(REPORTS_DIR) {
1360 d.folders.push(folder_segments(&rel));
1361 }
1362 continue;
1363 }
1364 let ext = e.path().extension().and_then(|x| x.to_str()).unwrap_or("");
1365 let yaml = matches!(ext, "yml" | "yaml") && rel != Path::new(crate::profiles::PROFILES_FILE);
1366 if yaml || in_reports || rel.starts_with(MACROS_DIR) || rel.starts_with(LOOKUPS_DIR) {
1367 d.sources.push(rel.clone());
1368 }
1369 if rel.starts_with(LOOKUPS_DIR) {
1370 d.lookups.push(rel);
1371 continue;
1372 }
1373 match ext {
1374 "yml" | "yaml"
1376 if rel != Path::new(PROJECT_FILE) && rel != Path::new(crate::profiles::PROFILES_FILE) =>
1377 {
1378 d.yaml.push(e.path().to_path_buf())
1379 }
1380 "sql" if rel.starts_with(MACROS_DIR) => d.macros.push(rel),
1381 "sql" if in_reports => d.sql.push(rel),
1382 _ => {}
1383 }
1384 }
1385 d
1386 }
1387
1388 fn classify(
1389 &mut self,
1390 yf: Rc<YamlFile>,
1391 fragments: &mut Vec<Fragment>,
1392 plugins: &mut Vec<(Rc<YamlFile>, Mapping)>,
1393 sets: &mut Vec<Rc<YamlFile>>,
1394 schedules: &mut Vec<Rc<YamlFile>>,
1395 timings: &mut Vec<Rc<YamlFile>>,
1396 ) {
1397 let in_reports = yf.display.starts_with(REPORTS_DIR);
1398 let timings_file = yf.display.file_name() == Some(std::ffi::OsStr::new(TIMINGS_FILE));
1399 match &yf.value {
1400 Value::Null => {}
1401 Value::Sequence(items)
1402 if !items.is_empty()
1403 && items
1404 .iter()
1405 .all(|i| ["name", "select", "report"].iter().any(|k| i.get(k).is_some())) =>
1406 {
1407 schedules.push(yf.clone());
1408 }
1409 Value::Mapping(m) => {
1410 let pl = pick(&yf.value, PLUGIN_KEYS);
1411 if !pl.is_empty() {
1412 plugins.push((yf.clone(), pl));
1413 }
1414 let dependency_file = packages::DEPENDENCY_FILES
1416 .iter()
1417 .any(|f| yf.display == Path::new(f));
1418 if !dependency_file && m.contains_key("packages") {
1419 self.diags.error(
1420 "misplaced-packages",
1421 Some(yf.display.clone()),
1422 yf.line_of("packages", None),
1423 "`packages:` goes in dependencies.yml or packages.yml at the project root",
1424 );
1425 }
1426 if dependency_file {
1427 for k in m.keys().filter_map(Value::as_str) {
1428 if OLD_PLUGIN_KEYS.contains(&k) {
1429 self.old_plugin_key(&yf, k);
1430 }
1431 }
1432 }
1433 let has_sources = m.contains_key(SOURCES_KEY);
1434 let rest: Mapping = m
1435 .iter()
1436 .filter(|(k, _)| !is_one_of(k, PLUGIN_KEYS) && k.as_str() != Some("packages"))
1437 .filter(|(k, _)| k.as_str() != Some(SOURCES_KEY))
1438 .filter(|(k, _)| !(has_sources && k.as_str() == Some("version")))
1440 .filter(|(k, _)| !(dependency_file && is_one_of(k, OLD_PLUGIN_KEYS)))
1441 .map(|(k, v)| (k.clone(), v.clone()))
1442 .collect();
1443 if rest.is_empty() {
1444 return;
1445 }
1446 let looks_like_report = rest.contains_key("queries") || rest.contains_key("name");
1447 if !in_reports && (timings_file || is_timing_registry(&rest)) {
1448 timings.push(yf.clone());
1449 } else if in_reports || looks_like_report {
1450 self.report_fragment(yf.clone(), rest, fragments);
1451 } else if is_set_registry(&rest) {
1452 sets.push(yf.clone());
1453 } else {
1454 self.diags.warning(
1455 "unrecognized-yaml",
1456 Some(yf.display.clone()),
1457 None,
1458 "not recognised as report, Set, plugin or schedule config; ignored",
1459 );
1460 }
1461 }
1462 _ => self.diags.warning(
1463 "unrecognized-yaml",
1464 Some(yf.display.clone()),
1465 None,
1466 "not recognised as report, Set, plugin or schedule config; ignored",
1467 ),
1468 }
1469 }
1470
1471 fn report_fragment(&mut self, yf: Rc<YamlFile>, map: Mapping, out: &mut Vec<Fragment>) {
1472 for k in map.keys().filter_map(Value::as_str) {
1473 if !REPORT_KEYS.contains(&k) {
1474 self.diags.error(
1475 "unknown-key",
1476 Some(yf.display.clone()),
1477 yf.line_of(k, None),
1478 format!("unknown report key `{k}`"),
1479 );
1480 }
1481 }
1482 let folder = folder_segments(yf.display.parent().unwrap_or(Path::new("")));
1483 let (name, explicit_name) = match map.get("name") {
1484 Some(Value::String(s)) if !s.is_empty() => (s.clone(), true),
1485 Some(_) => {
1486 self.diags.error(
1487 "invalid-field",
1488 Some(yf.display.clone()),
1489 yf.line_of("name", None),
1490 "`name` must be a non-empty string",
1491 );
1492 return;
1493 }
1494 None => match folder.last() {
1495 Some(f) => (f.clone(), false),
1496 None => {
1497 self.diags.error(
1498 "missing-field",
1499 Some(yf.display.clone()),
1500 None,
1501 "a report outside a report folder needs an explicit `name:`",
1502 );
1503 return;
1504 }
1505 },
1506 };
1507 out.push(Fragment {
1508 file: yf,
1509 name,
1510 explicit_name,
1511 folder,
1512 map,
1513 });
1514 }
1515
1516 fn parse_sources(&mut self, files: &[Rc<YamlFile>]) -> BTreeMap<String, SourceDef> {
1521 let mut out: BTreeMap<String, SourceDef> = BTreeMap::new();
1522 for yf in files {
1523 let file = Some(yf.display.clone());
1524 let top = yf.line_of(SOURCES_KEY, None);
1525 let items = match yf.value.get(SOURCES_KEY) {
1526 Some(Value::Sequence(items)) => items,
1527 Some(Value::Null) => continue,
1528 _ => {
1529 self.diags.error(
1530 "invalid-source",
1531 file,
1532 top,
1533 "`sources` must be a list of sources, as in dbt: `- name: sales` with `tables:`",
1534 );
1535 continue;
1536 }
1537 };
1538 if items.iter().all(Value::is_string) {
1539 self.diags.error(
1541 "moved-plugin-declaration",
1542 file,
1543 top,
1544 "`sources:` declares tables now (dbt's format); list plugin packages under `plugins:` instead (e.g. `plugins: [duckdb]`)",
1545 );
1546 continue;
1547 }
1548 let mut unsupported: BTreeSet<String> = BTreeSet::new();
1549 for item in items {
1550 if let Some(src) = self.source_def(yf, item, &mut unsupported) {
1551 if let Some(prev) = out.get(&src.name) {
1552 self.diags.error(
1553 "duplicate-source",
1554 file.clone(),
1555 src.line,
1556 format!(
1557 "source `{}` is also declared in {}; source names must be unique",
1558 src.name,
1559 prev.file.display()
1560 ),
1561 );
1562 continue;
1563 }
1564 out.insert(src.name.clone(), src);
1565 }
1566 }
1567 if !unsupported.is_empty() {
1568 self.diags.warning(
1569 "source-key-not-supported",
1570 file,
1571 top,
1572 format!(
1573 "dbt source keys DRE doesn't support yet are ignored: {}",
1574 unsupported.into_iter().collect::<Vec<_>>().join(", ")
1575 ),
1576 );
1577 }
1578 }
1579 out
1580 }
1581
1582 fn source_def(
1583 &mut self,
1584 yf: &YamlFile,
1585 item: &Value,
1586 unsupported: &mut BTreeSet<String>,
1587 ) -> Option<SourceDef> {
1588 let file = Some(yf.display.clone());
1589 let top = yf.line_of(SOURCES_KEY, None);
1590 let Some(m) = item.as_mapping() else {
1591 self.diags.error(
1592 "invalid-source",
1593 file,
1594 top,
1595 "each source must be a map with `name:` and `tables:`",
1596 );
1597 return None;
1598 };
1599 let Some(name) = m.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()) else {
1600 self.diags
1601 .error("invalid-source", file, top, "every source needs a `name`");
1602 return None;
1603 };
1604 let line = yf.line_containing(&format!("name: {name}")).or(top);
1605 let ctx = format!("source `{name}`");
1606 let mut ok = self.source_keys(
1607 yf,
1608 m,
1609 &ctx,
1610 line,
1611 SOURCE_KEYS,
1612 DBT_SOURCE_KEYS,
1613 "source",
1614 unsupported,
1615 );
1616 let mut text = |s: &mut Self, k: &str| -> Option<String> {
1617 match m.get(k) {
1618 None | Some(Value::Null) => None,
1619 Some(v) => match crate::yaml::scalar_str(v) {
1620 Some(t) if !v.is_mapping() && !v.is_sequence() => Some(t),
1621 _ => {
1622 s.diags.error(
1623 "invalid-source",
1624 file.clone(),
1625 line,
1626 format!("{ctx}: `{k}` must be a string"),
1627 );
1628 ok = false;
1629 None
1630 }
1631 },
1632 }
1633 };
1634 let description = text(self, "description");
1635 let database = text(self, "database");
1636 let schema = text(self, "schema");
1637 let profile = text(self, "profile");
1638 let quoting = self.quoting(yf, m, &ctx, line);
1639 let tags = self.source_tags(yf, m, &ctx, line);
1640 let meta = self.source_meta(yf, m, &ctx, line);
1641 let mut tables = Vec::new();
1642 match m.get("tables") {
1643 None | Some(Value::Null) => {}
1644 Some(Value::Sequence(ts)) => {
1645 let mut names = BTreeSet::new();
1647 for n in ts.iter().filter_map(|t| t.get("name").and_then(Value::as_str)) {
1648 if !names.insert(n) {
1649 self.diags.error(
1650 "duplicate-source-table",
1651 file.clone(),
1652 yf.line_containing(&format!("name: {n}")).or(line),
1653 format!("{ctx} declares table `{n}` twice"),
1654 );
1655 ok = false;
1656 }
1657 }
1658 for t in ts {
1659 if let Some(t) = self.source_table(yf, name, t, line, unsupported)
1660 && !tables.iter().any(|x: &SourceTableDef| x.name == t.name)
1661 {
1662 tables.push(t);
1663 }
1664 }
1665 }
1666 Some(_) => {
1667 self.diags.error(
1668 "invalid-source",
1669 file.clone(),
1670 line,
1671 format!("{ctx}: `tables` must be a list"),
1672 );
1673 ok = false;
1674 }
1675 }
1676 ok.then(|| SourceDef {
1677 name: name.to_string(),
1678 description,
1679 database,
1680 schema,
1681 profile,
1682 quoting,
1683 tags,
1684 meta,
1685 tables,
1686 file: yf.display.clone(),
1687 line,
1688 })
1689 }
1690
1691 fn source_table(
1692 &mut self,
1693 yf: &YamlFile,
1694 source: &str,
1695 item: &Value,
1696 source_line: Option<usize>,
1697 unsupported: &mut BTreeSet<String>,
1698 ) -> Option<SourceTableDef> {
1699 let file = Some(yf.display.clone());
1700 let Some(m) = item.as_mapping() else {
1701 self.diags.error(
1702 "invalid-source",
1703 file,
1704 source_line,
1705 format!("source `{source}`: each table must be a map with a `name`"),
1706 );
1707 return None;
1708 };
1709 let Some(name) = m.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()) else {
1710 self.diags.error(
1711 "invalid-source",
1712 file,
1713 source_line,
1714 format!("source `{source}`: every table needs a `name`"),
1715 );
1716 return None;
1717 };
1718 let line = yf.line_containing(&format!("name: {name}")).or(source_line);
1719 let ctx = format!("source `{source}`, table `{name}`");
1720 let mut ok = self.source_keys(
1721 yf,
1722 m,
1723 &ctx,
1724 line,
1725 SOURCE_TABLE_KEYS,
1726 DBT_SOURCE_TABLE_KEYS,
1727 "table",
1728 unsupported,
1729 );
1730 let mut text = |s: &mut Self, k: &str| -> Option<String> {
1731 match m.get(k) {
1732 None | Some(Value::Null) => None,
1733 Some(v) => match crate::yaml::scalar_str(v) {
1734 Some(t) if !v.is_mapping() && !v.is_sequence() => Some(t),
1735 _ => {
1736 s.diags.error(
1737 "invalid-source",
1738 file.clone(),
1739 line,
1740 format!("{ctx}: `{k}` must be a string"),
1741 );
1742 ok = false;
1743 None
1744 }
1745 },
1746 }
1747 };
1748 let identifier = text(self, "identifier");
1749 let description = text(self, "description");
1750 let quoting = self.quoting(yf, m, &ctx, line);
1751 let tags = self.source_tags(yf, m, &ctx, line);
1752 let meta = self.source_meta(yf, m, &ctx, line);
1753 let mut columns: Vec<SourceColumn> = Vec::new();
1754 match m.get("columns") {
1755 None | Some(Value::Null) => {}
1756 Some(Value::Sequence(cs)) => {
1757 for c in cs {
1758 let Some(cm) = c.as_mapping() else {
1759 self.diags.error(
1760 "invalid-source",
1761 file.clone(),
1762 line,
1763 format!("{ctx}: each column must be a map with a `name`"),
1764 );
1765 ok = false;
1766 continue;
1767 };
1768 let Some(cname) = cm.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()) else {
1769 self.diags.error(
1770 "invalid-source",
1771 file.clone(),
1772 line,
1773 format!("{ctx}: every column needs a `name`"),
1774 );
1775 ok = false;
1776 continue;
1777 };
1778 let cctx = format!("{ctx}, column `{cname}`");
1779 ok &= self.source_keys(
1780 yf,
1781 cm,
1782 &cctx,
1783 line,
1784 SOURCE_COLUMN_KEYS,
1785 DBT_SOURCE_COLUMN_KEYS,
1786 "column",
1787 unsupported,
1788 );
1789 if columns.iter().any(|c| c.name.eq_ignore_ascii_case(cname)) {
1790 self.diags.error(
1791 "duplicate-source-column",
1792 file.clone(),
1793 line,
1794 format!("{ctx} declares column `{cname}` twice"),
1795 );
1796 ok = false;
1797 continue;
1798 }
1799 let s = |k: &str| cm.get(k).and_then(crate::yaml::scalar_str);
1800 columns.push(SourceColumn {
1801 name: cname.to_string(),
1802 description: s("description"),
1803 data_type: s("data_type"),
1804 });
1805 }
1806 }
1807 Some(_) => {
1808 self.diags.error(
1809 "invalid-source",
1810 file.clone(),
1811 line,
1812 format!("{ctx}: `columns` must be a list"),
1813 );
1814 ok = false;
1815 }
1816 }
1817 ok.then(|| SourceTableDef {
1818 name: name.to_string(),
1819 identifier,
1820 description,
1821 quoting,
1822 tags,
1823 meta,
1824 columns,
1825 })
1826 }
1827
1828 #[allow(clippy::too_many_arguments)]
1831 fn source_keys(
1832 &mut self,
1833 yf: &YamlFile,
1834 m: &Mapping,
1835 ctx: &str,
1836 line: Option<usize>,
1837 known: &[&str],
1838 dbt: &[&str],
1839 level: &str,
1840 unsupported: &mut BTreeSet<String>,
1841 ) -> bool {
1842 let mut ok = true;
1843 for k in m.keys().filter_map(Value::as_str) {
1844 if known.contains(&k) {
1845 continue;
1846 }
1847 if dbt.contains(&k) {
1848 unsupported.insert(format!("{level} `{k}`"));
1849 continue;
1850 }
1851 self.diags.error(
1852 "unknown-key",
1853 Some(yf.display.clone()),
1854 yf.line_of(k, line).or(line),
1855 format!("{ctx}: unknown key `{k}`; a {level} takes {}", known.join(", ")),
1856 );
1857 ok = false;
1858 }
1859 ok
1860 }
1861
1862 fn quoting(&mut self, yf: &YamlFile, m: &Mapping, ctx: &str, line: Option<usize>) -> QuotingDef {
1863 let mut q = QuotingDef::default();
1864 let Some(v) = m.get("quoting") else { return q };
1865 let bad = |s: &mut Self, msg: String| {
1866 s.diags.error(
1867 "invalid-source",
1868 Some(yf.display.clone()),
1869 line,
1870 format!("{ctx}: {msg}"),
1871 )
1872 };
1873 let Some(qm) = v.as_mapping() else {
1874 bad(
1875 self,
1876 "`quoting` must be a map of `database`, `schema` and `identifier` to true or false".into(),
1877 );
1878 return q;
1879 };
1880 for (k, v) in qm {
1881 let k = k.as_str().unwrap_or_default();
1882 let Some(b) = v.as_bool() else {
1883 bad(self, format!("`quoting.{k}` must be true or false"));
1884 continue;
1885 };
1886 match k {
1887 "database" => q.database = Some(b),
1888 "schema" => q.schema = Some(b),
1889 "identifier" => q.identifier = Some(b),
1890 _ => bad(
1891 self,
1892 format!("unknown `quoting` key `{k}`; use `database`, `schema` or `identifier`"),
1893 ),
1894 }
1895 }
1896 q
1897 }
1898
1899 fn source_tags(&mut self, yf: &YamlFile, m: &Mapping, ctx: &str, line: Option<usize>) -> Vec<String> {
1900 match m.get("tags") {
1901 None | Some(Value::Null) => Vec::new(),
1902 Some(Value::String(t)) => vec![t.clone()],
1903 Some(v) => string_list(v).unwrap_or_else(|| {
1904 self.diags.error(
1905 "invalid-source",
1906 Some(yf.display.clone()),
1907 line,
1908 format!("{ctx}: `tags` must be a list of strings"),
1909 );
1910 Vec::new()
1911 }),
1912 }
1913 }
1914
1915 fn source_meta(
1916 &mut self,
1917 yf: &YamlFile,
1918 m: &Mapping,
1919 ctx: &str,
1920 line: Option<usize>,
1921 ) -> JsonMap<String, Json> {
1922 match m.get("meta") {
1923 None | Some(Value::Null) => JsonMap::new(),
1924 Some(Value::Mapping(mm)) => yaml_map_to_json(mm),
1925 Some(_) => {
1926 self.diags.error(
1927 "invalid-source",
1928 Some(yf.display.clone()),
1929 line,
1930 format!("{ctx}: `meta` must be a map"),
1931 );
1932 JsonMap::new()
1933 }
1934 }
1935 }
1936
1937 fn parse_pass(&mut self, project: &mut Project, used: &mut Usage) {
1943 let inputs = crate::parse::Inputs {
1944 target: project.target_name.clone(),
1945 cli_vars: self.opts.vars.clone(),
1946 date: self.opts.date,
1947 scheduled_at: self.opts.scheduled_at,
1948 timezone: self.opts.timezone.clone(),
1949 schedule: None,
1950 started_at: None,
1951 };
1952 project.inputs = inputs.clone();
1953 let failed: BTreeSet<PathBuf> = self
1954 .diags
1955 .iter()
1956 .filter(|d| d.severity == crate::Severity::Error)
1957 .filter_map(|d| d.file.clone())
1958 .collect();
1959 let mut results = Vec::new();
1960 for (ri, r) in project.reports.iter().enumerate() {
1961 for (bi, b) in r.bindings.iter().enumerate() {
1962 let parsed = crate::parse::binding(project, r, b, &b.vars, &inputs);
1963 results.push((ri, bi, parsed));
1964 }
1965 }
1966 for (ri, bi, parsed) in results {
1967 for p in &parsed.errors {
1968 if failed.contains(&p.file) {
1969 continue;
1970 }
1971 if p.code == crate::parse::PARSE_FAILED {
1975 let msg = crate::Diagnostic {
1976 severity: crate::Severity::Error,
1977 code: p.code,
1978 message: p.message.clone(),
1979 file: Some(p.file.clone()),
1980 line: p.line,
1981 plugin: None,
1982 };
1983 let errs = project
1984 .parse_errors
1985 .entry(project.reports[ri].name.clone())
1986 .or_default();
1987 if !errs.contains(&msg) {
1988 errs.push(msg);
1989 }
1990 continue;
1991 }
1992 self.diags
1993 .error(p.code, Some(p.file.clone()), p.line, p.message.clone());
1994 }
1995 for p in &parsed.warnings {
1996 self.diags
1997 .warning(p.code, Some(p.file.clone()), p.line, p.message.clone());
1998 }
1999 let file = Some(project.reports[ri].file.clone());
2000 if let Some(i) = &parsed.inherited {
2001 used.connection(i, file.clone(), None);
2002 }
2003 for q in &parsed.queries {
2004 if let Some(c) = &q.connection {
2005 used.connection(c, file.clone(), None);
2006 }
2007 }
2008 for d in parsed.destinations.iter().flatten() {
2009 used.destination(d, file.clone(), None);
2010 }
2011 project.reports[ri].bindings[bi].parsed = Some(std::sync::Arc::new(parsed));
2012 }
2013 }
2014
2015 fn parse_sets(&mut self, files: &[Rc<YamlFile>]) -> BTreeMap<String, SetDef> {
2018 let mut out = BTreeMap::new();
2019 let mut seen: BTreeMap<String, PathBuf> = BTreeMap::new();
2020 for yf in files {
2021 let Some(m) = yf.value.as_mapping() else { continue };
2022 for (k, v) in m {
2023 let Some(name) = k.as_str() else { continue };
2024 let line = yf.line_of(name, None);
2025 if let Some(prev) = seen.get(name) {
2026 self.diags.error(
2027 "duplicate-set",
2028 Some(yf.display.clone()),
2029 line,
2030 format!("Set `{name}` is also declared in {}", prev.display()),
2031 );
2032 continue;
2033 }
2034 seen.insert(name.to_string(), yf.display.clone());
2035 let profile = v.get("profile").and_then(Value::as_str).map(str::to_string);
2036 if v.get("profile").is_some() && profile.is_none() {
2037 self.diags.error(
2038 "invalid-field",
2039 Some(yf.display.clone()),
2040 line,
2041 format!("Set `{name}`: `profile` must be a string"),
2042 );
2043 }
2044 let vars = match v.get("vars") {
2045 Some(Value::Mapping(m)) => yaml_map_to_json(m),
2046 None | Some(Value::Null) => JsonMap::new(),
2047 Some(_) => {
2048 self.diags.error(
2049 "invalid-field",
2050 Some(yf.display.clone()),
2051 line,
2052 format!("Set `{name}`: `vars` must be a map"),
2053 );
2054 JsonMap::new()
2055 }
2056 };
2057 out.insert(
2058 name.to_string(),
2059 SetDef {
2060 profile,
2061 vars,
2062 file: yf.display.clone(),
2063 line,
2064 },
2065 );
2066 }
2067 }
2068 out
2069 }
2070
2071 fn check_refs(
2076 &mut self,
2077 sql: &[PathBuf],
2078 macros: &[PathBuf],
2079 index: &BTreeMap<String, Vec<PathBuf>>,
2080 lookups: &BTreeMap<String, Lookup>,
2081 ) -> BTreeSet<String> {
2082 let mut names = BTreeSet::new();
2083 let mut graph: BTreeMap<String, Vec<(String, usize)>> = BTreeMap::new();
2085 for file in sql.iter().chain(macros) {
2086 let Ok(src) = std::fs::read_to_string(self.root.join(file)) else {
2087 continue;
2088 };
2089 let is_sql = !file.starts_with(MACROS_DIR);
2090 for (name, line) in preflight::refs(&src) {
2091 if index.contains_key(&name) {
2092 if is_sql {
2093 let stem = file.file_stem().unwrap().to_string_lossy().to_string();
2094 graph.entry(stem).or_default().push((name.clone(), line));
2095 }
2096 names.insert(name);
2097 } else if !lookups.contains_key(&name) {
2098 self.diags.error(
2099 "unknown-ref",
2100 Some(file.clone()),
2101 Some(line),
2102 format!("`ref('{name}')`: there's no `{name}.sql` under reports/ and no lookup `{name}` under lookups/"),
2103 );
2104 }
2105 }
2106 }
2107 let mut reported: BTreeSet<Vec<String>> = BTreeSet::new();
2109 for start in graph.keys() {
2110 let mut path = vec![start.clone()];
2111 let mut stack = vec![graph[start].iter()];
2112 while let Some(it) = stack.last_mut() {
2113 let Some((next, line)) = it.next() else {
2114 stack.pop();
2115 path.pop();
2116 continue;
2117 };
2118 if let Some(i) = path.iter().position(|n| n == next) {
2119 let mut cycle = path[i..].to_vec();
2120 let mut key = cycle.clone();
2121 key.sort();
2122 if reported.insert(key) {
2123 cycle.push(next.clone());
2124 let file = index[path.last().unwrap()][0].clone();
2125 self.diags.error(
2126 "ref-cycle",
2127 Some(file),
2128 Some(*line),
2129 format!("`ref()` cycle: {}", cycle.join(" → ")),
2130 );
2131 }
2132 continue;
2133 }
2134 if let Some(edges) = graph.get(next) {
2135 path.push(next.clone());
2136 stack.push(edges.iter());
2137 }
2138 }
2139 }
2140 names
2141 }
2142
2143 fn index_sql(&mut self, sql: &[PathBuf]) -> BTreeMap<String, Vec<PathBuf>> {
2144 let mut index: BTreeMap<String, Vec<PathBuf>> = BTreeMap::new();
2145 for p in sql {
2146 let stem = p.file_stem().unwrap().to_string_lossy().to_string();
2147 index.entry(stem).or_default().push(p.clone());
2148 }
2149 for (name, paths) in &index {
2150 if paths.len() > 1 {
2151 self.diags.error(
2152 "duplicate-sql-name",
2153 Some(paths[1].clone()),
2154 None,
2155 format!(
2156 "`{name}.sql` exists more than once: {}; .sql basenames must be unique project-wide",
2157 paths
2158 .iter()
2159 .map(|p| p.display().to_string())
2160 .collect::<Vec<_>>()
2161 .join(", ")
2162 ),
2163 );
2164 }
2165 }
2166 index
2167 }
2168
2169 fn merge_reports(&mut self, fragments: Vec<Fragment>) -> Vec<RawReport> {
2172 let mut by_name: BTreeMap<String, Vec<Fragment>> = BTreeMap::new();
2173 for f in fragments {
2174 by_name.entry(f.name.clone()).or_default().push(f);
2175 }
2176 let mut out = Vec::new();
2177 for (name, frags) in by_name {
2178 let definers: Vec<&Fragment> = frags.iter().filter(|f| f.map.contains_key("queries")).collect();
2179 if definers.len() > 1 {
2180 self.diags.error(
2181 "duplicate-report-name",
2182 Some(definers[1].file.display.clone()),
2183 definers[1].file.line_of("queries", None),
2184 format!(
2185 "report `{name}` is declared in both {} and {}; report names must be unique project-wide",
2186 definers[0].file.display.display(),
2187 definers[1].file.display.display()
2188 ),
2189 );
2190 continue;
2191 }
2192 let Some(def) = definers.first() else {
2193 for f in &frags {
2194 let msg = if f.explicit_name {
2195 format!(
2196 "report `{name}` has no `queries`; a config fragment must name a report that declares `queries:`"
2197 )
2198 } else {
2199 format!("report `{name}` has no `queries`")
2200 };
2201 self.diags
2202 .error("missing-queries", Some(f.file.display.clone()), None, msg);
2203 }
2204 continue;
2205 };
2206 let mut keys: BTreeMap<String, Located<Value>> = BTreeMap::new();
2207 let mut ok = true;
2208 for f in &frags {
2209 for (k, v) in &f.map {
2210 let Some(k) = k.as_str() else { continue };
2211 if k == "name" || !REPORT_KEYS.contains(&k) {
2212 continue;
2213 }
2214 if let Some(prev) = keys.get(k) {
2215 self.diags.error(
2216 "conflicting-declaration",
2217 Some(f.file.display.clone()),
2218 f.file.line_of(k, None),
2219 format!(
2220 "`{k}` for report `{name}` is declared in both {} and {}",
2221 prev.file.display.display(),
2222 f.file.display.display()
2223 ),
2224 );
2225 ok = false;
2226 continue;
2227 }
2228 keys.insert(
2229 k.to_string(),
2230 Located {
2231 value: v.clone(),
2232 file: f.file.clone(),
2233 },
2234 );
2235 }
2236 }
2237 let _ = ok;
2238 out.push(RawReport {
2239 name,
2240 file: def.file.clone(),
2241 folder: def.folder.clone(),
2242 keys,
2243 });
2244 }
2245 out
2246 }
2247
2248 fn resolve_queries(
2249 &mut self,
2250 r: &RawReport,
2251 index: &BTreeMap<String, Vec<PathBuf>>,
2252 referenced: &mut BTreeSet<String>,
2253 ) -> Vec<QueryEntry> {
2254 let Some(q) = r.keys.get("queries") else {
2255 return Vec::new();
2256 };
2257 let yf = q.file.clone();
2258 let Some(items) = q.value.as_sequence() else {
2259 self.diags.error(
2260 "invalid-field",
2261 Some(yf.display.clone()),
2262 yf.line_of("queries", None),
2263 format!("report `{}`: `queries` must be a list", r.name),
2264 );
2265 return Vec::new();
2266 };
2267 if items.is_empty() {
2268 self.diags.error(
2269 "missing-queries",
2270 Some(yf.display.clone()),
2271 yf.line_of("queries", None),
2272 format!("report `{}` has an empty `queries` list", r.name),
2273 );
2274 }
2275 let mut out = Vec::new();
2276 for item in items {
2277 if let Some(e) = self.query_entry(&r.name, item, &yf, index) {
2278 referenced.insert(e.query.clone());
2279 out.push(e);
2280 } else if let Some(n) = entry_name(item) {
2281 referenced.insert(n);
2282 }
2283 }
2284 out
2285 }
2286
2287 fn query_entry(
2288 &mut self,
2289 report: &str,
2290 item: &Value,
2291 yf: &YamlFile,
2292 index: &BTreeMap<String, Vec<PathBuf>>,
2293 ) -> Option<QueryEntry> {
2294 let file = Some(yf.display.clone());
2295 let (name, m) = match item {
2296 Value::String(s) => (s.clone(), None),
2297 Value::Mapping(m) => match m.get("query").and_then(Value::as_str) {
2298 Some(s) => (s.to_string(), Some(m)),
2299 None => {
2300 self.diags.error(
2301 "invalid-field",
2302 file,
2303 yf.line_of("queries", None),
2304 format!("report `{report}`: a `queries` map entry needs a `query:` name"),
2305 );
2306 return None;
2307 }
2308 },
2309 _ => {
2310 self.diags.error(
2311 "invalid-field",
2312 file,
2313 yf.line_of("queries", None),
2314 format!("report `{report}`: `queries` entries must be names or maps"),
2315 );
2316 return None;
2317 }
2318 };
2319 let line = yf.line_containing(&name);
2320 if name.ends_with(".sql") || name.contains('/') || name.contains('\\') {
2321 self.diags.error(
2322 "invalid-query-name",
2323 file,
2324 line,
2325 format!(
2326 "report `{report}`: query `{name}` must be a bare .sql basename, with no path and no extension"
2327 ),
2328 );
2329 return None;
2330 }
2331 let mut e = QueryEntry {
2332 query: name.clone(),
2333 path: PathBuf::new(),
2334 profile: None,
2335 tab: true,
2336 tab_name: None,
2337 anchor: None,
2338 header: None,
2339 columns: BTreeMap::new(),
2340 };
2341 if let Some(m) = m {
2342 for k in m.keys().filter_map(Value::as_str) {
2343 if !QUERY_ENTRY_KEYS.contains(&k) {
2344 self.diags.error(
2345 "unknown-key",
2346 file.clone(),
2347 line,
2348 format!("report `{report}`: unknown key `{k}` on query `{name}`"),
2349 );
2350 }
2351 }
2352 match m.get("profile") {
2353 None => {}
2354 Some(Value::String(p)) if !p.trim().is_empty() => e.profile = Some(p.clone()),
2355 Some(_) => self.diags.error(
2356 "invalid-field",
2357 file.clone(),
2358 line,
2359 format!("report `{report}`: `profile` of `{name}` must be a connection name"),
2360 ),
2361 }
2362 e.tab_name = match m.get("tab_name") {
2363 None => None,
2364 Some(Value::String(s)) => Some(s.clone()),
2365 Some(Value::Sequence(_)) => {
2366 self.diags.error(
2367 "invalid-field",
2368 file.clone(),
2369 line,
2370 format!("report `{report}`: {}", one_tab_per_file(&name)),
2371 );
2372 None
2373 }
2374 Some(_) => {
2375 self.diags.error(
2376 "invalid-field",
2377 file.clone(),
2378 line,
2379 format!("report `{report}`: `tab_name` of `{name}` must be a string"),
2380 );
2381 None
2382 }
2383 };
2384 match m.get("tab").map(Value::as_bool) {
2385 None => {}
2386 Some(Some(b)) => e.tab = b,
2387 Some(None) => self.diags.error(
2388 "invalid-field",
2389 file.clone(),
2390 line,
2391 format!("report `{report}`: `tab` of `{name}` must be true or false"),
2392 ),
2393 }
2394 if !e.tab && e.tab_name.is_some() {
2395 self.diags.error(
2396 "invalid-field",
2397 file.clone(),
2398 line,
2399 format!("report `{report}`: `{name}` has `tab: false`, so its `tab_name` would never be used; remove one of them"),
2400 );
2401 }
2402 if let Some(a) = m.get("anchor") {
2403 match a.as_str() {
2404 Some(s) if options::is_cell(s) => e.anchor = Some(s.to_string()),
2405 _ => self.diags.error(
2406 "invalid-cell",
2407 file.clone(),
2408 line,
2409 format!("report `{report}`: `anchor` of `{name}` must be a cell reference like `A1`"),
2410 ),
2411 }
2412 }
2413 if let Some(h) = m.get("header") {
2414 match h.as_bool() {
2415 Some(b) => e.header = Some(b),
2416 None => self.diags.error(
2417 "invalid-field",
2418 file.clone(),
2419 line,
2420 format!("report `{report}`: `header` of `{name}` must be true or false"),
2421 ),
2422 }
2423 }
2424 if let Some(c) = m.get("columns") {
2425 let (columns, errs) = dre_protocol::options::parse_columns(&yaml_to_json(c));
2426 for err in errs {
2427 self.diags.error(
2428 "invalid-field",
2429 file.clone(),
2430 line,
2431 format!("report `{report}`: query `{name}`: {err}"),
2432 );
2433 }
2434 e.columns = columns;
2435 }
2436 }
2437 match index.get(&name) {
2438 Some(paths) => {
2439 e.path = paths[0].clone();
2440 Some(e)
2441 }
2442 None => {
2443 self.diags.error(
2444 "unknown-query",
2445 file,
2446 line,
2447 format!("report `{report}`: query `{name}` doesn't match any .sql file under reports/"),
2448 );
2449 None
2450 }
2451 }
2452 }
2453
2454 fn resolve_managed(
2457 &mut self,
2458 r: &RawReport,
2459 queries: Vec<QueryEntry>,
2460 project: &Project,
2461 folders: &BTreeMap<Vec<String>, FolderCfg>,
2462 used: &mut Usage,
2463 ) -> Option<Report> {
2464 let name = r.name.clone();
2465 let layers = folder_layers(folders, &r.folder);
2466 let key = |k: &str| r.keys.get(k);
2467 let located = |k: &str| {
2468 r.keys
2469 .get(k)
2470 .map(|l| (l.file.display.clone(), l.file.line_of(k, None)))
2471 };
2472
2473 let mut tags: Vec<String> = layers.iter().flat_map(|l| l.tags.clone()).collect();
2474 if let Some(t) = key("tags") {
2475 match string_list(&t.value) {
2476 Some(l) => tags.extend(l),
2477 None => {
2478 let (f, l) = located("tags").unwrap();
2479 self.diags.error(
2480 "invalid-field",
2481 Some(f),
2482 l,
2483 format!("report `{name}`: `tags` must be a list of strings"),
2484 );
2485 }
2486 }
2487 }
2488 dedup(&mut tags);
2489
2490 if key("profile").is_some() && key("sets").is_some() {
2491 let (f, l) = located("sets").unwrap();
2492 self.diags.error(
2493 "profile-and-sets",
2494 Some(f),
2495 l,
2496 format!("report `{name}` declares both `profile:` and `sets:`; use one or the other"),
2497 );
2498 }
2499
2500 let report_profile = match key("profile") {
2501 Some(p) => match p.value.as_str() {
2502 Some(s) => {
2503 let (f, l) = located("profile").unwrap();
2504 used.connection(s, Some(f), l);
2505 Some(s.to_string())
2506 }
2507 None => {
2508 let (f, l) = located("profile").unwrap();
2509 self.diags.error(
2510 "invalid-field",
2511 Some(f),
2512 l,
2513 format!("report `{name}`: `profile` must be a string"),
2514 );
2515 None
2516 }
2517 },
2518 None => None,
2519 };
2520 let folder_profile = layers
2521 .iter()
2522 .rev()
2523 .find_map(|l| l.profile.as_ref().map(|p| p.0.clone()));
2524 let report_timezone = match key("timezone") {
2525 Some(t) => {
2526 let (f, l) = located("timezone").unwrap();
2527 self.timezone_value(&t.value, &f, l, &format!("report `{name}`: `timezone`"))
2528 }
2529 None => None,
2530 };
2531 let timezone = report_timezone
2532 .or_else(|| layers.iter().rev().find_map(|l| l.timezone.clone()))
2533 .or_else(|| project.timezone.clone());
2534 let profile_at = if report_profile.is_some() {
2535 located("profile").map(|(file, line)| ProfileAt {
2536 file,
2537 line,
2538 key: "`profile`".into(),
2539 })
2540 } else {
2541 inherited_profile_at(&layers, project)
2542 };
2543 let base_profile = report_profile
2544 .or(folder_profile)
2545 .or(project.default_profile.clone());
2546
2547 let mut vars = project.vars.clone();
2548 for l in &layers {
2549 if let Some(v) = &l.vars {
2550 vars.extend(yaml_map_to_json(v));
2551 }
2552 }
2553 if let Some(v) = key("vars") {
2554 match v.value.as_mapping() {
2555 Some(m) => vars.extend(yaml_map_to_json(m)),
2556 None => {
2557 let (f, l) = located("vars").unwrap();
2558 self.diags.error(
2559 "invalid-field",
2560 Some(f),
2561 l,
2562 format!("report `{name}`: `vars` must be a map"),
2563 );
2564 }
2565 }
2566 }
2567
2568 let mut output = builtin_output();
2569 let mut merge_problems = Vec::new();
2570 if let Some(o) = project_default_output(project) {
2571 merge_problems.extend(merge_output(&mut output, &o));
2572 }
2573 for l in &layers {
2574 if let Some(o) = &l.output {
2575 merge_problems.extend(merge_output(&mut output, o));
2576 }
2577 }
2578 for p in merge_problems {
2579 self.diags.error(
2580 "invalid-field",
2581 Some(r.file.display.clone()),
2582 None,
2583 format!("report `{name}`: folder config: {p}"),
2584 );
2585 }
2586 if let Some(o) = key("output") {
2587 match o.value.as_mapping() {
2588 Some(m) => {
2589 if let Some(p) = merge_output(&mut output, m) {
2590 let (f, l) = located("output").unwrap();
2591 self.diags
2592 .error("invalid-field", Some(f), l, format!("report `{name}`: {p}"));
2593 }
2594 }
2595 None => {
2596 let (f, l) = located("output").unwrap();
2597 self.diags.error(
2598 "invalid-field",
2599 Some(f),
2600 l,
2601 format!("report `{name}`: `output` must be a map"),
2602 );
2603 }
2604 }
2605 }
2606
2607 if key("schedule").is_some() {
2608 let (f, l) = located("schedule").unwrap();
2609 self.moved_to_schedules(&f, l, &format!("report `{name}`: `schedule`"));
2610 }
2611
2612 let default_set = match key("default_set") {
2613 Some(d) => d.value.as_str().map(str::to_string),
2614 None => None,
2615 };
2616
2617 let base = BindingBase {
2618 profile: base_profile,
2619 profile_at,
2620 vars,
2621 output,
2622 };
2623 let has_sets = key("sets").is_some();
2624 let report_base = self.silent_binding(&name, &base, &queries, &r.file.display);
2625 let mut bindings = Vec::new();
2626 if let Some(s) = key("sets") {
2627 bindings = self.resolve_sets(&name, s, &queries, &base, project, used);
2628 let declared: Vec<&str> = bindings.iter().filter_map(|b| b.set.as_deref()).collect();
2629 if let Some(d) = &default_set
2630 && !declared.contains(&d.as_str())
2631 {
2632 let (f, l) = located("default_set").unwrap();
2633 self.diags.error(
2634 "unknown-default-set",
2635 Some(f),
2636 l,
2637 format!("report `{name}`: `default_set` `{d}` isn't one of the report's `sets:`"),
2638 );
2639 }
2640 } else {
2641 if default_set.is_some() {
2642 let (f, l) = located("default_set").unwrap();
2643 self.diags.error(
2644 "unknown-default-set",
2645 Some(f),
2646 l,
2647 format!("report `{name}`: `default_set` needs a `sets:` list"),
2648 );
2649 }
2650 let b = self.finish_binding(
2651 &name,
2652 None,
2653 &base,
2654 None,
2655 queries.clone(),
2656 r.file.display.clone(),
2657 used,
2658 );
2659 bindings.push(b);
2660 }
2661
2662 Some(Report {
2663 name,
2664 managed: true,
2665 file: r.file.display.clone(),
2666 folder: r.folder.clone(),
2667 tags,
2668 queries,
2669 default_set,
2670 timezone,
2671 has_sets,
2672 bindings,
2673 base: report_base,
2674 })
2675 }
2676
2677 fn resolve_sets(
2678 &mut self,
2679 report: &str,
2680 sets: &Located<Value>,
2681 queries: &[QueryEntry],
2682 base: &BindingBase,
2683 project: &Project,
2684 used: &mut Usage,
2685 ) -> Vec<Binding> {
2686 let yf = sets.file.clone();
2687 let file = Some(yf.display.clone());
2688 let Some(items) = sets.value.as_sequence() else {
2689 self.diags.error(
2690 "invalid-field",
2691 file,
2692 yf.line_of("sets", None),
2693 format!("report `{report}`: `sets` must be a list"),
2694 );
2695 return Vec::new();
2696 };
2697 let sets_line = yf.line_of("sets", None);
2698 let mut seen = BTreeSet::new();
2699 let mut out = Vec::new();
2700 for item in items {
2701 let (name, inline): (String, Option<&Mapping>) = match item {
2702 Value::String(s) => (s.clone(), None),
2703 Value::Mapping(m) => match m.get("name").and_then(Value::as_str) {
2704 Some(n) => (n.to_string(), Some(m)),
2705 None => {
2706 self.diags.error(
2707 "invalid-field",
2708 file.clone(),
2709 sets_line,
2710 format!("report `{report}`: a `sets:` map entry needs a `name:`"),
2711 );
2712 continue;
2713 }
2714 },
2715 _ => {
2716 self.diags.error(
2717 "invalid-field",
2718 file.clone(),
2719 sets_line,
2720 format!("report `{report}`: `sets:` entries must be names or maps"),
2721 );
2722 continue;
2723 }
2724 };
2725 let line = yf.line_of(&name, sets_line).or_else(|| yf.line_containing(&name));
2726 if !seen.insert(name.clone()) {
2727 self.diags.error(
2728 "duplicate-set",
2729 file.clone(),
2730 line,
2731 format!("report `{report}` lists Set `{name}` more than once"),
2732 );
2733 continue;
2734 }
2735 let registry = project.sets.get(&name);
2736 if inline.is_none() && registry.is_none() {
2737 self.diags.error(
2738 "unknown-set",
2739 file.clone(),
2740 line,
2741 format!("report `{report}`: Set `{name}` isn't declared in sets.yml"),
2742 );
2743 continue;
2744 }
2745 let mut b = BindingBase {
2746 profile: base.profile.clone(),
2747 profile_at: base.profile_at.clone(),
2748 vars: base.vars.clone(),
2749 output: base.output.clone(),
2750 };
2751 if let Some(reg) = registry {
2752 if let Some(p) = ®.profile {
2753 b.profile = Some(p.clone());
2754 b.profile_at = Some(ProfileAt {
2755 file: reg.file.clone(),
2756 line: reg.line,
2757 key: format!("`profile` of Set `{name}`"),
2758 });
2759 used.connection(p, None, None);
2760 }
2761 b.vars.extend(reg.vars.clone());
2762 }
2763 let mut qs = queries.to_vec();
2764 let mut tab_names: Option<Mapping> = None;
2765 if let Some(m) = inline {
2766 let ctx = format!("report `{report}`, Set `{name}`");
2767 for k in m.keys().filter_map(Value::as_str) {
2768 if !SET_ENTRY_KEYS.contains(&k) {
2769 self.diags.error(
2770 "unknown-key",
2771 file.clone(),
2772 line,
2773 format!("{ctx}: unknown key `{k}`"),
2774 );
2775 }
2776 }
2777 if let Some(p) = m.get("profile") {
2778 match p.as_str() {
2779 Some(p) => {
2780 b.profile = Some(p.to_string());
2781 b.profile_at = Some(ProfileAt {
2782 file: yf.display.clone(),
2783 line: yf.line_of("profile", line),
2784 key: format!("`profile` of Set `{name}`"),
2785 });
2786 used.connection(p, file.clone(), yf.line_of("profile", line));
2787 }
2788 None => self.diags.error(
2789 "invalid-field",
2790 file.clone(),
2791 line,
2792 format!("{ctx}: `profile` must be a string"),
2793 ),
2794 }
2795 }
2796 match m.get("vars") {
2797 Some(Value::Mapping(v)) => b.vars.extend(yaml_map_to_json(v)),
2798 None => {}
2799 Some(_) => self.diags.error(
2800 "invalid-field",
2801 file.clone(),
2802 line,
2803 format!("{ctx}: `vars` must be a map"),
2804 ),
2805 }
2806 if let Some(o) = m.get("output") {
2807 match o.as_mapping() {
2808 Some(o) => {
2809 if let Some(p) = merge_output(&mut b.output, o) {
2810 self.diags
2811 .error("invalid-field", file.clone(), line, format!("{ctx}: {p}"));
2812 }
2813 }
2814 None => self.diags.error(
2815 "invalid-field",
2816 file.clone(),
2817 line,
2818 format!("{ctx}: `output` must be a map"),
2819 ),
2820 }
2821 }
2822 if m.contains_key("schedule") {
2823 self.moved_to_schedules(&yf.display, line, &format!("{ctx}: `schedule`"));
2824 }
2825 let listed: Vec<&str> = queries.iter().map(|q| q.query.as_str()).collect();
2826 match (m.get("exclude"), m.get("queries")) {
2827 (Some(_), Some(_)) => self.diags.error(
2828 "exclude-and-queries",
2829 file.clone(),
2830 line,
2831 format!("{ctx}: use either `exclude:` or `queries:`, not both"),
2832 ),
2833 (Some(ex), None) => match string_list(ex) {
2834 Some(ex) => {
2835 for q in &ex {
2836 if !listed.contains(&q.as_str()) {
2837 self.diags.error(
2838 "unknown-query",
2839 file.clone(),
2840 yf.line_of("exclude", line),
2841 format!("{ctx}: `exclude` names `{q}`, which isn't in the report's `queries:`"),
2842 );
2843 }
2844 }
2845 qs.retain(|q| !ex.contains(&q.query));
2846 }
2847 None => self.diags.error(
2848 "invalid-field",
2849 file.clone(),
2850 line,
2851 format!("{ctx}: `exclude` must be a list of query names"),
2852 ),
2853 },
2854 (None, Some(ov)) => match ov.as_sequence() {
2855 Some(items) => {
2856 let mut sub = Vec::new();
2857 for it in items {
2858 let Some(qn) = entry_name(it) else { continue };
2859 match queries.iter().find(|q| q.query == qn) {
2860 Some(q) => {
2861 let mut q = q.clone();
2862 match it.get("tab_name") {
2863 None => {}
2864 Some(Value::String(s)) => q.tab_name = Some(s.clone()),
2865 Some(_) => self.diags.error(
2866 "invalid-field",
2867 file.clone(),
2868 line,
2869 format!("{ctx}: {}", one_tab_per_file(&qn)),
2870 ),
2871 }
2872 if let Some(b) = it.get("tab").and_then(Value::as_bool) {
2873 q.tab = b;
2874 }
2875 sub.push(q);
2876 }
2877 None => self.diags.error(
2878 "unknown-query",
2879 file.clone(),
2880 yf.line_of("queries", line),
2881 format!("{ctx}: `queries` names `{qn}`, which isn't in the report's `queries:`"),
2882 ),
2883 }
2884 }
2885 qs = sub;
2886 }
2887 None => self.diags.error(
2888 "invalid-field",
2889 file.clone(),
2890 line,
2891 format!("{ctx}: `queries` must be a list"),
2892 ),
2893 },
2894 (None, None) => {}
2895 }
2896 tab_names = match m.get("tab_names") {
2897 None => None,
2898 Some(Value::Mapping(t)) => Some(t.clone()),
2899 Some(_) => {
2900 self.diags.error(
2901 "invalid-field",
2902 file.clone(),
2903 line,
2904 format!("{ctx}: `tab_names` must be a map of query name to tab name"),
2905 );
2906 None
2907 }
2908 };
2909 }
2910 let bind = self.finish_binding(
2911 report,
2912 Some(name),
2913 &b,
2914 tab_names.as_ref(),
2915 qs,
2916 yf.display.clone(),
2917 used,
2918 );
2919 out.push(bind);
2920 }
2921 out
2922 }
2923
2924 fn silent_binding(
2926 &mut self,
2927 report: &str,
2928 base: &BindingBase,
2929 queries: &[QueryEntry],
2930 file: &Path,
2931 ) -> Binding {
2932 let saved = std::mem::take(&mut self.diags);
2933 let mut scratch = Usage::default();
2934 let b = self.finish_binding(
2935 report,
2936 None,
2937 base,
2938 None,
2939 queries.to_vec(),
2940 file.to_path_buf(),
2941 &mut scratch,
2942 );
2943 self.diags = saved;
2944 b
2945 }
2946
2947 #[allow(clippy::too_many_arguments)]
2948 fn finish_binding(
2949 &mut self,
2950 report: &str,
2951 set: Option<String>,
2952 base: &BindingBase,
2953 tab_names: Option<&Mapping>,
2954 mut queries: Vec<QueryEntry>,
2955 file: PathBuf,
2956 used: &mut Usage,
2957 ) -> Binding {
2958 let ctx = match &set {
2959 Some(s) => format!("report `{report}`, Set `{s}`"),
2960 None => format!("report `{report}`"),
2961 };
2962 if let Some(t) = tab_names {
2963 for (k, v) in t {
2964 let Some(q) = k.as_str() else { continue };
2965 match queries.iter_mut().find(|e| e.query == q) {
2966 Some(e) => match v {
2967 Value::String(s) => e.tab_name = Some(s.clone()),
2968 _ => self.diags.error(
2969 "invalid-field",
2970 Some(file.clone()),
2971 None,
2972 format!("{ctx}: {}", one_tab_per_file(q)),
2973 ),
2974 },
2975 None => self.diags.error(
2976 "unknown-query",
2977 Some(file.clone()),
2978 None,
2979 format!("{ctx}: `tab_names` names `{q}`, which isn't one of this Binding's queries"),
2980 ),
2981 }
2982 }
2983 }
2984 let output = self.typed_output(&base.output, &ctx, &file, &queries, used);
2985 if let Some(p) = &base.profile {
2986 used.connection(p, None, None);
2987 }
2988 Binding {
2989 set,
2990 profile: base.profile.clone(),
2991 vars: base.vars.clone(),
2992 queries: std::mem::take(&mut queries),
2993 output,
2994 schedules: Vec::new(),
2995 parsed: None,
2996 profile_at: base.profile_at.clone(),
2997 }
2998 }
2999
3000 fn typed_output(
3002 &mut self,
3003 m: &Mapping,
3004 ctx: &str,
3005 file: &Path,
3006 queries: &[QueryEntry],
3007 used: &mut Usage,
3008 ) -> Output {
3009 let file_path = file.to_path_buf();
3010 let file = Some(file_path.clone());
3011 let format = m
3012 .get("format")
3013 .and_then(Value::as_str)
3014 .unwrap_or("csv")
3015 .to_string();
3016 used.format(&format, ctx, &file_path);
3017 let mut opts = JsonMap::new();
3018 for (k, v) in m {
3019 let Some(k) = k.as_str() else { continue };
3020 if !OUTPUT_SHARED_KEYS.contains(&k) {
3021 opts.insert(k.to_string(), yaml_to_json(v));
3022 }
3023 }
3024 if let Some(Value::Mapping(d)) = self.format_options.get(format.as_str()) {
3026 for (k, v) in d {
3027 if let Some(k) = k.as_str()
3028 && !OUTPUT_SHARED_KEYS.contains(&k)
3029 {
3030 opts.entry(k.to_string()).or_insert_with(|| yaml_to_json(v));
3031 }
3032 }
3033 }
3034 let destinations = match m.get("destination") {
3035 None | Some(Value::Null) => Vec::new(),
3036 Some(Value::Mapping(d)) => self.typed_destination(d, ctx, &file, used).into_iter().collect(),
3037 Some(Value::Sequence(list)) if list.is_empty() => {
3038 self.diags.error(
3039 "invalid-field",
3040 file.clone(),
3041 None,
3042 format!(
3043 "{ctx}: `output.destination` is an empty list; name at least one destination, or remove it to keep the output in target/"
3044 ),
3045 );
3046 Vec::new()
3047 }
3048 Some(Value::Sequence(list)) => {
3049 let mut out = Vec::new();
3050 for (i, d) in list.iter().enumerate() {
3051 match d.as_mapping() {
3052 Some(d) => out.extend(self.typed_destination(d, ctx, &file, used)),
3053 None => self.diags.error(
3054 "invalid-field",
3055 file.clone(),
3056 None,
3057 format!("{ctx}: `output.destination` entry {} must be a map", i + 1),
3058 ),
3059 }
3060 }
3061 out
3062 }
3063 Some(_) => {
3064 self.diags.error(
3065 "invalid-field",
3066 file.clone(),
3067 None,
3068 format!("{ctx}: `output.destination` must be a map or a list of maps"),
3069 );
3070 Vec::new()
3071 }
3072 };
3073 if format != "xlsx" {
3074 for q in queries.iter().filter(|q| !q.columns.is_empty()) {
3075 self.diags.error(
3076 "invalid-field",
3077 file.clone(),
3078 None,
3079 format!(
3080 "{ctx}: `columns` on query `{}` only applies to the xlsx format",
3081 q.query
3082 ),
3083 );
3084 }
3085 }
3086 let template = match m.get("template") {
3087 None | Some(Value::Null) => None,
3088 Some(t) => {
3089 if format != "xlsx" {
3090 self.diags.error(
3091 "invalid-output-option",
3092 file.clone(),
3093 None,
3094 format!("{ctx}: `output.template` only applies to the xlsx format"),
3095 );
3096 }
3097 self.typed_template(t, ctx, &file, queries)
3098 }
3099 };
3100 let extension = match m.get("extension") {
3101 None => None,
3102 Some(Value::Null) | Some(Value::Bool(false)) => Some(String::new()),
3103 Some(Value::String(e)) => {
3104 let e = e.strip_prefix('.').unwrap_or(e);
3105 if e.contains(['/', '\\']) || e.chars().any(char::is_whitespace) {
3106 self.diags.error(
3107 "invalid-output-option",
3108 file.clone(),
3109 None,
3110 format!(
3111 "{ctx}: `extension` must be a file extension like `aba` (no path, no spaces)"
3112 ),
3113 );
3114 }
3115 Some(e.to_string())
3116 }
3117 Some(_) => {
3118 self.diags.error(
3119 "invalid-output-option",
3120 file.clone(),
3121 None,
3122 format!("{ctx}: `extension` must be a string (`aba`), or `\"\"`/`false` for none"),
3123 );
3124 None
3125 }
3126 };
3127 if extension.is_some() && format == "xlsx" {
3128 self.diags.error(
3129 "invalid-output-option",
3130 file.clone(),
3131 None,
3132 format!("{ctx}: `extension` doesn't apply to xlsx (Excel only opens .xlsx workbooks)"),
3133 );
3134 }
3135 Output {
3136 format,
3137 options: opts,
3138 destinations,
3139 template,
3140 extension,
3141 }
3142 }
3143
3144 fn typed_destination(
3146 &mut self,
3147 d: &Mapping,
3148 ctx: &str,
3149 file: &Option<PathBuf>,
3150 used: &mut Usage,
3151 ) -> Option<Destination> {
3152 let Some(p) = d.get("profile").and_then(Value::as_str) else {
3153 self.diags.error(
3154 "invalid-field",
3155 file.clone(),
3156 None,
3157 format!("{ctx}: `output.destination` needs a `profile:` naming a profiles.yml entry"),
3158 );
3159 return None;
3160 };
3161 let path = match d.get("path") {
3162 None | Some(Value::Null) => None,
3163 Some(Value::String(s)) => Some(s.clone()),
3164 Some(_) => {
3165 self.diags.error(
3166 "invalid-field",
3167 file.clone(),
3168 None,
3169 format!("{ctx}: destination `{p}`: `path` must be a string"),
3170 );
3171 None
3172 }
3173 };
3174 used.destination(p, file.clone(), None);
3175 let options = d
3176 .iter()
3177 .filter(|(k, _)| !is_one_of(k, &["profile", "path"]))
3178 .filter_map(|(k, v)| Some((k.as_str()?.to_string(), yaml_to_json(v))))
3179 .collect();
3180 Some(Destination {
3181 profile: p.to_string(),
3182 path,
3183 options,
3184 })
3185 }
3186
3187 fn typed_template(
3188 &mut self,
3189 t: &Value,
3190 ctx: &str,
3191 file: &Option<PathBuf>,
3192 queries: &[QueryEntry],
3193 ) -> Option<Template> {
3194 let err = |s: &mut Self, msg: String| {
3195 s.diags
3196 .error("invalid-template", file.clone(), None, format!("{ctx}: {msg}"))
3197 };
3198 let Some(tf) = t.get("file").and_then(Value::as_str) else {
3199 err(self, "`output.template` needs a `file:`".into());
3200 return None;
3201 };
3202 let mut bindings = Vec::new();
3203 let items = match t.get("bindings") {
3204 None => Vec::new(),
3205 Some(Value::Sequence(s)) => s.clone(),
3206 Some(_) => {
3207 err(self, "`output.template.bindings` must be a list".into());
3208 Vec::new()
3209 }
3210 };
3211 let names: Vec<&str> = queries.iter().map(|q| q.query.as_str()).collect();
3212 for (i, b) in items.iter().enumerate() {
3213 let n = i + 1;
3214 let Some(m) = b.as_mapping() else {
3215 err(self, format!("template binding {n} must be a map"));
3216 continue;
3217 };
3218 let s = |k: &str| m.get(k).and_then(Value::as_str).map(str::to_string);
3219 for k in m.keys().filter_map(Value::as_str) {
3220 if !TEMPLATE_BINDING_KEYS.contains(&k) {
3221 err(self, format!("template binding {n} has unknown key `{k}`"));
3222 }
3223 }
3224 let Some(sheet) = s("sheet") else {
3225 err(self, format!("template binding {n} needs a `sheet:`"));
3226 continue;
3227 };
3228 let tb = TemplateBinding {
3229 sheet,
3230 query: s("query"),
3231 result_index: m.get("result_index").and_then(Value::as_u64).map(|x| x as usize),
3232 anchor: s("anchor"),
3233 header: m.get("header").and_then(Value::as_bool),
3234 columns: m.get("columns").and_then(string_list),
3235 cell: s("cell"),
3236 value: s("value"),
3237 column: s("column"),
3238 };
3239 if let Some(q) = &tb.query
3240 && !names.contains(&q.as_str())
3241 {
3242 err(
3243 self,
3244 format!(
3245 "template binding {n} uses query `{q}`, which isn't one of this Binding's queries"
3246 ),
3247 );
3248 }
3249 if let Some(ri) = m.get("result_index")
3250 && ri.as_u64().is_none_or(|x| x == 0)
3251 {
3252 err(
3253 self,
3254 format!("template binding {n}: `result_index` must be 1 or more"),
3255 );
3256 }
3257 match &tb.cell {
3258 Some(c) => {
3259 if !options::is_cell(c) {
3260 err(
3261 self,
3262 format!("template binding {n}: `cell` `{c}` isn't a valid cell reference"),
3263 );
3264 }
3265 let by_value = tb.value.is_some();
3266 let by_query = tb.query.is_some() && tb.column.is_some();
3267 if by_value == by_query || (tb.query.is_some() != tb.column.is_some()) {
3268 err(
3269 self,
3270 format!(
3271 "template binding {n}: a single-cell binding needs exactly one of `value` or `query` + `column`"
3272 ),
3273 );
3274 }
3275 if tb.anchor.is_some() || tb.columns.is_some() {
3276 err(
3277 self,
3278 format!(
3279 "template binding {n}: `anchor`/`columns` apply to table blocks, not single cells"
3280 ),
3281 );
3282 }
3283 }
3284 None => {
3285 if tb.query.is_none() {
3286 err(
3287 self,
3288 format!(
3289 "template binding {n}: a table block needs a `query` (or use `cell` for a single cell)"
3290 ),
3291 );
3292 }
3293 if let Some(a) = &tb.anchor
3294 && !options::is_cell(a)
3295 {
3296 err(
3297 self,
3298 format!("template binding {n}: `anchor` `{a}` isn't a valid cell reference"),
3299 );
3300 }
3301 if tb.value.is_some() || tb.column.is_some() {
3302 err(
3303 self,
3304 format!(
3305 "template binding {n}: `value`/`column` apply to single-cell bindings (add `cell`)"
3306 ),
3307 );
3308 }
3309 }
3310 }
3311 bindings.push(tb);
3312 }
3313 Some(Template {
3314 file: tf.to_string(),
3315 bindings,
3316 })
3317 }
3318
3319 fn resolve_unmanaged(
3322 &mut self,
3323 name: &str,
3324 path: &Path,
3325 project: &Project,
3326 folders: &BTreeMap<Vec<String>, FolderCfg>,
3327 used: &mut Usage,
3328 ) -> Report {
3329 let folder = folder_segments(path.parent().unwrap_or(Path::new("")));
3330 let layers = folder_layers(folders, &folder);
3331 let mut tags: Vec<String> = layers.iter().flat_map(|l| l.tags.clone()).collect();
3332 dedup(&mut tags);
3333 let profile = layers
3334 .iter()
3335 .rev()
3336 .find_map(|l| l.profile.as_ref().map(|p| p.0.clone()))
3337 .or(project.default_profile.clone());
3338 let mut vars = project.vars.clone();
3339 for l in &layers {
3340 if let Some(v) = &l.vars {
3341 vars.extend(yaml_map_to_json(v));
3342 }
3343 }
3344 let mut output = builtin_output();
3345 let mut merge_problems = Vec::new();
3346 if let Some(o) = project_default_output(project) {
3347 merge_problems.extend(merge_output(&mut output, &o));
3348 }
3349 for l in &layers {
3350 if let Some(o) = &l.output {
3351 merge_problems.extend(merge_output(&mut output, o));
3352 }
3353 }
3354 for p in merge_problems {
3355 self.diags.error(
3356 "invalid-field",
3357 Some(path.to_path_buf()),
3358 None,
3359 format!("folder config: {p}"),
3360 );
3361 }
3362 self.diags.warning(
3363 "unmanaged-report",
3364 Some(path.to_path_buf()),
3365 None,
3366 format!(
3367 "`{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"
3368 ),
3369 );
3370 self.check_unmanaged_sql(name, path);
3371 let query = QueryEntry {
3372 query: name.to_string(),
3373 path: path.to_path_buf(),
3374 profile: None,
3375 tab: true,
3376 tab_name: None,
3377 anchor: None,
3378 header: None,
3379 columns: BTreeMap::new(),
3380 };
3381 let base = BindingBase {
3382 profile: profile.clone(),
3383 profile_at: inherited_profile_at(&layers, project),
3384 vars,
3385 output,
3386 };
3387 if let Some(p) = &profile {
3388 used.connection(p, None, None);
3389 }
3390 let b = self.finish_binding(
3391 name,
3392 None,
3393 &base,
3394 None,
3395 vec![query.clone()],
3396 path.to_path_buf(),
3397 used,
3398 );
3399 Report {
3400 name: name.to_string(),
3401 managed: false,
3402 file: path.to_path_buf(),
3403 folder,
3404 tags,
3405 queries: vec![query],
3406 default_set: None,
3407 timezone: layers
3408 .iter()
3409 .rev()
3410 .find_map(|l| l.timezone.clone())
3411 .or_else(|| project.timezone.clone()),
3412 has_sets: false,
3413 base: b.clone(),
3414 bindings: vec![b],
3415 }
3416 }
3417
3418 fn check_unmanaged_sql(&mut self, name: &str, path: &Path) {
3420 let Ok(text) = std::fs::read_to_string(self.root.join(path)) else {
3421 return;
3422 };
3423 let head = sqlsplit::strip_leading_comments(&text);
3424 if head.starts_with("{{") || head.starts_with("{%") || head.starts_with("{#") {
3425 return;
3426 }
3427 for st in sqlsplit::split(&text) {
3428 if !sqlsplit::classify(&st.text).is_read_only_safe() {
3429 let body = sqlsplit::strip_leading_comments(&st.text);
3430 let line = st.line + st.text[..st.text.len() - body.len()].matches('\n').count();
3431 self.diags.error(
3432 "unmanaged-side-effect",
3433 Some(path.to_path_buf()),
3434 Some(line),
3435 format!(
3436 "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",
3437 dre_protocol::util::summarize(body, 60)
3438 ),
3439 );
3440 }
3441 }
3442 }
3443
3444 fn moved_to_schedules(&mut self, file: &Path, line: Option<usize>, ctx: &str) {
3448 self.diags.error(
3449 "schedule-moved",
3450 Some(file.to_path_buf()),
3451 line,
3452 format!(
3453 "{ctx} is no longer supported; declare schedules in schedules.yml as named entries (`name`, `report:`/`select:`, `cron`/`every`/`rrule`, optional `vars`)"
3454 ),
3455 );
3456 }
3457
3458 fn parse_timings(&mut self, files: &[Rc<YamlFile>]) -> (BTreeMap<String, Timing>, BTreeSet<String>) {
3461 let mut out = BTreeMap::new();
3462 let mut broken = BTreeSet::new();
3463 let mut seen: BTreeMap<String, String> = BTreeMap::new();
3464 for yf in files {
3465 let Some(m) = yf.value.as_mapping() else { continue };
3466 for (k, v) in m {
3467 let file = Some(yf.display.clone());
3468 let Some(name) = k.as_str() else { continue };
3469 let line = yf.line_of(name, None);
3470 let mut ok = true;
3471 if !is_identifier(name) {
3472 self.diags.error(
3473 "invalid-timing",
3474 file.clone(),
3475 line,
3476 format!(
3477 "timing name `{name}` must be letters, digits and `_`, not starting with a digit"
3478 ),
3479 );
3480 ok = false;
3481 }
3482 if let Some(prev) = seen.get(name) {
3483 self.diags.error(
3484 "duplicate-timing-name",
3485 file.clone(),
3486 line,
3487 format!("timing `{name}` is already declared at {prev}; timing names must be unique"),
3488 );
3489 continue;
3490 }
3491 seen.insert(
3492 name.to_string(),
3493 format!("{}:{}", yf.display.display(), line.unwrap_or(0)),
3494 );
3495 let Some(t) = v.as_mapping() else {
3496 self.diags.error(
3497 "invalid-timing",
3498 file.clone(),
3499 line,
3500 format!("timing `{name}` must be a map, e.g. `{{cron: \"0 6 1 * *\", timezone: Australia/Sydney}}`"),
3501 );
3502 broken.insert(name.to_string());
3503 continue;
3504 };
3505 let mut block = Mapping::new();
3506 for (k, v) in t {
3507 let Some(k) = k.as_str() else { continue };
3508 if !schedule::TIMING_KEYS.contains(&k) {
3509 self.diags.error(
3510 "invalid-timing",
3511 file.clone(),
3512 line,
3513 format!("timing `{name}`: unknown key `{k}`"),
3514 );
3515 ok = false;
3516 } else if k != "timezone" {
3517 block.insert(Value::String(k.to_string()), v.clone());
3518 }
3519 }
3520 let shape = schedule::validate_block(&block, "a timing", "`cron`, `every` or `rrule`");
3521 for e in &shape {
3522 self.diags.error(
3523 "invalid-timing",
3524 file.clone(),
3525 line,
3526 format!("timing `{name}`: {e}"),
3527 );
3528 ok = false;
3529 }
3530 let timezone = match t.get("timezone") {
3531 None => None,
3532 Some(v) => {
3533 let tz = self.timezone_value(
3534 v,
3535 &yf.display,
3536 line,
3537 &format!("timing `{name}`: `timezone`"),
3538 );
3539 ok &= tz.is_some();
3540 tz
3541 }
3542 };
3543 let block = yaml_map_to_json(&block);
3544 if shape.is_empty() {
3545 for (code, msg) in schedule::strictness(&block) {
3546 self.diags
3547 .error(code, file.clone(), line, format!("timing `{name}`: {msg}"));
3548 }
3549 if let Some(msg) = schedule::no_time(&block) {
3550 self.diags.warning(
3551 "schedule-no-time",
3552 file.clone(),
3553 line,
3554 format!("timing `{name}`: {msg}"),
3555 );
3556 }
3557 }
3558 if ok {
3559 out.insert(
3560 name.to_string(),
3561 Timing {
3562 schedule: block,
3563 timezone,
3564 location: (yf.display.clone(), line),
3565 },
3566 );
3567 } else {
3568 broken.insert(name.to_string());
3569 }
3570 }
3571 }
3572 (out, broken)
3573 }
3574
3575 fn parse_schedules(
3576 &mut self,
3577 files: &[Rc<YamlFile>],
3578 project: &Project,
3579 broken_timings: &BTreeSet<String>,
3580 ) -> Vec<ScheduleEntry> {
3581 let mut out = Vec::new();
3582 let mut used_timings = BTreeSet::new();
3583 let mut seen: BTreeMap<String, String> = BTreeMap::new();
3584 for yf in files {
3585 let Some(items) = yf.value.as_sequence() else {
3586 continue;
3587 };
3588 for (i, item) in items.iter().enumerate() {
3589 let m = item.as_mapping().unwrap();
3590 let line = nth_item_line(&yf.text, i);
3591 let file = Some(yf.display.clone());
3592 let s = |k: &str| m.get(k).and_then(Value::as_str).map(str::to_string);
3593 let (select, report, set) = (s("select"), s("report"), s("set"));
3594 let mut ok = true;
3595 let name = match s("name") {
3596 Some(n) if is_identifier(&n) => {
3597 if let Some(prev) = seen.get(&n) {
3598 self.diags.error(
3599 "duplicate-schedule-name",
3600 file.clone(),
3601 line,
3602 format!("schedule `{n}` is already declared at {prev}; schedule names must be unique"),
3603 );
3604 ok = false;
3605 }
3606 seen.insert(
3607 n.clone(),
3608 format!("{}:{}", yf.display.display(), line.unwrap_or(0)),
3609 );
3610 n
3611 }
3612 Some(n) => {
3613 self.diags.error(
3614 "invalid-schedule",
3615 file.clone(),
3616 line,
3617 format!("schedule name `{n}` must be letters, digits and `_`, not starting with a digit"),
3618 );
3619 ok = false;
3620 n
3621 }
3622 None => {
3623 self.diags.error(
3624 "invalid-schedule",
3625 file.clone(),
3626 line,
3627 "every schedule needs a `name`",
3628 );
3629 ok = false;
3630 String::new()
3631 }
3632 };
3633 let vars = match m.get("vars") {
3634 None => JsonMap::new(),
3635 Some(Value::Mapping(v)) => yaml_map_to_json(v),
3636 Some(_) => {
3637 self.diags.error(
3638 "invalid-schedule",
3639 file.clone(),
3640 line,
3641 format!("schedule `{name}`: `vars` must be a map"),
3642 );
3643 ok = false;
3644 JsonMap::new()
3645 }
3646 };
3647 let mut sched = Mapping::new();
3648 for (k, v) in m {
3649 let Some(k) = k.as_str() else { continue };
3650 if SCHEDULE_ENTRY_KEYS.contains(&k) {
3651 continue;
3652 }
3653 if !schedule::SCHEDULE_KEYS.contains(&k) {
3654 self.diags.error(
3655 "invalid-schedule",
3656 file.clone(),
3657 line,
3658 format!("unknown schedule key `{k}`"),
3659 );
3660 continue;
3661 }
3662 sched.insert(Value::String(k.to_string()), v.clone());
3663 }
3664 let timing = match m.get("timing") {
3665 None => None,
3666 Some(Value::String(t)) => {
3667 used_timings.insert(t.clone());
3668 Some(t.clone())
3669 }
3670 Some(_) => {
3671 self.diags.error(
3672 "invalid-schedule",
3673 file.clone(),
3674 line,
3675 format!(
3676 "schedule `{name}`: `timing` must be the name of a timing in timings.yml"
3677 ),
3678 );
3679 ok = false;
3680 None
3681 }
3682 };
3683 let mut resolved = None;
3684 let shape = if let Some(t) = &timing {
3685 let mut errs = Vec::new();
3686 let own: Vec<String> = m
3687 .keys()
3688 .filter_map(Value::as_str)
3689 .filter(|k| schedule::TIMING_KEYS.contains(k))
3690 .map(|k| format!("`{k}`"))
3691 .collect();
3692 if !own.is_empty() {
3693 let keys = match own.split_last() {
3694 Some((last, [])) => last.clone(),
3695 Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
3696 None => String::new(),
3697 };
3698 errs.push(format!(
3699 "schedule `{name}` uses timing `{t}`, so it can't set {keys} too; change the timing, or give the schedule its own timing instead"
3700 ));
3701 }
3702 match project.timings.get(t) {
3703 Some(def) => resolved = Some(def.schedule.clone()),
3704 None if broken_timings.contains(t) => ok = false,
3705 None => {
3706 let names: Vec<&str> = project.timings.keys().map(String::as_str).collect();
3707 let valid = if names.is_empty() {
3708 "the project has no timings.yml entries".to_string()
3709 } else {
3710 format!("valid names: {}", names.join(", "))
3711 };
3712 self.diags.error(
3713 "unknown-timing",
3714 file.clone(),
3715 line,
3716 format!("schedule `{name}`: no timing `{t}`; {valid}"),
3717 );
3718 ok = false;
3719 }
3720 }
3721 errs
3722 } else {
3723 schedule::validate_block(&sched, "a schedule", "`timing`, `cron`, `every` or `rrule`")
3724 };
3725 if shape.is_empty() && timing.is_none() {
3726 let block = yaml_map_to_json(&sched);
3727 for (code, msg) in schedule::strictness(&block) {
3728 self.diags
3729 .error(code, file.clone(), line, format!("schedule `{name}`: {msg}"));
3730 }
3731 if let Some(msg) = schedule::no_time(&block) {
3732 self.diags.warning(
3733 "schedule-no-time",
3734 file.clone(),
3735 line,
3736 format!("schedule `{name}`: {msg}"),
3737 );
3738 }
3739 }
3740 for e in shape {
3741 self.diags.error("invalid-schedule", file.clone(), line, e);
3742 ok = false;
3743 }
3744 if select.is_some() && report.is_some() {
3745 self.diags.error(
3746 "invalid-schedule",
3747 file.clone(),
3748 line,
3749 "use either `select:` or `report:`, not both",
3750 );
3751 ok = false;
3752 }
3753 if select.is_some() && set.is_some() {
3754 self.diags.error(
3755 "invalid-schedule",
3756 file.clone(),
3757 line,
3758 "`set:` only applies with `report:`",
3759 );
3760 ok = false;
3761 }
3762 if let Some(sel) = &select {
3763 match selector::resolve(project, sel) {
3764 Ok(r) if r.is_empty() => {
3765 self.diags.error(
3766 "selector-matches-nothing",
3767 file.clone(),
3768 line,
3769 format!("selector `{sel}` matches no report"),
3770 );
3771 ok = false;
3772 }
3773 Ok(_) => {}
3774 Err(e) => {
3775 self.diags.error(e.code(), file.clone(), line, e.to_string());
3776 ok = false;
3777 }
3778 }
3779 }
3780 if let Some(rn) = &report {
3781 match project.report(rn) {
3782 None => {
3783 self.diags.error(
3784 "selector-matches-nothing",
3785 file.clone(),
3786 line,
3787 format!("report `{rn}` doesn't exist"),
3788 );
3789 ok = false;
3790 }
3791 Some(r) => {
3792 if let Some(sn) = &set
3793 && r.binding(sn).is_none()
3794 {
3795 self.diags.error(
3796 "selector-matches-nothing",
3797 file.clone(),
3798 line,
3799 format!("report `{rn}` has no Set `{sn}`"),
3800 );
3801 ok = false;
3802 }
3803 }
3804 }
3805 }
3806 if select.is_none() && report.is_none() {
3807 self.diags.error(
3808 "invalid-schedule",
3809 file.clone(),
3810 line,
3811 format!("schedule `{name}` needs `report:` (optionally with `set:`) or `select:`"),
3812 );
3813 ok = false;
3814 }
3815 let timezone = match (m.get("timezone"), &timing) {
3816 (None, Some(t)) => project.timings.get(t).and_then(|d| d.timezone.clone()),
3817 (None, None) => None,
3818 (Some(v), _) => {
3819 let t = self.timezone_value(
3820 v,
3821 &yf.display,
3822 line,
3823 &format!("schedule `{name}`: `timezone`"),
3824 );
3825 ok &= t.is_some();
3826 t
3827 }
3828 };
3829 let enabled = match m.get("enabled") {
3830 None => true,
3831 Some(Value::Bool(b)) => *b,
3832 Some(_) => {
3833 self.diags.error(
3834 "invalid-schedule",
3835 file.clone(),
3836 line,
3837 format!("schedule `{name}`: `enabled` must be true or false"),
3838 );
3839 ok = false;
3840 true
3841 }
3842 };
3843 if ok {
3844 out.push(ScheduleEntry {
3845 name,
3846 select,
3847 report,
3848 set,
3849 schedule: resolved.unwrap_or_else(|| yaml_map_to_json(&sched)),
3850 timing,
3851 enabled,
3852 vars,
3853 timezone,
3854 location: (yf.display.clone(), line),
3855 });
3856 }
3857 }
3858 }
3859 for (name, t) in &project.timings {
3860 if !used_timings.contains(name) {
3861 self.diags.warning(
3862 "unused-timing",
3863 Some(t.location.0.clone()),
3864 t.location.1,
3865 format!("timing `{name}` isn't used by any schedule"),
3866 );
3867 }
3868 }
3869 out
3870 }
3871
3872 fn check_schedule_paths(&mut self, project: &Project) {
3876 let date = chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
3877 for report in &project.reports {
3878 for b in report.bindings.iter().filter(|b| b.schedules.len() > 1) {
3879 let paths: Vec<&String> = b
3880 .output
3881 .destinations
3882 .iter()
3883 .filter_map(|d| d.path.as_ref())
3884 .collect();
3885 if paths.is_empty() {
3886 continue;
3887 }
3888 let mut rendered: Vec<(&String, Vec<String>)> = Vec::new();
3889 for name in &b.schedules {
3890 let Some(e) = project.schedules.iter().find(|e| &e.name == name) else {
3891 continue;
3892 };
3893 let mut vars = b.vars.clone();
3894 vars.extend(e.vars.clone());
3895 let Ok(r) = crate::render::Renderer::new(crate::render::RendererConfig {
3896 root: &project.root,
3897 macros: &project.macros,
3898 context: crate::render::RunContext {
3899 report: report.name.clone(),
3900 set: b.set.clone(),
3901 target: project.target_name.clone(),
3902 schedule: Some(name.clone()),
3903 date,
3904 now: chrono::Utc::now(),
3905 scheduled_at: None,
3906 calendar: crate::dates::Calendar::default(),
3907 },
3908 vars,
3909 cli_vars: self.opts.vars.clone(),
3910 runner: None,
3911 connections: None,
3912 mode: crate::render::Mode::Run,
3913 connection: None,
3914 source_type: String::new(),
3915 sources: None,
3916 run_query_max_rows: project.run_query_max_rows,
3917 sql: project.sql.clone(),
3918 lookups: project.lookups.clone(),
3919 lookup_inline_max_rows: project.lookup_inline_max_rows,
3920 packages: project.packages.clone(),
3921 project_name: project.name.clone(),
3922 dispatch: project.dispatch.clone(),
3923 }) else {
3924 continue;
3925 };
3926 let out: Result<Vec<String>, _> =
3927 paths.iter().map(|p| r.render(&report.file, p)).collect();
3928 if let Ok(out) = out {
3929 rendered.push((name, out));
3930 }
3931 }
3932 for (i, (a, pa)) in rendered.iter().enumerate() {
3933 for (bn, pb) in &rendered[i + 1..] {
3934 if let Some(same) = pa.iter().find(|p| pb.contains(p)) {
3935 self.diags.warning(
3936 "schedule-path-clash",
3937 Some(report.file.clone()),
3938 None,
3939 format!(
3940 "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",
3941 report.name,
3942 b.set.as_ref().map(|s| format!(", Set `{s}`,")).unwrap_or_default()
3943 ),
3944 );
3945 }
3946 }
3947 }
3948 }
3949 }
3950 }
3951
3952 fn apply_schedules(&mut self, project: &mut Project) {
3956 let entries = project.schedules.clone();
3957 for e in &entries {
3958 let reports: Vec<String> = match (&e.select, &e.report) {
3959 (Some(sel), _) => selector::resolve(project, sel)
3960 .map(|r| r.into_iter().map(|r| r.name.clone()).collect())
3961 .unwrap_or_default(),
3962 (_, Some(r)) => vec![r.clone()],
3963 _ => Vec::new(),
3964 };
3965 for report in project.reports.iter_mut().filter(|r| reports.contains(&r.name)) {
3966 for b in &mut report.bindings {
3967 if e.set.is_none() || e.set == b.set {
3968 b.schedules.push(e.name.clone());
3969 }
3970 }
3971 }
3972 }
3973 self.check_schedule_paths(project);
3974 self.check_schedule_timezones(project);
3975 }
3976
3977 fn check_schedule_timezones(&mut self, project: &Project) {
3980 for e in project.schedules.iter().filter(|e| e.timezone.is_none()) {
3981 let fires = crate::occurrences::firing_tz(project, e);
3982 let mut seen = BTreeSet::new();
3983 for (report, _) in crate::occurrences::bindings(project, &e.name) {
3984 let Some(r) = project.report(report) else { continue };
3985 let renders = r
3986 .timezone
3987 .as_ref()
3988 .and_then(|t| crate::dates::parse_tz(t).ok())
3989 .unwrap_or(chrono_tz::Tz::UTC);
3990 if renders != fires && seen.insert(report) {
3991 self.diags.warning(
3992 "schedule-timezone-mismatch",
3993 Some(e.location.0.clone()),
3994 e.location.1,
3995 format!(
3996 "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",
3997 e.name,
3998 fires.name(),
3999 renders.name(),
4000 renders.name()
4001 ),
4002 );
4003 }
4004 }
4005 }
4006 }
4007
4008 fn check_profiles(&mut self, project: &Project, used: &Usage) {
4011 let profiles = &project.profiles;
4012 if used.connections.is_empty() && used.destinations.is_empty() {
4013 return;
4014 }
4015 if !profiles.exists() {
4016 self.diags.error(
4017 "profiles-missing",
4018 None,
4019 None,
4020 if profiles.found_by == "~/.dre" {
4021 format!(
4022 "the project references profiles, but no profiles.yml was found in the project directory or at {}",
4023 profiles.path.display()
4024 )
4025 } else {
4026 format!(
4027 "the project references profiles, but no profiles.yml was found at {} (from {})",
4028 profiles.path.display(),
4029 profiles.found_by
4030 )
4031 },
4032 );
4033 return;
4034 }
4035 for (role, refs) in [
4036 (Role::Connection, &used.connections),
4037 (Role::Destination, &used.destinations),
4038 ] {
4039 for (name, (file, line)) in refs {
4040 if role == Role::Destination && name == LOCAL_TYPE {
4041 continue;
4042 }
4043 if !profiles.declares(role, name) {
4044 self.diags.error(
4045 "unknown-profile",
4046 file.clone(),
4047 *line,
4048 format!(
4049 "{} profile `{name}` isn't defined under `{}:` in {}",
4050 role.as_str(),
4051 profiles.section_key(role),
4052 profiles.path.display()
4053 ),
4054 );
4055 }
4056 }
4057 }
4058 }
4059
4060 fn plugin_entry(
4062 &mut self,
4063 m: &Mapping,
4064 yf: &YamlFile,
4065 line: Option<usize>,
4066 ) -> Option<(String, Option<Value>, PluginSource)> {
4067 let file = Some(yf.display.clone());
4068 let name = m
4069 .get("name")
4070 .and_then(Value::as_str)
4071 .unwrap_or_default()
4072 .to_string();
4073 let line = yf.line_of(&name, line);
4074 let err = |s: &mut Self, msg: String| {
4075 s.diags.error(
4076 "invalid-plugin-declaration",
4077 file.clone(),
4078 line,
4079 format!("plugin package `{name}`: {msg}"),
4080 );
4081 };
4082 if name.is_empty() {
4083 err(self, "`name` must be a non-empty string".into());
4084 return None;
4085 }
4086 for k in m.keys().filter_map(Value::as_str) {
4087 if !PLUGIN_ENTRY_KEYS.contains(&k) {
4088 err(
4089 self,
4090 format!(
4091 "unknown key `{k}`; use `name`, `version` and one of `github`, `local`, `registry`"
4092 ),
4093 );
4094 return None;
4095 }
4096 }
4097 let given: Vec<(&str, &str)> = ["github", "local", "registry"]
4098 .into_iter()
4099 .filter_map(|k| m.get(k).map(|v| (k, v.as_str().unwrap_or(""))))
4100 .collect();
4101 let source = match given.as_slice() {
4102 [] => PluginSource::Default,
4103 [(k, "")] => {
4104 err(self, format!("`{k}` must be a non-empty string"));
4105 return None;
4106 }
4107 [("github", r)] => {
4108 let ok = r.split('/').count() == 2 && r.split('/').all(|p| !p.is_empty());
4109 if !ok {
4110 err(self, format!("`github: {r}` must be `owner/repo`"));
4111 return None;
4112 }
4113 PluginSource::Github(r.to_string())
4114 }
4115 [("local", p)] => {
4116 if m.get("version").is_some() {
4117 err(
4118 self,
4119 "a `local` package has no `version`: it's used as it is".into(),
4120 );
4121 return None;
4122 }
4123 PluginSource::Local(p.to_string())
4124 }
4125 [(_, u)] => PluginSource::Registry(u.to_string()),
4126 _ => {
4127 err(self, "give only one of `github`, `local` and `registry`".into());
4128 return None;
4129 }
4130 };
4131 Some((name, m.get("version").cloned(), source))
4132 }
4133
4134 fn check_plugins(&mut self, decls: &[(Rc<YamlFile>, Mapping)], project: &mut Project, used: &Usage) {
4137 struct Decl {
4138 req: semver::VersionReq,
4139 raw: String,
4140 file: PathBuf,
4141 source: PluginSource,
4142 }
4143 let mut by_package: BTreeMap<String, Vec<Decl>> = BTreeMap::new();
4144 for (yf, m) in decls {
4145 for (block, v) in m {
4146 if block.as_str() != Some(PLUGINS_KEY) {
4147 continue;
4148 }
4149 let line = yf.line_of(PLUGINS_KEY, None);
4150 let file = Some(yf.display.clone());
4151 let entries: Vec<(String, Option<Value>, PluginSource)> = match v {
4152 Value::Sequence(items) => items
4153 .iter()
4154 .filter_map(|i| match i {
4155 Value::String(s) => Some((s.clone(), None, PluginSource::Default)),
4156 Value::Mapping(m) if m.get("name").is_some() => self.plugin_entry(m, yf, line),
4157 Value::Mapping(m) if m.len() == 1 => {
4158 let (k, v) = m.iter().next().unwrap();
4159 k.as_str()
4160 .map(|k| (k.to_string(), Some(v.clone()), PluginSource::Default))
4161 }
4162 _ => {
4163 self.diags.error(
4164 "invalid-plugin-declaration",
4165 file.clone(),
4166 line,
4167 "each `plugins` entry is a package name, `name: \"<version>\"`, or a map with `name:` and one of `github:`, `local:`, `registry:`",
4168 );
4169 None
4170 }
4171 })
4172 .collect(),
4173 Value::Mapping(m) => m
4174 .iter()
4175 .filter_map(|(k, v)| {
4176 k.as_str().map(|k| (k.to_string(), Some(v.clone()), PluginSource::Default))
4177 })
4178 .collect(),
4179 Value::Null => Vec::new(),
4180 _ => {
4181 self.diags.error(
4182 "invalid-plugin-declaration",
4183 file.clone(),
4184 line,
4185 "`plugins` must be a list like `- duckdb: \">=1.0\"`",
4186 );
4187 continue;
4188 }
4189 };
4190 for (name, c, source) in entries {
4191 if !dre_protocol::valid_name(&name) {
4192 self.diags.error(
4193 "invalid-plugin-declaration",
4194 file.clone(),
4195 yf.line_of(&name, line),
4196 format!(
4197 "plugin package `{name}`: a package name is lowercase letters, digits and `_`"
4198 ),
4199 );
4200 continue;
4201 }
4202 let raw = match &c {
4203 None | Some(Value::Null) => "*".to_string(),
4204 Some(v) => crate::yaml::scalar_str(v).unwrap_or_default(),
4205 };
4206 match semver::VersionReq::parse(&raw) {
4207 Ok(req) => by_package.entry(name).or_default().push(Decl {
4208 req,
4209 raw,
4210 file: yf.display.clone(),
4211 source,
4212 }),
4213 Err(e) => self.diags.error(
4214 "invalid-version-constraint",
4215 file.clone(),
4216 yf.line_of(&name, line),
4217 format!("plugin package `{name}`: invalid version constraint `{raw}`: {e}"),
4218 ),
4219 }
4220 }
4221 }
4222 }
4223 let mut out = Vec::new();
4224 for (name, ds) in &by_package {
4225 let source = ds
4227 .iter()
4228 .map(|d| &d.source)
4229 .find(|s| !s.is_default())
4230 .cloned()
4231 .unwrap_or_default();
4232 if let Some(other) = ds.iter().find(|d| !d.source.is_default() && d.source != source) {
4233 self.diags.error(
4234 "conflicting-plugin-sources",
4235 Some(other.file.clone()),
4236 None,
4237 format!(
4238 "plugin package `{name}` is declared with two sources: {} and {}",
4239 source.lock_key().unwrap_or_default(),
4240 other.source.lock_key().unwrap_or_default()
4241 ),
4242 );
4243 project.plugins_incomplete = true;
4244 continue;
4245 }
4246 let reqs: Vec<&semver::VersionReq> = ds.iter().map(|d| &d.req).collect();
4247 if !constraints::compatible(&reqs) {
4248 for (i, a) in ds.iter().enumerate() {
4249 for b in &ds[i + 1..] {
4250 if !constraints::compatible(&[&a.req, &b.req]) {
4251 self.diags.error(
4252 "conflicting-plugin-constraints",
4253 Some(b.file.clone()),
4254 None,
4255 format!(
4256 "plugin package `{name}` is declared `{}` in {} and `{}` in {}; no version satisfies both",
4257 a.raw,
4258 a.file.display(),
4259 b.raw,
4260 b.file.display()
4261 ),
4262 );
4263 }
4264 }
4265 }
4266 project.plugins_incomplete = true;
4267 continue;
4268 }
4269 let combined = constraints::combine(&reqs);
4270 let mut files: Vec<PathBuf> = ds.iter().map(|d| d.file.clone()).collect();
4271 files.dedup();
4272 out.push(PluginRequirement {
4273 name: name.clone(),
4274 version: combined.to_string(),
4275 declared_in: files,
4276 source,
4277 });
4278 }
4279 project.plugins = out;
4280
4281 let mut uses = Vec::new();
4282 let profiles = &project.profiles;
4283 for (role, kind, refs) in [
4284 (Role::Connection, PluginKind::Source, &used.connections),
4285 (Role::Destination, PluginKind::Destination, &used.destinations),
4286 ] {
4287 for name in refs.keys() {
4288 let Some(p) = profiles.get(role, name) else {
4289 continue;
4290 };
4291 let role_name = role.as_str();
4292 for out_ in p.targets.values() {
4293 if kind == PluginKind::Destination && out_.kind == LOCAL_TYPE {
4294 continue;
4295 }
4296 let id = PluginId::new(kind, out_.kind.clone());
4297 if uses.iter().any(|u: &PluginUse| u.plugin == id) {
4298 continue;
4299 }
4300 uses.push(PluginUse {
4301 plugin: id,
4302 file: profiles.file.as_ref().map(|f| f.display.clone()),
4303 line: profiles.line_of(role, name),
4304 what: format!("`type: {}` used by {role_name} profile `{name}`", out_.kind),
4305 });
4306 }
4307 }
4308 }
4309 for (fmt, (ctx, file)) in &used.formats {
4310 let note = if fmt == "csv" {
4311 " (csv is the built-in default output)"
4312 } else {
4313 ""
4314 };
4315 uses.push(PluginUse {
4316 plugin: PluginId::new(PluginKind::Format, fmt.clone()),
4317 file: Some(file.clone()),
4318 line: None,
4319 what: format!("format `{fmt}` is used by {ctx}{note}"),
4320 });
4321 }
4322 project.plugin_uses = uses;
4323 }
4324
4325 fn old_plugin_key(&mut self, yf: &YamlFile, key: &str) {
4327 self.diags.error(
4328 "moved-plugin-declaration",
4329 Some(yf.display.clone()),
4330 yf.line_of(key, None),
4331 format!(
4332 "`{key}:` no longer declares plugins: list plugin packages under `plugins:` instead (e.g. `plugins: [duckdb, object_store]`)"
4333 ),
4334 );
4335 }
4336
4337 fn preflight(&mut self, project: &Project, _used: &Usage) {
4340 let cli_owned: Vec<String> = self.opts.vars.keys().cloned().collect();
4341 let cli: BTreeSet<&str> = cli_owned.iter().map(String::as_str).collect();
4342 let mut sources: BTreeMap<PathBuf, String> = BTreeMap::new();
4343 let mut read = |root: &Path, p: &Path| -> Option<String> {
4344 if let Some(s) = sources.get(p) {
4345 return Some(s.clone());
4346 }
4347 let s = std::fs::read_to_string(root.join(p)).ok()?;
4348 sources.insert(p.to_path_buf(), s.clone());
4349 Some(s)
4350 };
4351 let mut checked: BTreeSet<PathBuf> = BTreeSet::new();
4352 let root = self.root.clone();
4353 let mut defs: BTreeMap<String, (PathBuf, preflight::MacroDef)> = BTreeMap::new();
4355 for m in &project.macros {
4356 if let Some(src) = read(&root, m) {
4357 self.check_template_text(m, &src, 0, None, &cli);
4358 checked.insert(m.clone());
4359 for d in preflight::macro_defs(&src) {
4360 defs.insert(d.name.clone(), (m.clone(), d));
4361 }
4362 }
4363 }
4364 for r in &project.reports {
4365 for b in &r.bindings {
4366 let ctx = match &b.set {
4367 Some(s) => format!("report `{}`, Set `{s}`", r.name),
4368 None => format!("report `{}`", r.name),
4369 };
4370 let mut called: Vec<String> = Vec::new();
4371 let mut refs: Vec<String> = Vec::new();
4372 for q in &b.queries {
4373 let Some(src) = read(&root, &q.path) else { continue };
4374 let first = checked.insert(q.path.clone());
4375 self.check_vars(&q.path, &src, 0, &b.vars, &cli, &ctx);
4376 if first {
4377 self.check_template_text(&q.path, &src, 0, None, &cli);
4378 }
4379 called.extend(preflight::called_names(&src));
4380 refs.extend(preflight::refs(&src).into_iter().map(|(n, _)| n));
4381 }
4382 let mut seen_refs = BTreeSet::new();
4384 while let Some(name) = refs.pop() {
4385 let Some(path) = project.sql.get(&name) else {
4386 continue;
4387 };
4388 if !seen_refs.insert(name) {
4389 continue;
4390 }
4391 let Some(src) = read(&root, path) else { continue };
4392 self.check_vars(path, &src, 0, &b.vars, &cli, &ctx);
4393 if checked.insert(path.clone()) {
4394 self.check_template_text(path, &src, 0, None, &cli);
4395 }
4396 called.extend(preflight::called_names(&src));
4397 refs.extend(preflight::refs(&src).into_iter().map(|(n, _)| n));
4398 }
4399 let mut seen = BTreeSet::new();
4401 while let Some(name) = called.pop() {
4402 let Some((file, def)) = defs.get(&name) else {
4403 continue;
4404 };
4405 if !seen.insert(name) {
4406 continue;
4407 }
4408 self.check_vars(file, &def.body, def.line_offset, &b.vars, &cli, &ctx);
4409 called.extend(preflight::called_names(&def.body));
4410 }
4411 let mut values: Vec<String> = Vec::new();
4413 for d in &b.output.destinations {
4414 values.extend(d.path.clone());
4415 values.extend(d.options.values().flat_map(json_strings));
4416 }
4417 if let Some(t) = &b.output.template {
4418 values.extend(t.bindings.iter().filter_map(|tb| tb.value.clone()));
4419 }
4420 for v in values.iter().filter(|v| preflight::is_templated(v)) {
4421 let yf = std::fs::read_to_string(root.join(&r.file)).unwrap_or_default();
4422 let line = yf.lines().position(|l| l.contains(v.as_str())).map(|i| i + 1);
4423 self.check_template_text(&r.file, v, line.map_or(0, |l| l - 1), line, &cli);
4424 self.check_vars(&r.file, v, line.map_or(0, |l| l - 1), &b.vars, &cli, &ctx);
4425 }
4426 }
4427 }
4428 }
4429
4430 fn check_template_text(
4432 &mut self,
4433 file: &Path,
4434 src: &str,
4435 line_offset: usize,
4436 fixed_line: Option<usize>,
4437 _cli: &BTreeSet<&str>,
4438 ) {
4439 let f = Some(file.to_path_buf());
4440 if let Err((line, msg)) = preflight::check_syntax(&file.to_string_lossy(), src) {
4441 let line = fixed_line.or(line.map(|l| l + line_offset));
4442 self.diags.error(
4443 "jinja-syntax",
4444 f.clone(),
4445 line,
4446 format!("Jinja syntax error: {msg}"),
4447 );
4448 return;
4449 }
4450 for c in preflight::calls(src)
4451 .into_iter()
4452 .filter(|c| c.func == "env_var" && !c.has_default)
4453 {
4454 if std::env::var_os(&c.name).is_none() {
4455 self.diags.error(
4456 "unset-env-var",
4457 f.clone(),
4458 Some(fixed_line.unwrap_or(c.line + line_offset)),
4459 format!(
4460 "`env_var('{}')`: environment variable `{}` is not set and no default is given",
4461 c.name, c.name
4462 ),
4463 );
4464 }
4465 }
4466 for (name, instead, line) in preflight::removed_names(src) {
4467 self.diags.error(
4468 "removed-template-name",
4469 f.clone(),
4470 Some(fixed_line.unwrap_or(line + line_offset)),
4471 format!("`{name}` was removed in DRE 0.2: use {instead}"),
4472 );
4473 }
4474 for (r, line) in preflight::unknown_run_refs(src) {
4475 self.diags.error(
4476 "unknown-run-attribute",
4477 f.clone(),
4478 Some(fixed_line.unwrap_or(line + line_offset)),
4479 format!(
4480 "`{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(...)"
4481 ),
4482 );
4483 }
4484 }
4485
4486 fn check_vars(
4487 &mut self,
4488 file: &Path,
4489 src: &str,
4490 line_offset: usize,
4491 vars: &JsonMap<String, Json>,
4492 cli: &BTreeSet<&str>,
4493 ctx: &str,
4494 ) {
4495 for c in preflight::calls(src)
4496 .into_iter()
4497 .filter(|c| c.func == "var" && !c.has_default)
4498 {
4499 if !vars.contains_key(&c.name) && !cli.contains(c.name.as_str()) {
4500 self.diags.error(
4501 "unresolved-var",
4502 Some(file.to_path_buf()),
4503 Some(c.line + line_offset),
4504 format!(
4505 "`var('{}')` has no value for {ctx} (checked --var, Set, report, folder and project vars) and no default",
4506 c.name
4507 ),
4508 );
4509 }
4510 }
4511 }
4512
4513 fn check_template_files(&mut self, project: &Project) {
4514 let mut seen = BTreeSet::new();
4515 for r in &project.reports {
4516 for b in &r.bindings {
4517 let Some(t) = &b.output.template else { continue };
4518 if !seen.insert((r.name.clone(), t.file.clone(), format!("{:?}", t.bindings))) {
4519 continue;
4520 }
4521 let candidates = [self.root.join(&t.file), self.root.join("templates").join(&t.file)];
4522 let Some(path) = candidates.iter().find(|p| p.is_file()) else {
4523 self.diags.error(
4524 "missing-template",
4525 Some(r.file.clone()),
4526 None,
4527 format!("report `{}`: template file `{}` doesn't exist", r.name, t.file),
4528 );
4529 continue;
4530 };
4531 match template_sheets(path) {
4532 Ok(sheets) => {
4533 for tb in &t.bindings {
4534 if !sheets.contains(&tb.sheet) {
4535 self.diags.error(
4536 "invalid-template",
4537 Some(r.file.clone()),
4538 None,
4539 format!(
4540 "report `{}`: template `{}` has no sheet `{}` (sheets: {})",
4541 r.name,
4542 t.file,
4543 tb.sheet,
4544 sheets.join(", ")
4545 ),
4546 );
4547 }
4548 }
4549 }
4550 Err(e) => self.diags.error(
4551 "invalid-template",
4552 Some(r.file.clone()),
4553 None,
4554 format!("report `{}`: can't read template `{}`: {e}", r.name, t.file),
4555 ),
4556 }
4557 }
4558 }
4559 }
4560}
4561
4562pub fn template_sheets(path: &Path) -> Result<Vec<String>, String> {
4564 use calamine::Reader;
4565 let wb: calamine::Xlsx<_> =
4566 calamine::open_workbook(path).map_err(|e: calamine::XlsxError| e.to_string())?;
4567 Ok(wb.sheet_names().to_vec())
4568}
4569
4570struct RawReport {
4571 name: String,
4572 file: Rc<YamlFile>,
4573 folder: Vec<String>,
4574 keys: BTreeMap<String, Located<Value>>,
4575}
4576
4577struct BindingBase {
4578 profile: Option<String>,
4579 profile_at: Option<ProfileAt>,
4580 vars: JsonMap<String, Json>,
4581 output: Mapping,
4582}
4583
4584#[derive(Default)]
4586struct Usage {
4587 connections: BTreeMap<String, (Option<PathBuf>, Option<usize>)>,
4588 destinations: BTreeMap<String, (Option<PathBuf>, Option<usize>)>,
4589 formats: BTreeMap<String, (String, PathBuf)>,
4591}
4592
4593impl Usage {
4594 fn connection(&mut self, p: &str, file: Option<PathBuf>, line: Option<usize>) {
4597 if preflight::is_templated(p) {
4598 return;
4599 }
4600 let e = self.connections.entry(p.to_string()).or_insert((None, None));
4601 if e.0.is_none() {
4602 *e = (file, line);
4603 }
4604 }
4605 fn destination(&mut self, p: &str, file: Option<PathBuf>, line: Option<usize>) {
4606 if preflight::is_templated(p) {
4607 return;
4608 }
4609 let e = self.destinations.entry(p.to_string()).or_insert((None, None));
4610 if e.0.is_none() {
4611 *e = (file, line);
4612 }
4613 }
4614 fn format(&mut self, f: &str, ctx: &str, file: &Path) {
4615 self.formats
4616 .entry(f.to_string())
4617 .or_insert_with(|| (ctx.to_string(), file.to_path_buf()));
4618 }
4619}
4620
4621fn builtin_output() -> Mapping {
4626 let mut m = Mapping::new();
4627 m.insert(Value::String("format".into()), Value::String("csv".into()));
4628 m
4629}
4630
4631fn project_default_output(p: &Project) -> Option<Mapping> {
4632 let yf = YamlFile::parse(
4633 std::fs::read_to_string(p.root.join(PROJECT_FILE)).ok()?,
4634 PathBuf::from(PROJECT_FILE),
4635 &mut Diagnostics::default(),
4636 )?;
4637 yf.value
4638 .get("default_output")
4639 .and_then(Value::as_mapping)
4640 .cloned()
4641}
4642
4643pub fn merge_output(base: &mut Mapping, over: &Mapping) -> Option<String> {
4654 let fmt_key = Value::String("format".into());
4655 if let Some(f) = over.get(&fmt_key)
4656 && base.get(&fmt_key) != Some(f)
4657 {
4658 base.retain(|k, _| is_one_of(k, &["destination", "template"]));
4659 }
4660 let mut problem = None;
4661 for (k, v) in over {
4662 if k.as_str() == Some("destination")
4663 && let Value::Mapping(o) = v
4664 {
4665 let profile = |m: &Mapping| m.get("profile").cloned();
4666 match base.get_mut(k) {
4667 Some(Value::Sequence(list)) if o.get("profile").is_none() && list.len() > 1 => {
4668 problem = Some(format!(
4669 "this overrides `destination` with no `profile:`, but it inherits {} destinations, so it's unclear which one to change; override the full list instead",
4670 list.len()
4671 ));
4672 continue;
4673 }
4674 Some(Value::Sequence(list))
4675 if list.len() == 1
4676 && list[0]
4677 .as_mapping()
4678 .is_some_and(|b| o.get("profile").is_none() || profile(b) == profile(o)) =>
4679 {
4680 let mut b = list[0].as_mapping().cloned().unwrap_or_default();
4681 b.extend(o.clone());
4682 base.insert(k.clone(), Value::Mapping(b));
4683 continue;
4684 }
4685 Some(Value::Mapping(b)) if o.get("profile").is_none() || profile(b) == profile(o) => {
4686 b.extend(o.clone());
4687 continue;
4688 }
4689 _ => {}
4690 }
4691 }
4692 base.insert(k.clone(), v.clone());
4693 }
4694 problem
4695}
4696
4697fn inherited_profile_at(layers: &[&FolderCfg], project: &Project) -> Option<ProfileAt> {
4700 if let Some((_, line)) = layers.iter().rev().find_map(|l| l.profile.as_ref()) {
4701 return Some(ProfileAt {
4702 file: PathBuf::from(PROJECT_FILE),
4703 line: *line,
4704 key: "`+profile`".into(),
4705 });
4706 }
4707 project.default_profile.as_ref().map(|_| ProfileAt {
4708 file: PathBuf::from(PROJECT_FILE),
4709 line: project.default_profile_line,
4710 key: "`default_profile`".into(),
4711 })
4712}
4713
4714fn folder_layers<'a>(folders: &'a BTreeMap<Vec<String>, FolderCfg>, folder: &[String]) -> Vec<&'a FolderCfg> {
4715 (0..=folder.len())
4716 .filter_map(|n| folders.get(&folder[..n].to_vec()))
4717 .collect()
4718}
4719
4720fn folder_segments(rel: &Path) -> Vec<String> {
4721 rel.components()
4722 .skip(1)
4723 .map(|c| c.as_os_str().to_string_lossy().to_string())
4724 .collect()
4725}
4726
4727pub fn dotted(path: &[String]) -> String {
4728 path.join(".")
4729}
4730
4731fn pick(v: &Value, keys: &[&str]) -> Mapping {
4732 v.as_mapping()
4733 .map(|m| {
4734 m.iter()
4735 .filter(|(k, _)| is_one_of(k, keys))
4736 .map(|(k, v)| (k.clone(), v.clone()))
4737 .collect()
4738 })
4739 .unwrap_or_default()
4740}
4741
4742fn json_strings(v: &Json) -> Vec<String> {
4744 match v {
4745 Json::String(s) => vec![s.clone()],
4746 Json::Array(a) => a.iter().flat_map(json_strings).collect(),
4747 Json::Object(o) => o.values().flat_map(json_strings).collect(),
4748 _ => Vec::new(),
4749 }
4750}
4751
4752fn is_one_of(k: &Value, keys: &[&str]) -> bool {
4753 k.as_str().is_some_and(|k| keys.contains(&k))
4754}
4755
4756fn is_timing_registry(m: &Mapping) -> bool {
4758 !m.is_empty()
4759 && m.values().all(|v| {
4760 v.as_mapping()
4761 .is_some_and(|t| ["cron", "every", "rrule"].iter().any(|k| t.contains_key(*k)))
4762 })
4763}
4764
4765fn is_set_registry(m: &Mapping) -> bool {
4768 m.values().any(Value::is_mapping)
4769 && m.values().all(|v| {
4770 v.is_null()
4771 || v.as_mapping()
4772 .is_some_and(|e| e.keys().all(|k| is_one_of(k, &["profile", "vars"])))
4773 })
4774}
4775
4776fn string_list(v: &Value) -> Option<Vec<String>> {
4777 v.as_sequence()?
4778 .iter()
4779 .map(|i| i.as_str().map(str::to_string))
4780 .collect()
4781}
4782
4783fn entry_name(v: &Value) -> Option<String> {
4784 match v {
4785 Value::String(s) => Some(s.clone()),
4786 Value::Mapping(m) => m.get("query").and_then(Value::as_str).map(str::to_string),
4787 _ => None,
4788 }
4789}
4790
4791fn dedup(v: &mut Vec<String>) {
4792 let mut seen = BTreeSet::new();
4793 v.retain(|t| seen.insert(t.clone()));
4794}
4795
4796fn nth_item_line(text: &str, n: usize) -> Option<usize> {
4797 text.lines()
4798 .enumerate()
4799 .filter(|(_, l)| l.starts_with("- ") || l.trim() == "-")
4800 .nth(n)
4801 .map(|(i, _)| i + 1)
4802}
4803
4804pub fn yaml_to_json(v: &Value) -> Json {
4805 serde_json::to_value(v).unwrap_or(Json::Null)
4806}
4807
4808pub fn yaml_map_to_json(m: &Mapping) -> JsonMap<String, Json> {
4809 match yaml_to_json(&Value::Mapping(m.clone())) {
4810 Json::Object(o) => o,
4811 _ => JsonMap::new(),
4812 }
4813}
4814
4815fn is_identifier(s: &str) -> bool {
4817 !s.is_empty()
4818 && !s.starts_with(|c: char| c.is_ascii_digit())
4819 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
4820}