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