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];
42use crate::profiles::{LOCAL_TYPE, Profiles, Role};
43use crate::yaml::YamlFile;
44use crate::{constraints, options, preflight, schedule, selector, sqlsplit};
45
46pub const PROJECT_FILE: &str = "dre_project.yml";
47pub const REPORTS_DIR: &str = "reports";
48pub const MACROS_DIR: &str = "macros";
49pub const TARGET_DIR: &str = "target";
50pub const LOGS_DIR: &str = "logs";
51pub use crate::plugins::DEPS_DIR;
52pub const DEFAULT_RUN_QUERY_MAX_ROWS: u64 = 10_000;
53
54pub const REPORT_KEYS: &[&str] = &[
55 "name",
56 "tags",
57 "queries",
58 "output",
59 "profile",
60 "sets",
61 "default_set",
62 "schedule",
63 "vars",
64 "timezone",
65];
66const PLUGINS_KEY: &str = "plugins";
68pub const PLUGIN_KEYS: &[&str] = &[PLUGINS_KEY];
69const OLD_PLUGIN_KEYS: &[&str] = &["sources", "destinations", "formats"];
71pub const PROJECT_KEYS: &[&str] = &[
72 "name",
73 "default_profile",
74 "default_output",
75 "format_options",
76 "default_set",
77 "vars",
78 "run_query_max_rows",
79 "lookup_inline_max_rows",
80 "dispatch",
81 "mask_secrets",
82 "timezone",
83 "week_start",
84 "week_numbering",
85 "reports",
86 crate::target::KEY,
87];
88pub const FOLDER_CONFIG_KEYS: &[&str] = &["+tags", "+output", "+profile", "+schedule", "+vars", "+timezone"];
89pub const SET_ENTRY_KEYS: &[&str] = &[
90 "name",
91 "profile",
92 "vars",
93 "exclude",
94 "queries",
95 "tab_names",
96 "output",
97 "schedule",
98];
99pub const QUERY_ENTRY_KEYS: &[&str] = &["query", "tab", "tab_name", "anchor", "header", "columns"];
100pub const OUTPUT_SHARED_KEYS: &[&str] = &["format", "destination", "template", "extension"];
101pub const TEMPLATE_BINDING_KEYS: &[&str] = &[
103 "sheet",
104 "query",
105 "result_index",
106 "anchor",
107 "header",
108 "columns",
109 "cell",
110 "value",
111 "column",
112];
113pub const PLUGIN_ENTRY_KEYS: &[&str] = &["name", "version", "github", "local", "registry"];
115
116#[derive(Debug, Clone, Serialize)]
121pub struct Project {
122 pub name: String,
123 #[serde(skip)]
124 pub root: PathBuf,
125 #[serde(skip)]
128 pub target_dir: PathBuf,
129 #[serde(skip)]
131 pub target_source: crate::target::Source,
132 #[serde(skip_serializing_if = "Option::is_none")]
133 pub default_profile: Option<String>,
134 #[serde(skip_serializing_if = "Option::is_none")]
135 pub default_set: Option<String>,
136 pub vars: JsonMap<String, Json>,
137 pub run_query_max_rows: u64,
138 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
140 pub format_options: BTreeMap<String, JsonMap<String, Json>>,
141 pub reports: Vec<Report>,
142 pub sets: BTreeMap<String, SetDef>,
143 pub plugins: Vec<PluginRequirement>,
145 #[serde(skip)]
148 pub plugin_uses: Vec<PluginUse>,
149 #[serde(skip)]
151 pub plugins_incomplete: bool,
152 #[serde(skip_serializing_if = "Vec::is_empty")]
153 pub schedules: Vec<ScheduleEntry>,
154 pub macros: Vec<PathBuf>,
156 pub packages: Vec<Package>,
158 pub mask_secrets: bool,
160 #[serde(skip_serializing_if = "Option::is_none")]
162 pub timezone: Option<String>,
163 #[serde(skip)]
165 pub week_start: WeekStart,
166 #[serde(skip)]
167 pub week_numbering: WeekNumbering,
168 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
170 pub dispatch: DispatchOrder,
171 #[serde(skip)]
173 pub sql: BTreeMap<String, PathBuf>,
174 #[serde(skip)]
176 pub lookups: BTreeMap<String, Lookup>,
177 pub lookup_inline_max_rows: u64,
179 #[serde(skip)]
181 pub folders: Vec<Vec<String>>,
182 #[serde(skip)]
186 pub sources: Vec<PathBuf>,
187 #[serde(skip)]
188 pub profiles: Profiles,
189}
190
191impl Project {
192 pub fn report(&self, name: &str) -> Option<&Report> {
193 self.reports.iter().find(|r| r.name == name)
194 }
195}
196
197#[derive(Debug, Clone, Serialize)]
198pub struct Report {
199 pub name: String,
200 pub managed: bool,
201 pub file: PathBuf,
203 pub folder: Vec<String>,
205 pub tags: Vec<String>,
206 pub queries: Vec<QueryEntry>,
207 #[serde(skip_serializing_if = "Option::is_none")]
208 pub default_set: Option<String>,
209 #[serde(skip_serializing_if = "Option::is_none")]
211 pub timezone: Option<String>,
212 pub has_sets: bool,
214 pub bindings: Vec<Binding>,
215 #[serde(skip)]
217 pub base: Binding,
218}
219
220impl Report {
221 pub fn binding(&self, set: &str) -> Option<&Binding> {
222 self.bindings.iter().find(|b| b.set.as_deref() == Some(set))
223 }
224}
225
226#[derive(Debug, Clone, PartialEq, Serialize)]
227pub struct QueryEntry {
228 pub query: String,
229 pub path: PathBuf,
231 #[serde(skip_serializing_if = "is_true")]
235 pub tab: bool,
236 #[serde(skip_serializing_if = "Option::is_none")]
237 pub tab_name: Option<String>,
238 #[serde(skip_serializing_if = "Option::is_none")]
239 pub anchor: Option<String>,
240 #[serde(skip_serializing_if = "Option::is_none")]
241 pub header: Option<bool>,
242 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
244 pub columns: BTreeMap<String, ColumnOptions>,
245}
246
247fn is_true(b: &bool) -> bool {
248 *b
249}
250
251fn one_tab_per_file(name: &str) -> String {
253 format!(
254 "`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:`"
255 )
256}
257
258#[derive(Debug, Clone, Default, Serialize)]
260pub struct Binding {
261 pub set: Option<String>,
263 #[serde(skip_serializing_if = "Option::is_none")]
264 pub profile: Option<String>,
265 pub vars: JsonMap<String, Json>,
267 pub queries: Vec<QueryEntry>,
268 pub output: Output,
269 #[serde(skip_serializing_if = "Vec::is_empty")]
271 pub schedules: Vec<String>,
272}
273
274impl Binding {
275 pub fn dir_name(&self) -> &str {
277 self.set.as_deref().unwrap_or("default")
278 }
279}
280
281#[derive(Debug, Clone, Default, PartialEq, Serialize)]
282pub struct Output {
283 pub format: String,
284 pub options: JsonMap<String, Json>,
286 #[serde(skip_serializing_if = "Vec::is_empty")]
288 pub destinations: Vec<Destination>,
289 #[serde(skip_serializing_if = "Option::is_none")]
290 pub template: Option<Template>,
291 #[serde(skip_serializing_if = "Option::is_none")]
294 pub extension: Option<String>,
295}
296
297#[derive(Debug, Clone, PartialEq, Serialize)]
298pub struct Destination {
299 pub profile: String,
300 #[serde(skip_serializing_if = "Option::is_none")]
301 pub path: Option<String>,
302 #[serde(skip_serializing_if = "JsonMap::is_empty")]
305 pub options: JsonMap<String, Json>,
306}
307
308#[derive(Debug, Clone, PartialEq, Serialize)]
309pub struct Template {
310 pub file: String,
311 pub bindings: Vec<TemplateBinding>,
312}
313
314#[derive(Debug, Clone, PartialEq, Serialize)]
315pub struct TemplateBinding {
316 pub sheet: String,
317 #[serde(skip_serializing_if = "Option::is_none")]
318 pub query: Option<String>,
319 #[serde(skip_serializing_if = "Option::is_none")]
320 pub result_index: Option<usize>,
321 #[serde(skip_serializing_if = "Option::is_none")]
322 pub anchor: Option<String>,
323 #[serde(skip_serializing_if = "Option::is_none")]
324 pub header: Option<bool>,
325 #[serde(skip_serializing_if = "Option::is_none")]
326 pub columns: Option<Vec<String>>,
327 #[serde(skip_serializing_if = "Option::is_none")]
328 pub cell: Option<String>,
329 #[serde(skip_serializing_if = "Option::is_none")]
330 pub value: Option<String>,
331 #[serde(skip_serializing_if = "Option::is_none")]
332 pub column: Option<String>,
333}
334
335#[derive(Debug, Clone, Serialize)]
336pub struct SetDef {
337 #[serde(skip_serializing_if = "Option::is_none")]
338 pub profile: Option<String>,
339 pub vars: JsonMap<String, Json>,
340}
341
342pub use dre_protocol::Kind as PluginKind;
344pub use dre_protocol::PluginId;
345pub use dre_protocol::msg::ColumnOptions;
346
347#[derive(Debug, Clone, Serialize)]
349pub struct PluginRequirement {
350 pub name: String,
352 pub version: String,
354 pub declared_in: Vec<PathBuf>,
356 #[serde(skip_serializing_if = "PluginSource::is_default")]
358 pub source: PluginSource,
359}
360
361#[derive(Debug, Clone, Serialize)]
363pub struct PluginUse {
364 pub plugin: PluginId,
365 pub file: Option<PathBuf>,
366 pub line: Option<usize>,
367 pub what: String,
369}
370
371#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
373#[serde(rename_all = "snake_case")]
374pub enum PluginSource {
375 #[default]
377 Default,
378 Registry(String),
380 Github(String),
382 Local(String),
384}
385
386impl PluginSource {
387 pub fn is_default(&self) -> bool {
388 *self == PluginSource::Default
389 }
390
391 pub fn lock_key(&self) -> Option<String> {
393 match self {
394 PluginSource::Default => None,
395 PluginSource::Registry(u) => Some(format!("registry:{u}")),
396 PluginSource::Github(r) => Some(format!("github:{r}")),
397 PluginSource::Local(p) => Some(format!("local:{p}")),
398 }
399 }
400}
401
402impl PluginRequirement {
403 pub fn req(&self) -> semver::VersionReq {
404 semver::VersionReq::parse(&self.version).unwrap_or(semver::VersionReq::STAR)
405 }
406}
407
408#[derive(Debug, Clone, Serialize)]
409pub struct ScheduleEntry {
410 pub name: String,
411 #[serde(skip_serializing_if = "Option::is_none")]
412 pub select: Option<String>,
413 #[serde(skip_serializing_if = "Option::is_none")]
414 pub report: Option<String>,
415 #[serde(skip_serializing_if = "Option::is_none")]
416 pub set: Option<String>,
417 pub schedule: JsonMap<String, Json>,
418 #[serde(skip_serializing_if = "JsonMap::is_empty")]
420 pub vars: JsonMap<String, Json>,
421 #[serde(skip_serializing_if = "Option::is_none")]
423 pub timezone: Option<String>,
424 #[serde(skip)]
426 pub location: (PathBuf, Option<usize>),
427}
428
429#[derive(Debug, Clone, Default)]
434pub struct LoadOptions {
435 pub profiles_dir: Option<PathBuf>,
437 pub target: Option<String>,
439 pub vars: BTreeMap<String, String>,
441 pub target_path: Option<String>,
443}
444
445pub fn load(root: &Path, opts: &LoadOptions) -> (Option<Project>, Diagnostics) {
448 let mut l = Loader {
449 root: root.to_path_buf(),
450 target: crate::target::TargetPath::default_for(root),
451 target_inside: Some(PathBuf::from(TARGET_DIR)),
452 opts: opts.clone(),
453 diags: Diagnostics::default(),
454 format_options: Mapping::new(),
455 };
456 let project = l.run();
457 (project, l.diags)
458}
459
460struct Loader {
461 root: PathBuf,
462 target: crate::target::TargetPath,
464 target_inside: Option<PathBuf>,
466 opts: LoadOptions,
467 diags: Diagnostics,
468 format_options: Mapping,
470}
471
472struct Fragment {
474 file: Rc<YamlFile>,
475 name: String,
476 explicit_name: bool,
477 folder: Vec<String>,
478 map: Mapping,
479}
480
481#[derive(Default, Clone)]
483struct FolderCfg {
484 tags: Vec<String>,
485 output: Option<Mapping>,
486 profile: Option<(String, Option<usize>)>,
487 vars: Option<Mapping>,
488 timezone: Option<String>,
489}
490
491struct Discovered {
492 yaml: Vec<PathBuf>,
493 sql: Vec<PathBuf>,
495 macros: Vec<PathBuf>,
496 lookups: Vec<PathBuf>,
498 folders: Vec<Vec<String>>,
499 sources: Vec<PathBuf>,
501}
502
503struct Located<T> {
505 value: T,
506 file: Rc<YamlFile>,
507}
508
509impl Loader {
510 fn rel(&self, p: &Path) -> PathBuf {
511 crate::slash(p.strip_prefix(&self.root).unwrap_or(p))
512 }
513
514 fn run(&mut self) -> Option<Project> {
515 let pfile = self.root.join(PROJECT_FILE);
516 if !pfile.is_file() {
517 self.diags.error(
518 "project-file-missing",
519 Some(PathBuf::from(PROJECT_FILE)),
520 None,
521 format!(
522 "no {PROJECT_FILE} found in {}",
523 crate::slash(&self.root).display()
524 ),
525 );
526 return None;
527 }
528 let pyaml = Rc::new(YamlFile::load(
529 &pfile,
530 PathBuf::from(PROJECT_FILE),
531 &mut self.diags,
532 )?);
533 let project_target = pyaml.value.get(crate::target::KEY).and_then(Value::as_str);
534 if let Some(v) = pyaml.value.get(crate::target::KEY)
535 && !v.is_string()
536 {
537 self.diags.error(
538 "invalid-field",
539 Some(PathBuf::from(PROJECT_FILE)),
540 pyaml.line_of(crate::target::KEY, None),
541 "`target_path` must be a path",
542 );
543 return None;
544 }
545 match crate::target::resolve(&self.root, self.opts.target_path.as_deref(), project_target) {
546 Ok(t) => {
547 self.target_inside = crate::target::inside(&self.root, &t.dir);
548 self.target = t;
549 }
550 Err(e) => {
551 let from_file = self.opts.target_path.is_none()
552 && std::env::var(crate::target::ENV).map_or(true, |v| v.is_empty());
553 self.diags.error(
554 "invalid-target-path",
555 from_file.then(|| PathBuf::from(PROJECT_FILE)),
556 from_file
557 .then(|| pyaml.line_of(crate::target::KEY, None))
558 .flatten(),
559 e,
560 );
561 return None;
562 }
563 }
564 let mut project = self.parse_project_file(&pyaml)?;
565
566 let found = self.discover();
567 project.folders = found.folders.clone();
568 project.macros = found.macros.clone();
569 project.sources = found.sources.clone();
570 let declared = packages::declared(&self.root, &mut self.diags);
571 project.packages = packages::resolve(&self.root, &declared, &mut self.diags);
572 self.check_macro_namespaces(&project);
573
574 let (profiles_dir, found_by) =
575 crate::profiles::locate(self.opts.profiles_dir.as_deref(), Some(&self.root));
576 project.profiles = Profiles::load(&profiles_dir, found_by, &mut self.diags);
577
578 let folder_cfg = self.parse_folder_config(&pyaml, &project.folders);
580 let mut used = Usage::default();
581 if let Some(p) = &project.default_profile {
582 used.source(
583 p,
584 Some(pyaml.display.clone()),
585 pyaml.line_of("default_profile", None),
586 );
587 }
588 for cfg in folder_cfg.values() {
589 if let Some((p, line)) = &cfg.profile {
590 used.source(p, Some(pyaml.display.clone()), *line);
591 }
592 }
593
594 let mut fragments = Vec::new();
596 let mut plugin_decls: Vec<(Rc<YamlFile>, Mapping)> = Vec::new();
597 let mut set_files = Vec::new();
598 let mut schedule_files = Vec::new();
599 plugin_decls.push((pyaml.clone(), pick(&pyaml.value, PLUGIN_KEYS)));
600 for path in &found.yaml {
601 let display = self.rel(path);
602 let Some(yf) = YamlFile::load(path, display.clone(), &mut self.diags) else {
603 continue;
604 };
605 let yf = Rc::new(yf);
606 self.classify(
607 yf,
608 &mut fragments,
609 &mut plugin_decls,
610 &mut set_files,
611 &mut schedule_files,
612 );
613 }
614
615 project.sets = self.parse_sets(&set_files);
616 let sql_index = self.index_sql(&found.sql);
617 project.sql = sql_index
618 .iter()
619 .filter(|(_, paths)| paths.len() == 1)
620 .map(|(name, paths)| (name.clone(), paths[0].clone()))
621 .collect();
622 let mut referenced: BTreeSet<String> = fragments
624 .iter()
625 .filter_map(|f| f.map.get("queries").and_then(Value::as_sequence))
626 .flatten()
627 .filter_map(entry_name)
628 .collect();
629 project.lookups = lookups::discover(&self.root, &found.lookups, &mut self.diags);
630 for (name, l) in &project.lookups {
631 if let Some(sql) = sql_index.get(name) {
632 self.diags.error(
633 "duplicate-ref-name",
634 Some(l.file.clone()),
635 None,
636 format!(
637 "lookup `{name}` has the same name as {}; `ref()` names must be unique",
638 sql[0].display()
639 ),
640 );
641 }
642 if let Err(e) = lookups::read(&self.root, l) {
643 self.diags.error("invalid-lookup", Some(l.file.clone()), None, e);
644 }
645 }
646 referenced.extend(self.check_refs(&found.sql, &found.macros, &sql_index, &project.lookups));
648 let mut reports = self.merge_reports(fragments);
649
650 let mut built = Vec::new();
651 for r in reports.iter_mut() {
652 let queries = self.resolve_queries(r, &sql_index, &mut referenced);
653 built.push((queries, r));
654 }
655 let mut resolved: Vec<Report> = Vec::new();
656 for (queries, raw) in built {
657 if let Some(rep) = self.resolve_managed(raw, queries, &project, &folder_cfg, &mut used) {
658 resolved.push(rep);
659 }
660 }
661 let managed_names: BTreeMap<String, PathBuf> = resolved
662 .iter()
663 .map(|r| (r.name.clone(), r.file.clone()))
664 .collect();
665 for (name, path) in &sql_index {
666 if referenced.contains(name) || path.len() != 1 {
667 continue;
668 }
669 let path = &path[0];
670 if let Some(other) = managed_names.get(name) {
671 self.diags.error(
672 "duplicate-report-name",
673 Some(path.clone()),
674 None,
675 format!(
676 "unmanaged report `{name}` has the same name as the report declared in {}; report names must be unique project-wide",
677 other.display()
678 ),
679 );
680 continue;
681 }
682 resolved.push(self.resolve_unmanaged(name, path, &project, &folder_cfg, &mut used));
683 }
684 resolved.sort_by(|a, b| a.name.cmp(&b.name));
685 project.reports = resolved;
686
687 self.check_profiles(&project, &used);
688 self.check_plugins(&plugin_decls, &mut project, &used);
689 project.schedules = self.parse_schedules(&schedule_files, &project);
690 self.apply_schedules(&mut project);
691 self.preflight(&project, &used);
692 self.check_template_files(&project);
693
694 Some(project)
695 }
696
697 fn parse_project_file(&mut self, yf: &Rc<YamlFile>) -> Option<Project> {
700 let file = Some(yf.display.clone());
701 let Some(m) = yf.value.as_mapping() else {
702 self.diags.error(
703 "invalid-project",
704 file,
705 None,
706 format!("{PROJECT_FILE} must be a map"),
707 );
708 return None;
709 };
710 for k in m.keys().filter_map(Value::as_str) {
711 if OLD_PLUGIN_KEYS.contains(&k) {
712 self.old_plugin_key(yf, k);
713 } else if !PROJECT_KEYS.contains(&k) && !PLUGIN_KEYS.contains(&k) {
714 self.diags.error(
715 "unknown-key",
716 file.clone(),
717 yf.line_of(k, None),
718 format!("unknown key `{k}`"),
719 );
720 }
721 }
722 let name = match m.get("name") {
723 Some(Value::String(s)) if !s.trim().is_empty() => Some(s.clone()),
724 Some(_) => {
725 self.diags.error(
726 "invalid-field",
727 file.clone(),
728 yf.line_of("name", None),
729 "`name` must be a non-empty string",
730 );
731 None
732 }
733 None => {
734 self.diags.error(
735 "missing-field",
736 file.clone(),
737 None,
738 "missing required field `name`",
739 );
740 None
741 }
742 };
743 let default_profile = self.opt_string(yf, m, "default_profile");
744 let default_set = self.opt_string(yf, m, "default_set");
745 let vars = self.opt_vars(yf, m.get("vars"), "vars");
746 let run_query_max_rows = match m.get("run_query_max_rows") {
747 None => DEFAULT_RUN_QUERY_MAX_ROWS,
748 Some(v) => match v.as_u64() {
749 Some(n) if n > 0 => n,
750 _ => {
751 self.diags.error(
752 "invalid-field",
753 file.clone(),
754 yf.line_of("run_query_max_rows", None),
755 "`run_query_max_rows` must be a positive whole number",
756 );
757 DEFAULT_RUN_QUERY_MAX_ROWS
758 }
759 },
760 };
761 let dispatch = self.parse_dispatch(yf, m.get("dispatch"));
762 match m.get("format_options") {
763 None | Some(Value::Null) => {}
764 Some(Value::Mapping(f)) if f.values().all(|v| v.is_mapping()) => self.format_options = f.clone(),
765 Some(_) => self.diags.error(
766 "invalid-field",
767 file.clone(),
768 yf.line_of("format_options", None),
769 "`format_options` must map format names to their options, e.g. `delimited: {delimiter: \"|\"}`",
770 ),
771 }
772 let mask_secrets = match m.get("mask_secrets") {
773 None => true,
774 Some(Value::Bool(b)) => *b,
775 Some(_) => {
776 self.diags.error(
777 "invalid-field",
778 file.clone(),
779 yf.line_of("mask_secrets", None),
780 "`mask_secrets` must be true or false",
781 );
782 true
783 }
784 };
785 let timezone = match m.get("timezone") {
786 None => None,
787 Some(v) => self.timezone_value(v, &yf.display, yf.line_of("timezone", None), "`timezone`"),
788 };
789 let week_start = match m.get("week_start") {
790 None => WeekStart::Monday,
791 Some(v) => v.as_str().and_then(WeekStart::parse).unwrap_or_else(|| {
792 self.diags.error(
793 "invalid-field",
794 file.clone(),
795 yf.line_of("week_start", None),
796 "`week_start` must be `monday` or `sunday`",
797 );
798 WeekStart::Monday
799 }),
800 };
801 let week_numbering = match m.get("week_numbering") {
802 None => WeekNumbering::Iso,
803 Some(v) => v.as_str().and_then(WeekNumbering::parse).unwrap_or_else(|| {
804 self.diags.error(
805 "invalid-field",
806 file.clone(),
807 yf.line_of("week_numbering", None),
808 "`week_numbering` must be `iso` or `us`",
809 );
810 WeekNumbering::Iso
811 }),
812 };
813 let lookup_inline_max_rows = match m.get("lookup_inline_max_rows") {
814 None => DEFAULT_INLINE_MAX_ROWS,
815 Some(v) => match v.as_u64() {
816 Some(n) => n,
817 _ => {
818 self.diags.error(
819 "invalid-field",
820 file.clone(),
821 yf.line_of("lookup_inline_max_rows", None),
822 "`lookup_inline_max_rows` must be a whole number",
823 );
824 DEFAULT_INLINE_MAX_ROWS
825 }
826 },
827 };
828 Some(Project {
829 name: name?,
830 root: self.root.clone(),
831 target_dir: self.target.dir.clone(),
832 target_source: self.target.source,
833 default_profile,
834 default_set,
835 vars,
836 run_query_max_rows,
837 reports: Vec::new(),
838 sets: BTreeMap::new(),
839 plugins: Vec::new(),
840 plugin_uses: Vec::new(),
841 plugins_incomplete: false,
842 schedules: Vec::new(),
843 macros: Vec::new(),
844 packages: Vec::new(),
845 mask_secrets,
846 timezone,
847 week_start,
848 week_numbering,
849 dispatch,
850 sql: BTreeMap::new(),
851 lookups: BTreeMap::new(),
852 lookup_inline_max_rows,
853 format_options: self
854 .format_options
855 .iter()
856 .filter_map(|(k, v)| Some((k.as_str()?.to_string(), yaml_map_to_json(v.as_mapping()?))))
857 .collect(),
858 folders: Vec::new(),
859 sources: Vec::new(),
860 profiles: Profiles::default(),
861 })
862 }
863
864 fn timezone_value(&mut self, v: &Value, file: &Path, line: Option<usize>, what: &str) -> Option<String> {
866 let msg = match v.as_str() {
867 Some(s) => match crate::dates::parse_tz(s) {
868 Ok(_) => return Some(s.to_string()),
869 Err(e) => format!("{what}: {e}"),
870 },
871 None => format!("{what} must be a string, an IANA timezone name such as `Australia/Sydney`"),
872 };
873 self.diags
874 .error("invalid-timezone", Some(file.to_path_buf()), line, msg);
875 None
876 }
877
878 fn opt_string(&mut self, yf: &YamlFile, m: &Mapping, key: &str) -> Option<String> {
879 match m.get(key)? {
880 Value::String(s) => Some(s.clone()),
881 _ => {
882 self.diags.error(
883 "invalid-field",
884 Some(yf.display.clone()),
885 yf.line_of(key, None),
886 format!("`{key}` must be a string"),
887 );
888 None
889 }
890 }
891 }
892
893 fn parse_dispatch(&mut self, yf: &YamlFile, v: Option<&Value>) -> DispatchOrder {
895 let mut out = DispatchOrder::new();
896 let Some(v) = v else { return out };
897 let line = yf.line_of("dispatch", None);
898 let Some(list) = v.as_sequence() else {
899 self.diags.error(
900 "invalid-field",
901 Some(yf.display.clone()),
902 line,
903 "`dispatch` must be a list of `{macro_namespace, search_order}`",
904 );
905 return out;
906 };
907 for e in list {
908 let ns = e.get("macro_namespace").and_then(Value::as_str);
909 let order: Option<Vec<String>> = e
910 .get("search_order")
911 .and_then(Value::as_sequence)
912 .map(|s| s.iter().filter_map(|x| x.as_str().map(str::to_string)).collect());
913 match (ns, order) {
914 (Some(ns), Some(order)) if !order.is_empty() => {
915 out.insert(ns.to_string(), order);
916 }
917 _ => self.diags.error(
918 "invalid-field",
919 Some(yf.display.clone()),
920 line,
921 "each `dispatch` entry needs `macro_namespace` and a non-empty `search_order` list",
922 ),
923 }
924 }
925 out
926 }
927
928 fn check_macro_namespaces(&mut self, project: &Project) {
931 let own: BTreeSet<String> = project
932 .macros
933 .iter()
934 .filter_map(|m| std::fs::read_to_string(self.root.join(m)).ok())
935 .flat_map(|src| preflight::macro_defs(&src).into_iter().map(|d| d.name))
936 .collect();
937 for p in &project.packages {
938 let clash = if RESERVED_NAMES.contains(&p.name.as_str()) {
939 Some("a DRE function or variable".to_string())
940 } else if own.contains(&p.name) {
941 Some("a macro in macros/".to_string())
942 } else if p.name == project.name {
943 Some("this project".to_string())
944 } else {
945 None
946 };
947 if let Some(c) = clash {
948 self.diags.error(
949 "package-name-clash",
950 None,
951 None,
952 format!(
953 "package `{}` has the same name as {c}; macros couldn't be called through it",
954 p.name
955 ),
956 );
957 }
958 }
959 for (ns, order) in &project.dispatch {
960 for n in order {
961 if n != &project.name && !project.packages.iter().any(|p| &p.name == n) {
962 self.diags.error(
963 "invalid-field",
964 Some(PathBuf::from(PROJECT_FILE)),
965 None,
966 format!("`dispatch` for `{ns}` searches `{n}`, which is neither this project nor an installed package"),
967 );
968 }
969 }
970 }
971 }
972
973 fn opt_vars(&mut self, yf: &YamlFile, v: Option<&Value>, key: &str) -> JsonMap<String, Json> {
974 match v {
975 None | Some(Value::Null) => JsonMap::new(),
976 Some(Value::Mapping(m)) => yaml_map_to_json(m),
977 Some(_) => {
978 self.diags.error(
979 "invalid-field",
980 Some(yf.display.clone()),
981 yf.line_of(key, None),
982 format!("`{key}` must be a map"),
983 );
984 JsonMap::new()
985 }
986 }
987 }
988
989 fn parse_folder_config(
990 &mut self,
991 yf: &Rc<YamlFile>,
992 folders: &[Vec<String>],
993 ) -> BTreeMap<Vec<String>, FolderCfg> {
994 let mut out = BTreeMap::new();
995 let Some(tree) = yf.value.get("reports") else {
996 return out;
997 };
998 let mut stack = vec![(Vec::<String>::new(), tree.clone(), yf.line_of("reports", None))];
999 while let Some((path, node, line)) = stack.pop() {
1000 let Some(m) = node.as_mapping() else {
1001 self.diags.error(
1002 "invalid-folder-config",
1003 Some(yf.display.clone()),
1004 line,
1005 format!("folder config for `{}` must be a map", dotted(&path)),
1006 );
1007 continue;
1008 };
1009 let mut cfg = FolderCfg::default();
1010 let mut any = false;
1011 for (k, v) in m {
1012 let Some(k) = k.as_str() else { continue };
1013 let kline = yf.line_of(k, line);
1014 if let Some(key) = k.strip_prefix('+') {
1015 any = true;
1016 if !FOLDER_CONFIG_KEYS.contains(&k) {
1017 self.diags.error(
1018 "unknown-key",
1019 Some(yf.display.clone()),
1020 kline,
1021 format!(
1022 "unknown folder config `{k}`; folder config keys are {}",
1023 FOLDER_CONFIG_KEYS.join(", ")
1024 ),
1025 );
1026 continue;
1027 }
1028 let bad = |s: &mut Self, what: &str| {
1029 s.diags.error(
1030 "invalid-field",
1031 Some(yf.display.clone()),
1032 kline,
1033 format!("`{k}` must be {what}"),
1034 )
1035 };
1036 match key {
1037 "tags" => match string_list(v) {
1038 Some(t) => cfg.tags = t,
1039 None => bad(self, "a list of strings"),
1040 },
1041 "output" => match v.as_mapping() {
1042 Some(o) => cfg.output = Some(o.clone()),
1043 None => bad(self, "a map"),
1044 },
1045 "profile" => match v.as_str() {
1046 Some(p) => cfg.profile = Some((p.to_string(), kline)),
1047 None => bad(self, "a string"),
1048 },
1049 "schedule" => {
1050 let ctx = format!("folder `{}`: `+schedule`", dotted(&path));
1051 self.moved_to_schedules(&yf.display, kline, &ctx);
1052 }
1053 "timezone" => {
1054 cfg.timezone = self.timezone_value(v, &yf.display, kline, "`+timezone`");
1055 }
1056 _ => match v.as_mapping() {
1057 Some(s) => cfg.vars = Some(s.clone()),
1058 None => bad(self, "a map"),
1059 },
1060 }
1061 } else {
1062 let mut p = path.clone();
1063 p.push(k.to_string());
1064 stack.push((p, v.clone(), kline));
1065 }
1066 }
1067 if any && !path.is_empty() {
1068 if !folders.contains(&path) {
1069 self.diags.warning(
1070 "unknown-folder",
1071 Some(yf.display.clone()),
1072 line,
1073 format!(
1074 "folder config for `{}` matches no folder under reports/",
1075 dotted(&path)
1076 ),
1077 );
1078 }
1079 out.insert(path, cfg);
1080 } else if any {
1081 out.insert(path, cfg);
1082 }
1083 }
1084 out
1085 }
1086
1087 fn discover(&mut self) -> Discovered {
1090 let mut d = Discovered {
1091 yaml: Vec::new(),
1092 sql: Vec::new(),
1093 macros: Vec::new(),
1094 lookups: Vec::new(),
1095 folders: Vec::new(),
1096 sources: Vec::new(),
1097 };
1098 let target_inside = self.target_inside.clone();
1099 let root = self.root.clone();
1100 let walker = walkdir::WalkDir::new(&self.root)
1101 .sort_by_file_name()
1102 .into_iter()
1103 .filter_entry(|e| {
1104 let name = e.file_name().to_string_lossy();
1105 e.depth() == 0
1107 || !(name.starts_with('.')
1108 || target_inside
1109 .as_deref()
1110 .is_some_and(|t| e.path().strip_prefix(&root).is_ok_and(|r| r == t))
1111 || (e.depth() == 1 && [TARGET_DIR, DEPS_DIR, LOGS_DIR].contains(&name.as_ref())))
1112 });
1113 for e in walker.filter_map(Result::ok) {
1114 let rel = self.rel(e.path());
1115 let in_reports = rel.starts_with(REPORTS_DIR);
1116 if e.file_type().is_dir() {
1117 if in_reports && rel != Path::new(REPORTS_DIR) {
1118 d.folders.push(folder_segments(&rel));
1119 }
1120 continue;
1121 }
1122 let ext = e.path().extension().and_then(|x| x.to_str()).unwrap_or("");
1123 let yaml = matches!(ext, "yml" | "yaml") && rel != Path::new(crate::profiles::PROFILES_FILE);
1124 if yaml || in_reports || rel.starts_with(MACROS_DIR) || rel.starts_with(LOOKUPS_DIR) {
1125 d.sources.push(rel.clone());
1126 }
1127 if rel.starts_with(LOOKUPS_DIR) {
1128 d.lookups.push(rel);
1129 continue;
1130 }
1131 match ext {
1132 "yml" | "yaml"
1134 if rel != Path::new(PROJECT_FILE) && rel != Path::new(crate::profiles::PROFILES_FILE) =>
1135 {
1136 d.yaml.push(e.path().to_path_buf())
1137 }
1138 "sql" if rel.starts_with(MACROS_DIR) => d.macros.push(rel),
1139 "sql" if in_reports => d.sql.push(rel),
1140 _ => {}
1141 }
1142 }
1143 d
1144 }
1145
1146 fn classify(
1147 &mut self,
1148 yf: Rc<YamlFile>,
1149 fragments: &mut Vec<Fragment>,
1150 plugins: &mut Vec<(Rc<YamlFile>, Mapping)>,
1151 sets: &mut Vec<Rc<YamlFile>>,
1152 schedules: &mut Vec<Rc<YamlFile>>,
1153 ) {
1154 let in_reports = yf.display.starts_with(REPORTS_DIR);
1155 match &yf.value {
1156 Value::Null => {}
1157 Value::Sequence(items)
1158 if !items.is_empty()
1159 && items
1160 .iter()
1161 .all(|i| ["name", "select", "report"].iter().any(|k| i.get(k).is_some())) =>
1162 {
1163 schedules.push(yf.clone());
1164 }
1165 Value::Mapping(m) => {
1166 let pl = pick(&yf.value, PLUGIN_KEYS);
1167 if !pl.is_empty() {
1168 plugins.push((yf.clone(), pl));
1169 }
1170 let dependency_file = packages::DEPENDENCY_FILES
1172 .iter()
1173 .any(|f| yf.display == Path::new(f));
1174 if !dependency_file && m.contains_key("packages") {
1175 self.diags.error(
1176 "misplaced-packages",
1177 Some(yf.display.clone()),
1178 yf.line_of("packages", None),
1179 "`packages:` goes in dependencies.yml or packages.yml at the project root",
1180 );
1181 }
1182 if dependency_file {
1183 for k in m.keys().filter_map(Value::as_str) {
1184 if OLD_PLUGIN_KEYS.contains(&k) {
1185 self.old_plugin_key(&yf, k);
1186 }
1187 }
1188 }
1189 let rest: Mapping = m
1190 .iter()
1191 .filter(|(k, _)| !is_one_of(k, PLUGIN_KEYS) && k.as_str() != Some("packages"))
1192 .filter(|(k, _)| !(dependency_file && is_one_of(k, OLD_PLUGIN_KEYS)))
1193 .map(|(k, v)| (k.clone(), v.clone()))
1194 .collect();
1195 if rest.is_empty() {
1196 return;
1197 }
1198 let looks_like_report = rest.contains_key("queries") || rest.contains_key("name");
1199 if in_reports || looks_like_report {
1200 self.report_fragment(yf.clone(), rest, fragments);
1201 } else if is_set_registry(&rest) {
1202 sets.push(yf.clone());
1203 } else {
1204 self.diags.warning(
1205 "unrecognized-yaml",
1206 Some(yf.display.clone()),
1207 None,
1208 "not recognised as report, Set, plugin or schedule config; ignored",
1209 );
1210 }
1211 }
1212 _ => self.diags.warning(
1213 "unrecognized-yaml",
1214 Some(yf.display.clone()),
1215 None,
1216 "not recognised as report, Set, plugin or schedule config; ignored",
1217 ),
1218 }
1219 }
1220
1221 fn report_fragment(&mut self, yf: Rc<YamlFile>, map: Mapping, out: &mut Vec<Fragment>) {
1222 for k in map.keys().filter_map(Value::as_str) {
1223 if !REPORT_KEYS.contains(&k) {
1224 self.diags.error(
1225 "unknown-key",
1226 Some(yf.display.clone()),
1227 yf.line_of(k, None),
1228 format!("unknown report key `{k}`"),
1229 );
1230 }
1231 }
1232 let folder = folder_segments(yf.display.parent().unwrap_or(Path::new("")));
1233 let (name, explicit_name) = match map.get("name") {
1234 Some(Value::String(s)) if !s.is_empty() => (s.clone(), true),
1235 Some(_) => {
1236 self.diags.error(
1237 "invalid-field",
1238 Some(yf.display.clone()),
1239 yf.line_of("name", None),
1240 "`name` must be a non-empty string",
1241 );
1242 return;
1243 }
1244 None => match folder.last() {
1245 Some(f) => (f.clone(), false),
1246 None => {
1247 self.diags.error(
1248 "missing-field",
1249 Some(yf.display.clone()),
1250 None,
1251 "a report outside a report folder needs an explicit `name:`",
1252 );
1253 return;
1254 }
1255 },
1256 };
1257 out.push(Fragment {
1258 file: yf,
1259 name,
1260 explicit_name,
1261 folder,
1262 map,
1263 });
1264 }
1265
1266 fn parse_sets(&mut self, files: &[Rc<YamlFile>]) -> BTreeMap<String, SetDef> {
1269 let mut out = BTreeMap::new();
1270 let mut seen: BTreeMap<String, PathBuf> = BTreeMap::new();
1271 for yf in files {
1272 let Some(m) = yf.value.as_mapping() else { continue };
1273 for (k, v) in m {
1274 let Some(name) = k.as_str() else { continue };
1275 let line = yf.line_of(name, None);
1276 if let Some(prev) = seen.get(name) {
1277 self.diags.error(
1278 "duplicate-set",
1279 Some(yf.display.clone()),
1280 line,
1281 format!("Set `{name}` is also declared in {}", prev.display()),
1282 );
1283 continue;
1284 }
1285 seen.insert(name.to_string(), yf.display.clone());
1286 let profile = v.get("profile").and_then(Value::as_str).map(str::to_string);
1287 if v.get("profile").is_some() && profile.is_none() {
1288 self.diags.error(
1289 "invalid-field",
1290 Some(yf.display.clone()),
1291 line,
1292 format!("Set `{name}`: `profile` must be a string"),
1293 );
1294 }
1295 let vars = match v.get("vars") {
1296 Some(Value::Mapping(m)) => yaml_map_to_json(m),
1297 None | Some(Value::Null) => JsonMap::new(),
1298 Some(_) => {
1299 self.diags.error(
1300 "invalid-field",
1301 Some(yf.display.clone()),
1302 line,
1303 format!("Set `{name}`: `vars` must be a map"),
1304 );
1305 JsonMap::new()
1306 }
1307 };
1308 out.insert(name.to_string(), SetDef { profile, vars });
1309 }
1310 }
1311 out
1312 }
1313
1314 fn check_refs(
1319 &mut self,
1320 sql: &[PathBuf],
1321 macros: &[PathBuf],
1322 index: &BTreeMap<String, Vec<PathBuf>>,
1323 lookups: &BTreeMap<String, Lookup>,
1324 ) -> BTreeSet<String> {
1325 let mut names = BTreeSet::new();
1326 let mut graph: BTreeMap<String, Vec<(String, usize)>> = BTreeMap::new();
1328 for file in sql.iter().chain(macros) {
1329 let Ok(src) = std::fs::read_to_string(self.root.join(file)) else {
1330 continue;
1331 };
1332 let is_sql = !file.starts_with(MACROS_DIR);
1333 for (name, line) in preflight::refs(&src) {
1334 if index.contains_key(&name) {
1335 if is_sql {
1336 let stem = file.file_stem().unwrap().to_string_lossy().to_string();
1337 graph.entry(stem).or_default().push((name.clone(), line));
1338 }
1339 names.insert(name);
1340 } else if !lookups.contains_key(&name) {
1341 self.diags.error(
1342 "unknown-ref",
1343 Some(file.clone()),
1344 Some(line),
1345 format!("`ref('{name}')`: there's no `{name}.sql` under reports/ and no lookup `{name}` under lookups/"),
1346 );
1347 }
1348 }
1349 }
1350 let mut reported: BTreeSet<Vec<String>> = BTreeSet::new();
1352 for start in graph.keys() {
1353 let mut path = vec![start.clone()];
1354 let mut stack = vec![graph[start].iter()];
1355 while let Some(it) = stack.last_mut() {
1356 let Some((next, line)) = it.next() else {
1357 stack.pop();
1358 path.pop();
1359 continue;
1360 };
1361 if let Some(i) = path.iter().position(|n| n == next) {
1362 let mut cycle = path[i..].to_vec();
1363 let mut key = cycle.clone();
1364 key.sort();
1365 if reported.insert(key) {
1366 cycle.push(next.clone());
1367 let file = index[path.last().unwrap()][0].clone();
1368 self.diags.error(
1369 "ref-cycle",
1370 Some(file),
1371 Some(*line),
1372 format!("`ref()` cycle: {}", cycle.join(" → ")),
1373 );
1374 }
1375 continue;
1376 }
1377 if let Some(edges) = graph.get(next) {
1378 path.push(next.clone());
1379 stack.push(edges.iter());
1380 }
1381 }
1382 }
1383 names
1384 }
1385
1386 fn index_sql(&mut self, sql: &[PathBuf]) -> BTreeMap<String, Vec<PathBuf>> {
1387 let mut index: BTreeMap<String, Vec<PathBuf>> = BTreeMap::new();
1388 for p in sql {
1389 let stem = p.file_stem().unwrap().to_string_lossy().to_string();
1390 index.entry(stem).or_default().push(p.clone());
1391 }
1392 for (name, paths) in &index {
1393 if paths.len() > 1 {
1394 self.diags.error(
1395 "duplicate-sql-name",
1396 Some(paths[1].clone()),
1397 None,
1398 format!(
1399 "`{name}.sql` exists more than once: {}; .sql basenames must be unique project-wide",
1400 paths
1401 .iter()
1402 .map(|p| p.display().to_string())
1403 .collect::<Vec<_>>()
1404 .join(", ")
1405 ),
1406 );
1407 }
1408 }
1409 index
1410 }
1411
1412 fn merge_reports(&mut self, fragments: Vec<Fragment>) -> Vec<RawReport> {
1415 let mut by_name: BTreeMap<String, Vec<Fragment>> = BTreeMap::new();
1416 for f in fragments {
1417 by_name.entry(f.name.clone()).or_default().push(f);
1418 }
1419 let mut out = Vec::new();
1420 for (name, frags) in by_name {
1421 let definers: Vec<&Fragment> = frags.iter().filter(|f| f.map.contains_key("queries")).collect();
1422 if definers.len() > 1 {
1423 self.diags.error(
1424 "duplicate-report-name",
1425 Some(definers[1].file.display.clone()),
1426 definers[1].file.line_of("queries", None),
1427 format!(
1428 "report `{name}` is declared in both {} and {}; report names must be unique project-wide",
1429 definers[0].file.display.display(),
1430 definers[1].file.display.display()
1431 ),
1432 );
1433 continue;
1434 }
1435 let Some(def) = definers.first() else {
1436 for f in &frags {
1437 let msg = if f.explicit_name {
1438 format!(
1439 "report `{name}` has no `queries`; a config fragment must name a report that declares `queries:`"
1440 )
1441 } else {
1442 format!("report `{name}` has no `queries`")
1443 };
1444 self.diags
1445 .error("missing-queries", Some(f.file.display.clone()), None, msg);
1446 }
1447 continue;
1448 };
1449 let mut keys: BTreeMap<String, Located<Value>> = BTreeMap::new();
1450 let mut ok = true;
1451 for f in &frags {
1452 for (k, v) in &f.map {
1453 let Some(k) = k.as_str() else { continue };
1454 if k == "name" || !REPORT_KEYS.contains(&k) {
1455 continue;
1456 }
1457 if let Some(prev) = keys.get(k) {
1458 self.diags.error(
1459 "conflicting-declaration",
1460 Some(f.file.display.clone()),
1461 f.file.line_of(k, None),
1462 format!(
1463 "`{k}` for report `{name}` is declared in both {} and {}",
1464 prev.file.display.display(),
1465 f.file.display.display()
1466 ),
1467 );
1468 ok = false;
1469 continue;
1470 }
1471 keys.insert(
1472 k.to_string(),
1473 Located {
1474 value: v.clone(),
1475 file: f.file.clone(),
1476 },
1477 );
1478 }
1479 }
1480 let _ = ok;
1481 out.push(RawReport {
1482 name,
1483 file: def.file.clone(),
1484 folder: def.folder.clone(),
1485 keys,
1486 });
1487 }
1488 out
1489 }
1490
1491 fn resolve_queries(
1492 &mut self,
1493 r: &RawReport,
1494 index: &BTreeMap<String, Vec<PathBuf>>,
1495 referenced: &mut BTreeSet<String>,
1496 ) -> Vec<QueryEntry> {
1497 let Some(q) = r.keys.get("queries") else {
1498 return Vec::new();
1499 };
1500 let yf = q.file.clone();
1501 let Some(items) = q.value.as_sequence() else {
1502 self.diags.error(
1503 "invalid-field",
1504 Some(yf.display.clone()),
1505 yf.line_of("queries", None),
1506 format!("report `{}`: `queries` must be a list", r.name),
1507 );
1508 return Vec::new();
1509 };
1510 if items.is_empty() {
1511 self.diags.error(
1512 "missing-queries",
1513 Some(yf.display.clone()),
1514 yf.line_of("queries", None),
1515 format!("report `{}` has an empty `queries` list", r.name),
1516 );
1517 }
1518 let mut out = Vec::new();
1519 for item in items {
1520 if let Some(e) = self.query_entry(&r.name, item, &yf, index) {
1521 referenced.insert(e.query.clone());
1522 out.push(e);
1523 } else if let Some(n) = entry_name(item) {
1524 referenced.insert(n);
1525 }
1526 }
1527 out
1528 }
1529
1530 fn query_entry(
1531 &mut self,
1532 report: &str,
1533 item: &Value,
1534 yf: &YamlFile,
1535 index: &BTreeMap<String, Vec<PathBuf>>,
1536 ) -> Option<QueryEntry> {
1537 let file = Some(yf.display.clone());
1538 let (name, m) = match item {
1539 Value::String(s) => (s.clone(), None),
1540 Value::Mapping(m) => match m.get("query").and_then(Value::as_str) {
1541 Some(s) => (s.to_string(), Some(m)),
1542 None => {
1543 self.diags.error(
1544 "invalid-field",
1545 file,
1546 yf.line_of("queries", None),
1547 format!("report `{report}`: a `queries` map entry needs a `query:` name"),
1548 );
1549 return None;
1550 }
1551 },
1552 _ => {
1553 self.diags.error(
1554 "invalid-field",
1555 file,
1556 yf.line_of("queries", None),
1557 format!("report `{report}`: `queries` entries must be names or maps"),
1558 );
1559 return None;
1560 }
1561 };
1562 let line = yf.line_containing(&name);
1563 if name.ends_with(".sql") || name.contains('/') || name.contains('\\') {
1564 self.diags.error(
1565 "invalid-query-name",
1566 file,
1567 line,
1568 format!(
1569 "report `{report}`: query `{name}` must be a bare .sql basename, with no path and no extension"
1570 ),
1571 );
1572 return None;
1573 }
1574 let mut e = QueryEntry {
1575 query: name.clone(),
1576 path: PathBuf::new(),
1577 tab: true,
1578 tab_name: None,
1579 anchor: None,
1580 header: None,
1581 columns: BTreeMap::new(),
1582 };
1583 if let Some(m) = m {
1584 for k in m.keys().filter_map(Value::as_str) {
1585 if !QUERY_ENTRY_KEYS.contains(&k) {
1586 self.diags.error(
1587 "unknown-key",
1588 file.clone(),
1589 line,
1590 format!("report `{report}`: unknown key `{k}` on query `{name}`"),
1591 );
1592 }
1593 }
1594 e.tab_name = match m.get("tab_name") {
1595 None => None,
1596 Some(Value::String(s)) => Some(s.clone()),
1597 Some(Value::Sequence(_)) => {
1598 self.diags.error(
1599 "invalid-field",
1600 file.clone(),
1601 line,
1602 format!("report `{report}`: {}", one_tab_per_file(&name)),
1603 );
1604 None
1605 }
1606 Some(_) => {
1607 self.diags.error(
1608 "invalid-field",
1609 file.clone(),
1610 line,
1611 format!("report `{report}`: `tab_name` of `{name}` must be a string"),
1612 );
1613 None
1614 }
1615 };
1616 match m.get("tab").map(Value::as_bool) {
1617 None => {}
1618 Some(Some(b)) => e.tab = b,
1619 Some(None) => self.diags.error(
1620 "invalid-field",
1621 file.clone(),
1622 line,
1623 format!("report `{report}`: `tab` of `{name}` must be true or false"),
1624 ),
1625 }
1626 if !e.tab && e.tab_name.is_some() {
1627 self.diags.error(
1628 "invalid-field",
1629 file.clone(),
1630 line,
1631 format!("report `{report}`: `{name}` has `tab: false`, so its `tab_name` would never be used; remove one of them"),
1632 );
1633 }
1634 if let Some(a) = m.get("anchor") {
1635 match a.as_str() {
1636 Some(s) if options::is_cell(s) => e.anchor = Some(s.to_string()),
1637 _ => self.diags.error(
1638 "invalid-cell",
1639 file.clone(),
1640 line,
1641 format!("report `{report}`: `anchor` of `{name}` must be a cell reference like `A1`"),
1642 ),
1643 }
1644 }
1645 if let Some(h) = m.get("header") {
1646 match h.as_bool() {
1647 Some(b) => e.header = Some(b),
1648 None => self.diags.error(
1649 "invalid-field",
1650 file.clone(),
1651 line,
1652 format!("report `{report}`: `header` of `{name}` must be true or false"),
1653 ),
1654 }
1655 }
1656 if let Some(c) = m.get("columns") {
1657 let (columns, errs) = dre_protocol::options::parse_columns(&yaml_to_json(c));
1658 for err in errs {
1659 self.diags.error(
1660 "invalid-field",
1661 file.clone(),
1662 line,
1663 format!("report `{report}`: query `{name}`: {err}"),
1664 );
1665 }
1666 e.columns = columns;
1667 }
1668 }
1669 match index.get(&name) {
1670 Some(paths) => {
1671 e.path = paths[0].clone();
1672 Some(e)
1673 }
1674 None => {
1675 self.diags.error(
1676 "unknown-query",
1677 file,
1678 line,
1679 format!("report `{report}`: query `{name}` doesn't match any .sql file under reports/"),
1680 );
1681 None
1682 }
1683 }
1684 }
1685
1686 fn resolve_managed(
1689 &mut self,
1690 r: &RawReport,
1691 queries: Vec<QueryEntry>,
1692 project: &Project,
1693 folders: &BTreeMap<Vec<String>, FolderCfg>,
1694 used: &mut Usage,
1695 ) -> Option<Report> {
1696 let name = r.name.clone();
1697 let layers = folder_layers(folders, &r.folder);
1698 let key = |k: &str| r.keys.get(k);
1699 let located = |k: &str| {
1700 r.keys
1701 .get(k)
1702 .map(|l| (l.file.display.clone(), l.file.line_of(k, None)))
1703 };
1704
1705 let mut tags: Vec<String> = layers.iter().flat_map(|l| l.tags.clone()).collect();
1706 if let Some(t) = key("tags") {
1707 match string_list(&t.value) {
1708 Some(l) => tags.extend(l),
1709 None => {
1710 let (f, l) = located("tags").unwrap();
1711 self.diags.error(
1712 "invalid-field",
1713 Some(f),
1714 l,
1715 format!("report `{name}`: `tags` must be a list of strings"),
1716 );
1717 }
1718 }
1719 }
1720 dedup(&mut tags);
1721
1722 if key("profile").is_some() && key("sets").is_some() {
1723 let (f, l) = located("sets").unwrap();
1724 self.diags.error(
1725 "profile-and-sets",
1726 Some(f),
1727 l,
1728 format!("report `{name}` declares both `profile:` and `sets:`; use one or the other"),
1729 );
1730 }
1731
1732 let report_profile = match key("profile") {
1733 Some(p) => match p.value.as_str() {
1734 Some(s) => {
1735 let (f, l) = located("profile").unwrap();
1736 used.source(s, Some(f), l);
1737 Some(s.to_string())
1738 }
1739 None => {
1740 let (f, l) = located("profile").unwrap();
1741 self.diags.error(
1742 "invalid-field",
1743 Some(f),
1744 l,
1745 format!("report `{name}`: `profile` must be a string"),
1746 );
1747 None
1748 }
1749 },
1750 None => None,
1751 };
1752 let folder_profile = layers
1753 .iter()
1754 .rev()
1755 .find_map(|l| l.profile.as_ref().map(|p| p.0.clone()));
1756 let report_timezone = match key("timezone") {
1757 Some(t) => {
1758 let (f, l) = located("timezone").unwrap();
1759 self.timezone_value(&t.value, &f, l, &format!("report `{name}`: `timezone`"))
1760 }
1761 None => None,
1762 };
1763 let timezone = report_timezone
1764 .or_else(|| layers.iter().rev().find_map(|l| l.timezone.clone()))
1765 .or_else(|| project.timezone.clone());
1766 let base_profile = report_profile
1767 .or(folder_profile)
1768 .or(project.default_profile.clone());
1769
1770 let mut vars = project.vars.clone();
1771 for l in &layers {
1772 if let Some(v) = &l.vars {
1773 vars.extend(yaml_map_to_json(v));
1774 }
1775 }
1776 if let Some(v) = key("vars") {
1777 match v.value.as_mapping() {
1778 Some(m) => vars.extend(yaml_map_to_json(m)),
1779 None => {
1780 let (f, l) = located("vars").unwrap();
1781 self.diags.error(
1782 "invalid-field",
1783 Some(f),
1784 l,
1785 format!("report `{name}`: `vars` must be a map"),
1786 );
1787 }
1788 }
1789 }
1790
1791 let mut output = builtin_output();
1792 let mut merge_problems = Vec::new();
1793 if let Some(o) = project_default_output(project) {
1794 merge_problems.extend(merge_output(&mut output, &o));
1795 }
1796 for l in &layers {
1797 if let Some(o) = &l.output {
1798 merge_problems.extend(merge_output(&mut output, o));
1799 }
1800 }
1801 for p in merge_problems {
1802 self.diags.error(
1803 "invalid-field",
1804 Some(r.file.display.clone()),
1805 None,
1806 format!("report `{name}`: folder config: {p}"),
1807 );
1808 }
1809 if let Some(o) = key("output") {
1810 match o.value.as_mapping() {
1811 Some(m) => {
1812 if let Some(p) = merge_output(&mut output, m) {
1813 let (f, l) = located("output").unwrap();
1814 self.diags
1815 .error("invalid-field", Some(f), l, format!("report `{name}`: {p}"));
1816 }
1817 }
1818 None => {
1819 let (f, l) = located("output").unwrap();
1820 self.diags.error(
1821 "invalid-field",
1822 Some(f),
1823 l,
1824 format!("report `{name}`: `output` must be a map"),
1825 );
1826 }
1827 }
1828 }
1829
1830 if key("schedule").is_some() {
1831 let (f, l) = located("schedule").unwrap();
1832 self.moved_to_schedules(&f, l, &format!("report `{name}`: `schedule`"));
1833 }
1834
1835 let default_set = match key("default_set") {
1836 Some(d) => d.value.as_str().map(str::to_string),
1837 None => None,
1838 };
1839
1840 let base = BindingBase {
1841 profile: base_profile,
1842 vars,
1843 output,
1844 };
1845 let has_sets = key("sets").is_some();
1846 let report_base = self.silent_binding(&name, &base, &queries, &r.file.display);
1847 let mut bindings = Vec::new();
1848 if let Some(s) = key("sets") {
1849 bindings = self.resolve_sets(&name, s, &queries, &base, project, used);
1850 let declared: Vec<&str> = bindings.iter().filter_map(|b| b.set.as_deref()).collect();
1851 if let Some(d) = &default_set
1852 && !declared.contains(&d.as_str())
1853 {
1854 let (f, l) = located("default_set").unwrap();
1855 self.diags.error(
1856 "unknown-default-set",
1857 Some(f),
1858 l,
1859 format!("report `{name}`: `default_set` `{d}` isn't one of the report's `sets:`"),
1860 );
1861 }
1862 } else {
1863 if default_set.is_some() {
1864 let (f, l) = located("default_set").unwrap();
1865 self.diags.error(
1866 "unknown-default-set",
1867 Some(f),
1868 l,
1869 format!("report `{name}`: `default_set` needs a `sets:` list"),
1870 );
1871 }
1872 let b = self.finish_binding(
1873 &name,
1874 None,
1875 &base,
1876 None,
1877 queries.clone(),
1878 r.file.display.clone(),
1879 used,
1880 );
1881 bindings.push(b);
1882 }
1883
1884 Some(Report {
1885 name,
1886 managed: true,
1887 file: r.file.display.clone(),
1888 folder: r.folder.clone(),
1889 tags,
1890 queries,
1891 default_set,
1892 timezone,
1893 has_sets,
1894 bindings,
1895 base: report_base,
1896 })
1897 }
1898
1899 fn resolve_sets(
1900 &mut self,
1901 report: &str,
1902 sets: &Located<Value>,
1903 queries: &[QueryEntry],
1904 base: &BindingBase,
1905 project: &Project,
1906 used: &mut Usage,
1907 ) -> Vec<Binding> {
1908 let yf = sets.file.clone();
1909 let file = Some(yf.display.clone());
1910 let Some(items) = sets.value.as_sequence() else {
1911 self.diags.error(
1912 "invalid-field",
1913 file,
1914 yf.line_of("sets", None),
1915 format!("report `{report}`: `sets` must be a list"),
1916 );
1917 return Vec::new();
1918 };
1919 let sets_line = yf.line_of("sets", None);
1920 let mut seen = BTreeSet::new();
1921 let mut out = Vec::new();
1922 for item in items {
1923 let (name, inline): (String, Option<&Mapping>) = match item {
1924 Value::String(s) => (s.clone(), None),
1925 Value::Mapping(m) => match m.get("name").and_then(Value::as_str) {
1926 Some(n) => (n.to_string(), Some(m)),
1927 None => {
1928 self.diags.error(
1929 "invalid-field",
1930 file.clone(),
1931 sets_line,
1932 format!("report `{report}`: a `sets:` map entry needs a `name:`"),
1933 );
1934 continue;
1935 }
1936 },
1937 _ => {
1938 self.diags.error(
1939 "invalid-field",
1940 file.clone(),
1941 sets_line,
1942 format!("report `{report}`: `sets:` entries must be names or maps"),
1943 );
1944 continue;
1945 }
1946 };
1947 let line = yf.line_of(&name, sets_line).or_else(|| yf.line_containing(&name));
1948 if !seen.insert(name.clone()) {
1949 self.diags.error(
1950 "duplicate-set",
1951 file.clone(),
1952 line,
1953 format!("report `{report}` lists Set `{name}` more than once"),
1954 );
1955 continue;
1956 }
1957 let registry = project.sets.get(&name);
1958 if inline.is_none() && registry.is_none() {
1959 self.diags.error(
1960 "unknown-set",
1961 file.clone(),
1962 line,
1963 format!("report `{report}`: Set `{name}` isn't declared in sets.yml"),
1964 );
1965 continue;
1966 }
1967 let mut b = BindingBase {
1968 profile: base.profile.clone(),
1969 vars: base.vars.clone(),
1970 output: base.output.clone(),
1971 };
1972 if let Some(reg) = registry {
1973 if let Some(p) = ®.profile {
1974 b.profile = Some(p.clone());
1975 used.source(p, None, None);
1976 }
1977 b.vars.extend(reg.vars.clone());
1978 }
1979 let mut qs = queries.to_vec();
1980 let mut tab_names: Option<Mapping> = None;
1981 if let Some(m) = inline {
1982 let ctx = format!("report `{report}`, Set `{name}`");
1983 for k in m.keys().filter_map(Value::as_str) {
1984 if !SET_ENTRY_KEYS.contains(&k) {
1985 self.diags.error(
1986 "unknown-key",
1987 file.clone(),
1988 line,
1989 format!("{ctx}: unknown key `{k}`"),
1990 );
1991 }
1992 }
1993 if let Some(p) = m.get("profile") {
1994 match p.as_str() {
1995 Some(p) => {
1996 b.profile = Some(p.to_string());
1997 used.source(p, file.clone(), yf.line_of("profile", line));
1998 }
1999 None => self.diags.error(
2000 "invalid-field",
2001 file.clone(),
2002 line,
2003 format!("{ctx}: `profile` must be a string"),
2004 ),
2005 }
2006 }
2007 match m.get("vars") {
2008 Some(Value::Mapping(v)) => b.vars.extend(yaml_map_to_json(v)),
2009 None => {}
2010 Some(_) => self.diags.error(
2011 "invalid-field",
2012 file.clone(),
2013 line,
2014 format!("{ctx}: `vars` must be a map"),
2015 ),
2016 }
2017 if let Some(o) = m.get("output") {
2018 match o.as_mapping() {
2019 Some(o) => {
2020 if let Some(p) = merge_output(&mut b.output, o) {
2021 self.diags
2022 .error("invalid-field", file.clone(), line, format!("{ctx}: {p}"));
2023 }
2024 }
2025 None => self.diags.error(
2026 "invalid-field",
2027 file.clone(),
2028 line,
2029 format!("{ctx}: `output` must be a map"),
2030 ),
2031 }
2032 }
2033 if m.contains_key("schedule") {
2034 self.moved_to_schedules(&yf.display, line, &format!("{ctx}: `schedule`"));
2035 }
2036 let listed: Vec<&str> = queries.iter().map(|q| q.query.as_str()).collect();
2037 match (m.get("exclude"), m.get("queries")) {
2038 (Some(_), Some(_)) => self.diags.error(
2039 "exclude-and-queries",
2040 file.clone(),
2041 line,
2042 format!("{ctx}: use either `exclude:` or `queries:`, not both"),
2043 ),
2044 (Some(ex), None) => match string_list(ex) {
2045 Some(ex) => {
2046 for q in &ex {
2047 if !listed.contains(&q.as_str()) {
2048 self.diags.error(
2049 "unknown-query",
2050 file.clone(),
2051 yf.line_of("exclude", line),
2052 format!("{ctx}: `exclude` names `{q}`, which isn't in the report's `queries:`"),
2053 );
2054 }
2055 }
2056 qs.retain(|q| !ex.contains(&q.query));
2057 }
2058 None => self.diags.error(
2059 "invalid-field",
2060 file.clone(),
2061 line,
2062 format!("{ctx}: `exclude` must be a list of query names"),
2063 ),
2064 },
2065 (None, Some(ov)) => match ov.as_sequence() {
2066 Some(items) => {
2067 let mut sub = Vec::new();
2068 for it in items {
2069 let Some(qn) = entry_name(it) else { continue };
2070 match queries.iter().find(|q| q.query == qn) {
2071 Some(q) => {
2072 let mut q = q.clone();
2073 match it.get("tab_name") {
2074 None => {}
2075 Some(Value::String(s)) => q.tab_name = Some(s.clone()),
2076 Some(_) => self.diags.error(
2077 "invalid-field",
2078 file.clone(),
2079 line,
2080 format!("{ctx}: {}", one_tab_per_file(&qn)),
2081 ),
2082 }
2083 if let Some(b) = it.get("tab").and_then(Value::as_bool) {
2084 q.tab = b;
2085 }
2086 sub.push(q);
2087 }
2088 None => self.diags.error(
2089 "unknown-query",
2090 file.clone(),
2091 yf.line_of("queries", line),
2092 format!("{ctx}: `queries` names `{qn}`, which isn't in the report's `queries:`"),
2093 ),
2094 }
2095 }
2096 qs = sub;
2097 }
2098 None => self.diags.error(
2099 "invalid-field",
2100 file.clone(),
2101 line,
2102 format!("{ctx}: `queries` must be a list"),
2103 ),
2104 },
2105 (None, None) => {}
2106 }
2107 tab_names = match m.get("tab_names") {
2108 None => None,
2109 Some(Value::Mapping(t)) => Some(t.clone()),
2110 Some(_) => {
2111 self.diags.error(
2112 "invalid-field",
2113 file.clone(),
2114 line,
2115 format!("{ctx}: `tab_names` must be a map of query name to tab name"),
2116 );
2117 None
2118 }
2119 };
2120 }
2121 let bind = self.finish_binding(
2122 report,
2123 Some(name),
2124 &b,
2125 tab_names.as_ref(),
2126 qs,
2127 yf.display.clone(),
2128 used,
2129 );
2130 out.push(bind);
2131 }
2132 out
2133 }
2134
2135 fn silent_binding(
2137 &mut self,
2138 report: &str,
2139 base: &BindingBase,
2140 queries: &[QueryEntry],
2141 file: &Path,
2142 ) -> Binding {
2143 let saved = std::mem::take(&mut self.diags);
2144 let mut scratch = Usage::default();
2145 let b = self.finish_binding(
2146 report,
2147 None,
2148 base,
2149 None,
2150 queries.to_vec(),
2151 file.to_path_buf(),
2152 &mut scratch,
2153 );
2154 self.diags = saved;
2155 b
2156 }
2157
2158 #[allow(clippy::too_many_arguments)]
2159 fn finish_binding(
2160 &mut self,
2161 report: &str,
2162 set: Option<String>,
2163 base: &BindingBase,
2164 tab_names: Option<&Mapping>,
2165 mut queries: Vec<QueryEntry>,
2166 file: PathBuf,
2167 used: &mut Usage,
2168 ) -> Binding {
2169 let ctx = match &set {
2170 Some(s) => format!("report `{report}`, Set `{s}`"),
2171 None => format!("report `{report}`"),
2172 };
2173 if let Some(t) = tab_names {
2174 for (k, v) in t {
2175 let Some(q) = k.as_str() else { continue };
2176 match queries.iter_mut().find(|e| e.query == q) {
2177 Some(e) => match v {
2178 Value::String(s) => e.tab_name = Some(s.clone()),
2179 _ => self.diags.error(
2180 "invalid-field",
2181 Some(file.clone()),
2182 None,
2183 format!("{ctx}: {}", one_tab_per_file(q)),
2184 ),
2185 },
2186 None => self.diags.error(
2187 "unknown-query",
2188 Some(file.clone()),
2189 None,
2190 format!("{ctx}: `tab_names` names `{q}`, which isn't one of this Binding's queries"),
2191 ),
2192 }
2193 }
2194 }
2195 let output = self.typed_output(&base.output, &ctx, &file, &queries, used);
2196 if let Some(p) = &base.profile {
2197 used.source(p, None, None);
2198 }
2199 if base.profile.is_none() {
2200 self.diags.error(
2201 "no-source-profile",
2202 Some(file.clone()),
2203 None,
2204 format!(
2205 "{ctx} has no source profile: declare `profile:` (report, Set or folder `+profile`) or `default_profile` in {PROJECT_FILE}"
2206 ),
2207 );
2208 }
2209 Binding {
2210 set,
2211 profile: base.profile.clone(),
2212 vars: base.vars.clone(),
2213 queries: std::mem::take(&mut queries),
2214 output,
2215 schedules: Vec::new(),
2216 }
2217 }
2218
2219 fn typed_output(
2221 &mut self,
2222 m: &Mapping,
2223 ctx: &str,
2224 file: &Path,
2225 queries: &[QueryEntry],
2226 used: &mut Usage,
2227 ) -> Output {
2228 let file_path = file.to_path_buf();
2229 let file = Some(file_path.clone());
2230 let format = m
2231 .get("format")
2232 .and_then(Value::as_str)
2233 .unwrap_or("csv")
2234 .to_string();
2235 used.format(&format, ctx, &file_path);
2236 let mut opts = JsonMap::new();
2237 for (k, v) in m {
2238 let Some(k) = k.as_str() else { continue };
2239 if !OUTPUT_SHARED_KEYS.contains(&k) {
2240 opts.insert(k.to_string(), yaml_to_json(v));
2241 }
2242 }
2243 if let Some(Value::Mapping(d)) = self.format_options.get(format.as_str()) {
2245 for (k, v) in d {
2246 if let Some(k) = k.as_str()
2247 && !OUTPUT_SHARED_KEYS.contains(&k)
2248 {
2249 opts.entry(k.to_string()).or_insert_with(|| yaml_to_json(v));
2250 }
2251 }
2252 }
2253 let destinations = match m.get("destination") {
2254 None | Some(Value::Null) => Vec::new(),
2255 Some(Value::Mapping(d)) => self.typed_destination(d, ctx, &file, used).into_iter().collect(),
2256 Some(Value::Sequence(list)) if list.is_empty() => {
2257 self.diags.error(
2258 "invalid-field",
2259 file.clone(),
2260 None,
2261 format!(
2262 "{ctx}: `output.destination` is an empty list; name at least one destination, or remove it to keep the output in target/"
2263 ),
2264 );
2265 Vec::new()
2266 }
2267 Some(Value::Sequence(list)) => {
2268 let mut out = Vec::new();
2269 for (i, d) in list.iter().enumerate() {
2270 match d.as_mapping() {
2271 Some(d) => out.extend(self.typed_destination(d, ctx, &file, used)),
2272 None => self.diags.error(
2273 "invalid-field",
2274 file.clone(),
2275 None,
2276 format!("{ctx}: `output.destination` entry {} must be a map", i + 1),
2277 ),
2278 }
2279 }
2280 out
2281 }
2282 Some(_) => {
2283 self.diags.error(
2284 "invalid-field",
2285 file.clone(),
2286 None,
2287 format!("{ctx}: `output.destination` must be a map or a list of maps"),
2288 );
2289 Vec::new()
2290 }
2291 };
2292 if format != "xlsx" {
2293 for q in queries.iter().filter(|q| !q.columns.is_empty()) {
2294 self.diags.error(
2295 "invalid-field",
2296 file.clone(),
2297 None,
2298 format!(
2299 "{ctx}: `columns` on query `{}` only applies to the xlsx format",
2300 q.query
2301 ),
2302 );
2303 }
2304 }
2305 let template = match m.get("template") {
2306 None | Some(Value::Null) => None,
2307 Some(t) => {
2308 if format != "xlsx" {
2309 self.diags.error(
2310 "invalid-output-option",
2311 file.clone(),
2312 None,
2313 format!("{ctx}: `output.template` only applies to the xlsx format"),
2314 );
2315 }
2316 self.typed_template(t, ctx, &file, queries)
2317 }
2318 };
2319 let extension = match m.get("extension") {
2320 None => None,
2321 Some(Value::Null) | Some(Value::Bool(false)) => Some(String::new()),
2322 Some(Value::String(e)) => {
2323 let e = e.strip_prefix('.').unwrap_or(e);
2324 if e.contains(['/', '\\']) || e.chars().any(char::is_whitespace) {
2325 self.diags.error(
2326 "invalid-output-option",
2327 file.clone(),
2328 None,
2329 format!(
2330 "{ctx}: `extension` must be a file extension like `aba` (no path, no spaces)"
2331 ),
2332 );
2333 }
2334 Some(e.to_string())
2335 }
2336 Some(_) => {
2337 self.diags.error(
2338 "invalid-output-option",
2339 file.clone(),
2340 None,
2341 format!("{ctx}: `extension` must be a string (`aba`), or `\"\"`/`false` for none"),
2342 );
2343 None
2344 }
2345 };
2346 if extension.is_some() && format == "xlsx" {
2347 self.diags.error(
2348 "invalid-output-option",
2349 file.clone(),
2350 None,
2351 format!("{ctx}: `extension` doesn't apply to xlsx (Excel only opens .xlsx workbooks)"),
2352 );
2353 }
2354 Output {
2355 format,
2356 options: opts,
2357 destinations,
2358 template,
2359 extension,
2360 }
2361 }
2362
2363 fn typed_destination(
2365 &mut self,
2366 d: &Mapping,
2367 ctx: &str,
2368 file: &Option<PathBuf>,
2369 used: &mut Usage,
2370 ) -> Option<Destination> {
2371 let Some(p) = d.get("profile").and_then(Value::as_str) else {
2372 self.diags.error(
2373 "invalid-field",
2374 file.clone(),
2375 None,
2376 format!("{ctx}: `output.destination` needs a `profile:` naming a profiles.yml entry"),
2377 );
2378 return None;
2379 };
2380 let path = match d.get("path") {
2381 None | Some(Value::Null) => None,
2382 Some(Value::String(s)) => Some(s.clone()),
2383 Some(_) => {
2384 self.diags.error(
2385 "invalid-field",
2386 file.clone(),
2387 None,
2388 format!("{ctx}: destination `{p}`: `path` must be a string"),
2389 );
2390 None
2391 }
2392 };
2393 used.destination(p, file.clone(), None);
2394 let options = d
2395 .iter()
2396 .filter(|(k, _)| !is_one_of(k, &["profile", "path"]))
2397 .filter_map(|(k, v)| Some((k.as_str()?.to_string(), yaml_to_json(v))))
2398 .collect();
2399 Some(Destination {
2400 profile: p.to_string(),
2401 path,
2402 options,
2403 })
2404 }
2405
2406 fn typed_template(
2407 &mut self,
2408 t: &Value,
2409 ctx: &str,
2410 file: &Option<PathBuf>,
2411 queries: &[QueryEntry],
2412 ) -> Option<Template> {
2413 let err = |s: &mut Self, msg: String| {
2414 s.diags
2415 .error("invalid-template", file.clone(), None, format!("{ctx}: {msg}"))
2416 };
2417 let Some(tf) = t.get("file").and_then(Value::as_str) else {
2418 err(self, "`output.template` needs a `file:`".into());
2419 return None;
2420 };
2421 let mut bindings = Vec::new();
2422 let items = match t.get("bindings") {
2423 None => Vec::new(),
2424 Some(Value::Sequence(s)) => s.clone(),
2425 Some(_) => {
2426 err(self, "`output.template.bindings` must be a list".into());
2427 Vec::new()
2428 }
2429 };
2430 let names: Vec<&str> = queries.iter().map(|q| q.query.as_str()).collect();
2431 for (i, b) in items.iter().enumerate() {
2432 let n = i + 1;
2433 let Some(m) = b.as_mapping() else {
2434 err(self, format!("template binding {n} must be a map"));
2435 continue;
2436 };
2437 let s = |k: &str| m.get(k).and_then(Value::as_str).map(str::to_string);
2438 for k in m.keys().filter_map(Value::as_str) {
2439 if !TEMPLATE_BINDING_KEYS.contains(&k) {
2440 err(self, format!("template binding {n} has unknown key `{k}`"));
2441 }
2442 }
2443 let Some(sheet) = s("sheet") else {
2444 err(self, format!("template binding {n} needs a `sheet:`"));
2445 continue;
2446 };
2447 let tb = TemplateBinding {
2448 sheet,
2449 query: s("query"),
2450 result_index: m.get("result_index").and_then(Value::as_u64).map(|x| x as usize),
2451 anchor: s("anchor"),
2452 header: m.get("header").and_then(Value::as_bool),
2453 columns: m.get("columns").and_then(string_list),
2454 cell: s("cell"),
2455 value: s("value"),
2456 column: s("column"),
2457 };
2458 if let Some(q) = &tb.query
2459 && !names.contains(&q.as_str())
2460 {
2461 err(
2462 self,
2463 format!(
2464 "template binding {n} uses query `{q}`, which isn't one of this Binding's queries"
2465 ),
2466 );
2467 }
2468 if let Some(ri) = m.get("result_index")
2469 && ri.as_u64().is_none_or(|x| x == 0)
2470 {
2471 err(
2472 self,
2473 format!("template binding {n}: `result_index` must be 1 or more"),
2474 );
2475 }
2476 match &tb.cell {
2477 Some(c) => {
2478 if !options::is_cell(c) {
2479 err(
2480 self,
2481 format!("template binding {n}: `cell` `{c}` isn't a valid cell reference"),
2482 );
2483 }
2484 let by_value = tb.value.is_some();
2485 let by_query = tb.query.is_some() && tb.column.is_some();
2486 if by_value == by_query || (tb.query.is_some() != tb.column.is_some()) {
2487 err(
2488 self,
2489 format!(
2490 "template binding {n}: a single-cell binding needs exactly one of `value` or `query` + `column`"
2491 ),
2492 );
2493 }
2494 if tb.anchor.is_some() || tb.columns.is_some() {
2495 err(
2496 self,
2497 format!(
2498 "template binding {n}: `anchor`/`columns` apply to table blocks, not single cells"
2499 ),
2500 );
2501 }
2502 }
2503 None => {
2504 if tb.query.is_none() {
2505 err(
2506 self,
2507 format!(
2508 "template binding {n}: a table block needs a `query` (or use `cell` for a single cell)"
2509 ),
2510 );
2511 }
2512 if let Some(a) = &tb.anchor
2513 && !options::is_cell(a)
2514 {
2515 err(
2516 self,
2517 format!("template binding {n}: `anchor` `{a}` isn't a valid cell reference"),
2518 );
2519 }
2520 if tb.value.is_some() || tb.column.is_some() {
2521 err(
2522 self,
2523 format!(
2524 "template binding {n}: `value`/`column` apply to single-cell bindings (add `cell`)"
2525 ),
2526 );
2527 }
2528 }
2529 }
2530 bindings.push(tb);
2531 }
2532 Some(Template {
2533 file: tf.to_string(),
2534 bindings,
2535 })
2536 }
2537
2538 fn resolve_unmanaged(
2541 &mut self,
2542 name: &str,
2543 path: &Path,
2544 project: &Project,
2545 folders: &BTreeMap<Vec<String>, FolderCfg>,
2546 used: &mut Usage,
2547 ) -> Report {
2548 let folder = folder_segments(path.parent().unwrap_or(Path::new("")));
2549 let layers = folder_layers(folders, &folder);
2550 let mut tags: Vec<String> = layers.iter().flat_map(|l| l.tags.clone()).collect();
2551 dedup(&mut tags);
2552 let profile = layers
2553 .iter()
2554 .rev()
2555 .find_map(|l| l.profile.as_ref().map(|p| p.0.clone()))
2556 .or(project.default_profile.clone());
2557 let mut vars = project.vars.clone();
2558 for l in &layers {
2559 if let Some(v) = &l.vars {
2560 vars.extend(yaml_map_to_json(v));
2561 }
2562 }
2563 let mut output = builtin_output();
2564 let mut merge_problems = Vec::new();
2565 if let Some(o) = project_default_output(project) {
2566 merge_problems.extend(merge_output(&mut output, &o));
2567 }
2568 for l in &layers {
2569 if let Some(o) = &l.output {
2570 merge_problems.extend(merge_output(&mut output, o));
2571 }
2572 }
2573 for p in merge_problems {
2574 self.diags.error(
2575 "invalid-field",
2576 Some(path.to_path_buf()),
2577 None,
2578 format!("folder config: {p}"),
2579 );
2580 }
2581 self.diags.warning(
2582 "unmanaged-report",
2583 Some(path.to_path_buf()),
2584 None,
2585 format!(
2586 "`{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"
2587 ),
2588 );
2589 self.check_unmanaged_sql(name, path);
2590 let query = QueryEntry {
2591 query: name.to_string(),
2592 path: path.to_path_buf(),
2593 tab: true,
2594 tab_name: None,
2595 anchor: None,
2596 header: None,
2597 columns: BTreeMap::new(),
2598 };
2599 let base = BindingBase {
2600 profile: profile.clone(),
2601 vars,
2602 output,
2603 };
2604 if let Some(p) = &profile {
2605 used.source(p, None, None);
2606 }
2607 let b = self.finish_binding(
2608 name,
2609 None,
2610 &base,
2611 None,
2612 vec![query.clone()],
2613 path.to_path_buf(),
2614 used,
2615 );
2616 Report {
2617 name: name.to_string(),
2618 managed: false,
2619 file: path.to_path_buf(),
2620 folder,
2621 tags,
2622 queries: vec![query],
2623 default_set: None,
2624 timezone: layers
2625 .iter()
2626 .rev()
2627 .find_map(|l| l.timezone.clone())
2628 .or_else(|| project.timezone.clone()),
2629 has_sets: false,
2630 base: b.clone(),
2631 bindings: vec![b],
2632 }
2633 }
2634
2635 fn check_unmanaged_sql(&mut self, name: &str, path: &Path) {
2637 let Ok(text) = std::fs::read_to_string(self.root.join(path)) else {
2638 return;
2639 };
2640 let head = sqlsplit::strip_leading_comments(&text);
2641 if head.starts_with("{{") || head.starts_with("{%") || head.starts_with("{#") {
2642 return;
2643 }
2644 for st in sqlsplit::split(&text) {
2645 if !sqlsplit::classify(&st.text).is_read_only_safe() {
2646 let body = sqlsplit::strip_leading_comments(&st.text);
2647 let line = st.line + st.text[..st.text.len() - body.len()].matches('\n').count();
2648 self.diags.error(
2649 "unmanaged-side-effect",
2650 Some(path.to_path_buf()),
2651 Some(line),
2652 format!(
2653 "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",
2654 dre_protocol::util::summarize(body, 60)
2655 ),
2656 );
2657 }
2658 }
2659 }
2660
2661 fn moved_to_schedules(&mut self, file: &Path, line: Option<usize>, ctx: &str) {
2665 self.diags.error(
2666 "schedule-moved",
2667 Some(file.to_path_buf()),
2668 line,
2669 format!(
2670 "{ctx} is no longer supported; declare schedules in schedules.yml as named entries (`name`, `report:`/`select:`, `cron`/`every`/`rrule`, optional `vars`)"
2671 ),
2672 );
2673 }
2674
2675 fn parse_schedules(&mut self, files: &[Rc<YamlFile>], project: &Project) -> Vec<ScheduleEntry> {
2676 let mut out = Vec::new();
2677 let mut seen: BTreeMap<String, String> = BTreeMap::new();
2678 for yf in files {
2679 let Some(items) = yf.value.as_sequence() else {
2680 continue;
2681 };
2682 for (i, item) in items.iter().enumerate() {
2683 let m = item.as_mapping().unwrap();
2684 let line = nth_item_line(&yf.text, i);
2685 let file = Some(yf.display.clone());
2686 let s = |k: &str| m.get(k).and_then(Value::as_str).map(str::to_string);
2687 let (select, report, set) = (s("select"), s("report"), s("set"));
2688 let mut ok = true;
2689 let name = match s("name") {
2690 Some(n) if is_identifier(&n) => {
2691 if let Some(prev) = seen.get(&n) {
2692 self.diags.error(
2693 "duplicate-schedule-name",
2694 file.clone(),
2695 line,
2696 format!("schedule `{n}` is already declared at {prev}; schedule names must be unique"),
2697 );
2698 ok = false;
2699 }
2700 seen.insert(
2701 n.clone(),
2702 format!("{}:{}", yf.display.display(), line.unwrap_or(0)),
2703 );
2704 n
2705 }
2706 Some(n) => {
2707 self.diags.error(
2708 "invalid-schedule",
2709 file.clone(),
2710 line,
2711 format!("schedule name `{n}` must be letters, digits and `_`, not starting with a digit"),
2712 );
2713 ok = false;
2714 n
2715 }
2716 None => {
2717 self.diags.error(
2718 "invalid-schedule",
2719 file.clone(),
2720 line,
2721 "every schedule needs a `name`",
2722 );
2723 ok = false;
2724 String::new()
2725 }
2726 };
2727 let vars = match m.get("vars") {
2728 None => JsonMap::new(),
2729 Some(Value::Mapping(v)) => yaml_map_to_json(v),
2730 Some(_) => {
2731 self.diags.error(
2732 "invalid-schedule",
2733 file.clone(),
2734 line,
2735 format!("schedule `{name}`: `vars` must be a map"),
2736 );
2737 ok = false;
2738 JsonMap::new()
2739 }
2740 };
2741 let mut sched = Mapping::new();
2742 for (k, v) in m {
2743 let Some(k) = k.as_str() else { continue };
2744 if ["name", "select", "report", "set", "vars", "timezone"].contains(&k) {
2745 continue;
2746 }
2747 if !schedule::SCHEDULE_KEYS.contains(&k) {
2748 self.diags.error(
2749 "invalid-schedule",
2750 file.clone(),
2751 line,
2752 format!("unknown schedule key `{k}`"),
2753 );
2754 continue;
2755 }
2756 sched.insert(Value::String(k.to_string()), v.clone());
2757 }
2758 for e in schedule::validate_block(&sched) {
2759 self.diags.error("invalid-schedule", file.clone(), line, e);
2760 ok = false;
2761 }
2762 if select.is_some() && report.is_some() {
2763 self.diags.error(
2764 "invalid-schedule",
2765 file.clone(),
2766 line,
2767 "use either `select:` or `report:`, not both",
2768 );
2769 ok = false;
2770 }
2771 if select.is_some() && set.is_some() {
2772 self.diags.error(
2773 "invalid-schedule",
2774 file.clone(),
2775 line,
2776 "`set:` only applies with `report:`",
2777 );
2778 ok = false;
2779 }
2780 if let Some(sel) = &select {
2781 match selector::resolve(project, sel) {
2782 Ok(r) if r.is_empty() => {
2783 self.diags.error(
2784 "selector-matches-nothing",
2785 file.clone(),
2786 line,
2787 format!("selector `{sel}` matches no report"),
2788 );
2789 ok = false;
2790 }
2791 Ok(_) => {}
2792 Err(e) => {
2793 self.diags.error(e.code(), file.clone(), line, e.to_string());
2794 ok = false;
2795 }
2796 }
2797 }
2798 if let Some(rn) = &report {
2799 match project.report(rn) {
2800 None => {
2801 self.diags.error(
2802 "selector-matches-nothing",
2803 file.clone(),
2804 line,
2805 format!("report `{rn}` doesn't exist"),
2806 );
2807 ok = false;
2808 }
2809 Some(r) => {
2810 if let Some(sn) = &set
2811 && r.binding(sn).is_none()
2812 {
2813 self.diags.error(
2814 "selector-matches-nothing",
2815 file.clone(),
2816 line,
2817 format!("report `{rn}` has no Set `{sn}`"),
2818 );
2819 ok = false;
2820 }
2821 }
2822 }
2823 }
2824 if select.is_none() && report.is_none() {
2825 self.diags.error(
2826 "invalid-schedule",
2827 file.clone(),
2828 line,
2829 format!("schedule `{name}` needs `report:` (optionally with `set:`) or `select:`"),
2830 );
2831 ok = false;
2832 }
2833 let timezone = match m.get("timezone") {
2834 None => None,
2835 Some(v) => {
2836 let t = self.timezone_value(
2837 v,
2838 &yf.display,
2839 line,
2840 &format!("schedule `{name}`: `timezone`"),
2841 );
2842 ok &= t.is_some();
2843 t
2844 }
2845 };
2846 if ok {
2847 out.push(ScheduleEntry {
2848 name,
2849 select,
2850 report,
2851 set,
2852 schedule: yaml_map_to_json(&sched),
2853 vars,
2854 timezone,
2855 location: (yf.display.clone(), line),
2856 });
2857 }
2858 }
2859 }
2860 out
2861 }
2862
2863 fn check_schedule_paths(&mut self, project: &Project) {
2867 let date = chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
2868 for report in &project.reports {
2869 for b in report.bindings.iter().filter(|b| b.schedules.len() > 1) {
2870 let paths: Vec<&String> = b
2871 .output
2872 .destinations
2873 .iter()
2874 .filter_map(|d| d.path.as_ref())
2875 .collect();
2876 if paths.is_empty() {
2877 continue;
2878 }
2879 let mut rendered: Vec<(&String, Vec<String>)> = Vec::new();
2880 for name in &b.schedules {
2881 let Some(e) = project.schedules.iter().find(|e| &e.name == name) else {
2882 continue;
2883 };
2884 let mut vars = b.vars.clone();
2885 vars.extend(e.vars.clone());
2886 let Ok(r) = crate::render::Renderer::new(crate::render::RendererConfig {
2887 root: &project.root,
2888 macros: &project.macros,
2889 context: crate::render::RunContext {
2890 report: report.name.clone(),
2891 set: b.set.clone(),
2892 target: String::new(),
2893 profile: b.profile.clone().unwrap_or_default(),
2894 source_type: String::new(),
2895 schedule: Some(name.clone()),
2896 date,
2897 now: chrono::Utc::now(),
2898 calendar: crate::dates::Calendar::default(),
2899 },
2900 vars,
2901 cli_vars: self.opts.vars.clone(),
2902 runner: None,
2903 connections: None,
2904 run_query_max_rows: project.run_query_max_rows,
2905 sql: project.sql.clone(),
2906 lookups: project.lookups.clone(),
2907 lookup_inline_max_rows: project.lookup_inline_max_rows,
2908 packages: project.packages.clone(),
2909 project_name: project.name.clone(),
2910 dispatch: project.dispatch.clone(),
2911 }) else {
2912 continue;
2913 };
2914 let out: Result<Vec<String>, _> =
2915 paths.iter().map(|p| r.render(&report.file, p)).collect();
2916 if let Ok(out) = out {
2917 rendered.push((name, out));
2918 }
2919 }
2920 for (i, (a, pa)) in rendered.iter().enumerate() {
2921 for (bn, pb) in &rendered[i + 1..] {
2922 if let Some(same) = pa.iter().find(|p| pb.contains(p)) {
2923 self.diags.warning(
2924 "schedule-path-clash",
2925 Some(report.file.clone()),
2926 None,
2927 format!(
2928 "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",
2929 report.name,
2930 b.set.as_ref().map(|s| format!(", Set `{s}`,")).unwrap_or_default()
2931 ),
2932 );
2933 }
2934 }
2935 }
2936 }
2937 }
2938 }
2939
2940 fn apply_schedules(&mut self, project: &mut Project) {
2944 let entries = project.schedules.clone();
2945 for e in &entries {
2946 let reports: Vec<String> = match (&e.select, &e.report) {
2947 (Some(sel), _) => selector::resolve(project, sel)
2948 .map(|r| r.into_iter().map(|r| r.name.clone()).collect())
2949 .unwrap_or_default(),
2950 (_, Some(r)) => vec![r.clone()],
2951 _ => Vec::new(),
2952 };
2953 for report in project.reports.iter_mut().filter(|r| reports.contains(&r.name)) {
2954 for b in &mut report.bindings {
2955 if e.set.is_none() || e.set == b.set {
2956 b.schedules.push(e.name.clone());
2957 }
2958 }
2959 }
2960 }
2961 self.check_schedule_paths(project);
2962 }
2963
2964 fn check_profiles(&mut self, project: &Project, used: &Usage) {
2967 let profiles = &project.profiles;
2968 if used.sources.is_empty() && used.destinations.is_empty() {
2969 return;
2970 }
2971 if !profiles.exists() {
2972 self.diags.error(
2973 "profiles-missing",
2974 None,
2975 None,
2976 if profiles.found_by == "~/.dre" {
2977 format!(
2978 "the project references profiles, but no profiles.yml was found in the project directory or at {}",
2979 profiles.path.display()
2980 )
2981 } else {
2982 format!(
2983 "the project references profiles, but no profiles.yml was found at {} (from {})",
2984 profiles.path.display(),
2985 profiles.found_by
2986 )
2987 },
2988 );
2989 return;
2990 }
2991 for (role, refs) in [
2992 (Role::Source, &used.sources),
2993 (Role::Destination, &used.destinations),
2994 ] {
2995 for (name, (file, line)) in refs {
2996 if role == Role::Destination && name == LOCAL_TYPE {
2997 continue;
2998 }
2999 if !profiles.declares(role, name) {
3000 self.diags.error(
3001 "unknown-profile",
3002 file.clone(),
3003 *line,
3004 format!(
3005 "{} profile `{name}` isn't defined under `{}:` in {}",
3006 role.as_str(),
3007 role.section(),
3008 profiles.path.display()
3009 ),
3010 );
3011 }
3012 }
3013 }
3014 if let Some(t) = &self.opts.target {
3015 let sources: Vec<&String> = used
3016 .sources
3017 .keys()
3018 .filter(|p| profiles.get(Role::Source, p).is_some())
3019 .collect();
3020 let defining = sources
3021 .iter()
3022 .filter(|p| profiles.get(Role::Source, p).unwrap().targets.contains_key(t))
3023 .count();
3024 if !sources.is_empty() && defining == 0 {
3025 self.diags.error(
3026 "unknown-target",
3027 None,
3028 None,
3029 format!(
3030 "target `{t}` isn't defined by any referenced source profile ({})",
3031 sources
3032 .iter()
3033 .map(|s| format!("`{s}`"))
3034 .collect::<Vec<_>>()
3035 .join(", ")
3036 ),
3037 );
3038 } else {
3039 for p in sources
3040 .iter()
3041 .filter(|p| !profiles.get(Role::Source, p).unwrap().targets.contains_key(t))
3042 {
3043 self.diags.warning(
3044 "missing-target",
3045 None,
3046 None,
3047 format!("source profile `{p}` has no `{t}` target; its reports can't run with --target {t}"),
3048 );
3049 }
3050 }
3051 }
3052 }
3053
3054 fn plugin_entry(
3056 &mut self,
3057 m: &Mapping,
3058 yf: &YamlFile,
3059 line: Option<usize>,
3060 ) -> Option<(String, Option<Value>, PluginSource)> {
3061 let file = Some(yf.display.clone());
3062 let name = m
3063 .get("name")
3064 .and_then(Value::as_str)
3065 .unwrap_or_default()
3066 .to_string();
3067 let line = yf.line_of(&name, line);
3068 let err = |s: &mut Self, msg: String| {
3069 s.diags.error(
3070 "invalid-plugin-declaration",
3071 file.clone(),
3072 line,
3073 format!("plugin package `{name}`: {msg}"),
3074 );
3075 };
3076 if name.is_empty() {
3077 err(self, "`name` must be a non-empty string".into());
3078 return None;
3079 }
3080 for k in m.keys().filter_map(Value::as_str) {
3081 if !PLUGIN_ENTRY_KEYS.contains(&k) {
3082 err(
3083 self,
3084 format!(
3085 "unknown key `{k}`; use `name`, `version` and one of `github`, `local`, `registry`"
3086 ),
3087 );
3088 return None;
3089 }
3090 }
3091 let given: Vec<(&str, &str)> = ["github", "local", "registry"]
3092 .into_iter()
3093 .filter_map(|k| m.get(k).map(|v| (k, v.as_str().unwrap_or(""))))
3094 .collect();
3095 let source = match given.as_slice() {
3096 [] => PluginSource::Default,
3097 [(k, "")] => {
3098 err(self, format!("`{k}` must be a non-empty string"));
3099 return None;
3100 }
3101 [("github", r)] => {
3102 let ok = r.split('/').count() == 2 && r.split('/').all(|p| !p.is_empty());
3103 if !ok {
3104 err(self, format!("`github: {r}` must be `owner/repo`"));
3105 return None;
3106 }
3107 PluginSource::Github(r.to_string())
3108 }
3109 [("local", p)] => {
3110 if m.get("version").is_some() {
3111 err(
3112 self,
3113 "a `local` package has no `version`: it's used as it is".into(),
3114 );
3115 return None;
3116 }
3117 PluginSource::Local(p.to_string())
3118 }
3119 [(_, u)] => PluginSource::Registry(u.to_string()),
3120 _ => {
3121 err(self, "give only one of `github`, `local` and `registry`".into());
3122 return None;
3123 }
3124 };
3125 Some((name, m.get("version").cloned(), source))
3126 }
3127
3128 fn check_plugins(&mut self, decls: &[(Rc<YamlFile>, Mapping)], project: &mut Project, used: &Usage) {
3131 struct Decl {
3132 req: semver::VersionReq,
3133 raw: String,
3134 file: PathBuf,
3135 source: PluginSource,
3136 }
3137 let mut by_package: BTreeMap<String, Vec<Decl>> = BTreeMap::new();
3138 for (yf, m) in decls {
3139 for (block, v) in m {
3140 if block.as_str() != Some(PLUGINS_KEY) {
3141 continue;
3142 }
3143 let line = yf.line_of(PLUGINS_KEY, None);
3144 let file = Some(yf.display.clone());
3145 let entries: Vec<(String, Option<Value>, PluginSource)> = match v {
3146 Value::Sequence(items) => items
3147 .iter()
3148 .filter_map(|i| match i {
3149 Value::String(s) => Some((s.clone(), None, PluginSource::Default)),
3150 Value::Mapping(m) if m.get("name").is_some() => self.plugin_entry(m, yf, line),
3151 Value::Mapping(m) if m.len() == 1 => {
3152 let (k, v) = m.iter().next().unwrap();
3153 k.as_str()
3154 .map(|k| (k.to_string(), Some(v.clone()), PluginSource::Default))
3155 }
3156 _ => {
3157 self.diags.error(
3158 "invalid-plugin-declaration",
3159 file.clone(),
3160 line,
3161 "each `plugins` entry is a package name, `name: \"<version>\"`, or a map with `name:` and one of `github:`, `local:`, `registry:`",
3162 );
3163 None
3164 }
3165 })
3166 .collect(),
3167 Value::Mapping(m) => m
3168 .iter()
3169 .filter_map(|(k, v)| {
3170 k.as_str().map(|k| (k.to_string(), Some(v.clone()), PluginSource::Default))
3171 })
3172 .collect(),
3173 Value::Null => Vec::new(),
3174 _ => {
3175 self.diags.error(
3176 "invalid-plugin-declaration",
3177 file.clone(),
3178 line,
3179 "`plugins` must be a list like `- duckdb: \">=1.0\"`",
3180 );
3181 continue;
3182 }
3183 };
3184 for (name, c, source) in entries {
3185 if !dre_protocol::valid_name(&name) {
3186 self.diags.error(
3187 "invalid-plugin-declaration",
3188 file.clone(),
3189 yf.line_of(&name, line),
3190 format!(
3191 "plugin package `{name}`: a package name is lowercase letters, digits and `_`"
3192 ),
3193 );
3194 continue;
3195 }
3196 let raw = match &c {
3197 None | Some(Value::Null) => "*".to_string(),
3198 Some(v) => crate::yaml::scalar_str(v).unwrap_or_default(),
3199 };
3200 match semver::VersionReq::parse(&raw) {
3201 Ok(req) => by_package.entry(name).or_default().push(Decl {
3202 req,
3203 raw,
3204 file: yf.display.clone(),
3205 source,
3206 }),
3207 Err(e) => self.diags.error(
3208 "invalid-version-constraint",
3209 file.clone(),
3210 yf.line_of(&name, line),
3211 format!("plugin package `{name}`: invalid version constraint `{raw}`: {e}"),
3212 ),
3213 }
3214 }
3215 }
3216 }
3217 let mut out = Vec::new();
3218 for (name, ds) in &by_package {
3219 let source = ds
3221 .iter()
3222 .map(|d| &d.source)
3223 .find(|s| !s.is_default())
3224 .cloned()
3225 .unwrap_or_default();
3226 if let Some(other) = ds.iter().find(|d| !d.source.is_default() && d.source != source) {
3227 self.diags.error(
3228 "conflicting-plugin-sources",
3229 Some(other.file.clone()),
3230 None,
3231 format!(
3232 "plugin package `{name}` is declared with two sources: {} and {}",
3233 source.lock_key().unwrap_or_default(),
3234 other.source.lock_key().unwrap_or_default()
3235 ),
3236 );
3237 project.plugins_incomplete = true;
3238 continue;
3239 }
3240 let reqs: Vec<&semver::VersionReq> = ds.iter().map(|d| &d.req).collect();
3241 if !constraints::compatible(&reqs) {
3242 for (i, a) in ds.iter().enumerate() {
3243 for b in &ds[i + 1..] {
3244 if !constraints::compatible(&[&a.req, &b.req]) {
3245 self.diags.error(
3246 "conflicting-plugin-constraints",
3247 Some(b.file.clone()),
3248 None,
3249 format!(
3250 "plugin package `{name}` is declared `{}` in {} and `{}` in {}; no version satisfies both",
3251 a.raw,
3252 a.file.display(),
3253 b.raw,
3254 b.file.display()
3255 ),
3256 );
3257 }
3258 }
3259 }
3260 project.plugins_incomplete = true;
3261 continue;
3262 }
3263 let combined = constraints::combine(&reqs);
3264 let mut files: Vec<PathBuf> = ds.iter().map(|d| d.file.clone()).collect();
3265 files.dedup();
3266 out.push(PluginRequirement {
3267 name: name.clone(),
3268 version: combined.to_string(),
3269 declared_in: files,
3270 source,
3271 });
3272 }
3273 project.plugins = out;
3274
3275 let mut uses = Vec::new();
3276 let profiles = &project.profiles;
3277 for (role, kind, refs) in [
3278 (Role::Source, PluginKind::Source, &used.sources),
3279 (Role::Destination, PluginKind::Destination, &used.destinations),
3280 ] {
3281 for name in refs.keys() {
3282 let Some(p) = profiles.get(role, name) else {
3283 continue;
3284 };
3285 let role_name = role.as_str();
3286 for out_ in p.targets.values() {
3287 if kind == PluginKind::Destination && out_.kind == LOCAL_TYPE {
3288 continue;
3289 }
3290 let id = PluginId::new(kind, out_.kind.clone());
3291 if uses.iter().any(|u: &PluginUse| u.plugin == id) {
3292 continue;
3293 }
3294 uses.push(PluginUse {
3295 plugin: id,
3296 file: profiles.file.as_ref().map(|f| f.display.clone()),
3297 line: profiles.line_of(role, name),
3298 what: format!("`type: {}` used by {role_name} profile `{name}`", out_.kind),
3299 });
3300 }
3301 }
3302 }
3303 for (fmt, (ctx, file)) in &used.formats {
3304 let note = if fmt == "csv" {
3305 " (csv is the built-in default output)"
3306 } else {
3307 ""
3308 };
3309 uses.push(PluginUse {
3310 plugin: PluginId::new(PluginKind::Format, fmt.clone()),
3311 file: Some(file.clone()),
3312 line: None,
3313 what: format!("format `{fmt}` is used by {ctx}{note}"),
3314 });
3315 }
3316 project.plugin_uses = uses;
3317 }
3318
3319 fn old_plugin_key(&mut self, yf: &YamlFile, key: &str) {
3321 self.diags.error(
3322 "moved-plugin-declaration",
3323 Some(yf.display.clone()),
3324 yf.line_of(key, None),
3325 format!(
3326 "`{key}:` no longer declares plugins: list plugin packages under `plugins:` instead (e.g. `plugins: [duckdb, object_store]`)"
3327 ),
3328 );
3329 }
3330
3331 fn preflight(&mut self, project: &Project, _used: &Usage) {
3334 let cli_owned: Vec<String> = self.opts.vars.keys().cloned().collect();
3335 let cli: BTreeSet<&str> = cli_owned.iter().map(String::as_str).collect();
3336 let mut sources: BTreeMap<PathBuf, String> = BTreeMap::new();
3337 let mut read = |root: &Path, p: &Path| -> Option<String> {
3338 if let Some(s) = sources.get(p) {
3339 return Some(s.clone());
3340 }
3341 let s = std::fs::read_to_string(root.join(p)).ok()?;
3342 sources.insert(p.to_path_buf(), s.clone());
3343 Some(s)
3344 };
3345 let mut checked: BTreeSet<PathBuf> = BTreeSet::new();
3346 let root = self.root.clone();
3347 let mut defs: BTreeMap<String, (PathBuf, preflight::MacroDef)> = BTreeMap::new();
3349 for m in &project.macros {
3350 if let Some(src) = read(&root, m) {
3351 self.check_template_text(m, &src, 0, None, &cli);
3352 checked.insert(m.clone());
3353 for d in preflight::macro_defs(&src) {
3354 defs.insert(d.name.clone(), (m.clone(), d));
3355 }
3356 }
3357 }
3358 for r in &project.reports {
3359 for b in &r.bindings {
3360 let ctx = match &b.set {
3361 Some(s) => format!("report `{}`, Set `{s}`", r.name),
3362 None => format!("report `{}`", r.name),
3363 };
3364 let mut called: Vec<String> = Vec::new();
3365 let mut refs: Vec<String> = Vec::new();
3366 for q in &b.queries {
3367 let Some(src) = read(&root, &q.path) else { continue };
3368 let first = checked.insert(q.path.clone());
3369 self.check_vars(&q.path, &src, 0, &b.vars, &cli, &ctx);
3370 if first {
3371 self.check_template_text(&q.path, &src, 0, None, &cli);
3372 }
3373 called.extend(preflight::called_names(&src));
3374 refs.extend(preflight::refs(&src).into_iter().map(|(n, _)| n));
3375 }
3376 let mut seen_refs = BTreeSet::new();
3378 while let Some(name) = refs.pop() {
3379 let Some(path) = project.sql.get(&name) else {
3380 continue;
3381 };
3382 if !seen_refs.insert(name) {
3383 continue;
3384 }
3385 let Some(src) = read(&root, path) else { continue };
3386 self.check_vars(path, &src, 0, &b.vars, &cli, &ctx);
3387 if checked.insert(path.clone()) {
3388 self.check_template_text(path, &src, 0, None, &cli);
3389 }
3390 called.extend(preflight::called_names(&src));
3391 refs.extend(preflight::refs(&src).into_iter().map(|(n, _)| n));
3392 }
3393 let mut seen = BTreeSet::new();
3395 while let Some(name) = called.pop() {
3396 let Some((file, def)) = defs.get(&name) else {
3397 continue;
3398 };
3399 if !seen.insert(name) {
3400 continue;
3401 }
3402 self.check_vars(file, &def.body, def.line_offset, &b.vars, &cli, &ctx);
3403 called.extend(preflight::called_names(&def.body));
3404 }
3405 let mut values: Vec<String> = Vec::new();
3407 for d in &b.output.destinations {
3408 values.extend(d.path.clone());
3409 values.extend(d.options.values().flat_map(json_strings));
3410 }
3411 if let Some(t) = &b.output.template {
3412 values.extend(t.bindings.iter().filter_map(|tb| tb.value.clone()));
3413 }
3414 for v in values.iter().filter(|v| preflight::is_templated(v)) {
3415 let yf = std::fs::read_to_string(root.join(&r.file)).unwrap_or_default();
3416 let line = yf.lines().position(|l| l.contains(v.as_str())).map(|i| i + 1);
3417 self.check_template_text(&r.file, v, line.map_or(0, |l| l - 1), line, &cli);
3418 self.check_vars(&r.file, v, line.map_or(0, |l| l - 1), &b.vars, &cli, &ctx);
3419 }
3420 }
3421 }
3422 }
3423
3424 fn check_template_text(
3426 &mut self,
3427 file: &Path,
3428 src: &str,
3429 line_offset: usize,
3430 fixed_line: Option<usize>,
3431 _cli: &BTreeSet<&str>,
3432 ) {
3433 let f = Some(file.to_path_buf());
3434 if let Err((line, msg)) = preflight::check_syntax(&file.to_string_lossy(), src) {
3435 let line = fixed_line.or(line.map(|l| l + line_offset));
3436 self.diags.error(
3437 "jinja-syntax",
3438 f.clone(),
3439 line,
3440 format!("Jinja syntax error: {msg}"),
3441 );
3442 return;
3443 }
3444 for c in preflight::calls(src)
3445 .into_iter()
3446 .filter(|c| c.func == "env_var" && !c.has_default)
3447 {
3448 if std::env::var_os(&c.name).is_none() {
3449 self.diags.error(
3450 "unset-env-var",
3451 f.clone(),
3452 Some(fixed_line.unwrap_or(c.line + line_offset)),
3453 format!(
3454 "`env_var('{}')`: environment variable `{}` is not set and no default is given",
3455 c.name, c.name
3456 ),
3457 );
3458 }
3459 }
3460 for (r, line) in preflight::unknown_run_refs(src) {
3461 self.diags.error(
3462 "unknown-run-attribute",
3463 f.clone(),
3464 Some(fixed_line.unwrap_or(line + line_offset)),
3465 format!(
3466 "`{r}` isn't part of the run context; known: run.report, run.set, run.target, run.profile, run.source_type, run.schedule, run.date (a date: .prev_month, .month_start, .yyyymmdd, ...), run.now, run.timezone, run.date_format(...)"
3467 ),
3468 );
3469 }
3470 }
3471
3472 fn check_vars(
3473 &mut self,
3474 file: &Path,
3475 src: &str,
3476 line_offset: usize,
3477 vars: &JsonMap<String, Json>,
3478 cli: &BTreeSet<&str>,
3479 ctx: &str,
3480 ) {
3481 for c in preflight::calls(src)
3482 .into_iter()
3483 .filter(|c| c.func == "var" && !c.has_default)
3484 {
3485 if !vars.contains_key(&c.name) && !cli.contains(c.name.as_str()) {
3486 self.diags.error(
3487 "unresolved-var",
3488 Some(file.to_path_buf()),
3489 Some(c.line + line_offset),
3490 format!(
3491 "`var('{}')` has no value for {ctx} (checked --var, Set, report, folder and project vars) and no default",
3492 c.name
3493 ),
3494 );
3495 }
3496 }
3497 }
3498
3499 fn check_template_files(&mut self, project: &Project) {
3500 let mut seen = BTreeSet::new();
3501 for r in &project.reports {
3502 for b in &r.bindings {
3503 let Some(t) = &b.output.template else { continue };
3504 if !seen.insert((r.name.clone(), t.file.clone(), format!("{:?}", t.bindings))) {
3505 continue;
3506 }
3507 let candidates = [self.root.join(&t.file), self.root.join("templates").join(&t.file)];
3508 let Some(path) = candidates.iter().find(|p| p.is_file()) else {
3509 self.diags.error(
3510 "missing-template",
3511 Some(r.file.clone()),
3512 None,
3513 format!("report `{}`: template file `{}` doesn't exist", r.name, t.file),
3514 );
3515 continue;
3516 };
3517 match template_sheets(path) {
3518 Ok(sheets) => {
3519 for tb in &t.bindings {
3520 if !sheets.contains(&tb.sheet) {
3521 self.diags.error(
3522 "invalid-template",
3523 Some(r.file.clone()),
3524 None,
3525 format!(
3526 "report `{}`: template `{}` has no sheet `{}` (sheets: {})",
3527 r.name,
3528 t.file,
3529 tb.sheet,
3530 sheets.join(", ")
3531 ),
3532 );
3533 }
3534 }
3535 }
3536 Err(e) => self.diags.error(
3537 "invalid-template",
3538 Some(r.file.clone()),
3539 None,
3540 format!("report `{}`: can't read template `{}`: {e}", r.name, t.file),
3541 ),
3542 }
3543 }
3544 }
3545 }
3546}
3547
3548pub fn template_sheets(path: &Path) -> Result<Vec<String>, String> {
3550 use calamine::Reader;
3551 let wb: calamine::Xlsx<_> =
3552 calamine::open_workbook(path).map_err(|e: calamine::XlsxError| e.to_string())?;
3553 Ok(wb.sheet_names().to_vec())
3554}
3555
3556struct RawReport {
3557 name: String,
3558 file: Rc<YamlFile>,
3559 folder: Vec<String>,
3560 keys: BTreeMap<String, Located<Value>>,
3561}
3562
3563struct BindingBase {
3564 profile: Option<String>,
3565 vars: JsonMap<String, Json>,
3566 output: Mapping,
3567}
3568
3569#[derive(Default)]
3571struct Usage {
3572 sources: BTreeMap<String, (Option<PathBuf>, Option<usize>)>,
3573 destinations: BTreeMap<String, (Option<PathBuf>, Option<usize>)>,
3574 formats: BTreeMap<String, (String, PathBuf)>,
3576}
3577
3578impl Usage {
3579 fn source(&mut self, p: &str, file: Option<PathBuf>, line: Option<usize>) {
3580 let e = self.sources.entry(p.to_string()).or_insert((None, None));
3581 if e.0.is_none() {
3582 *e = (file, line);
3583 }
3584 }
3585 fn destination(&mut self, p: &str, file: Option<PathBuf>, line: Option<usize>) {
3586 let e = self.destinations.entry(p.to_string()).or_insert((None, None));
3587 if e.0.is_none() {
3588 *e = (file, line);
3589 }
3590 }
3591 fn format(&mut self, f: &str, ctx: &str, file: &Path) {
3592 self.formats
3593 .entry(f.to_string())
3594 .or_insert_with(|| (ctx.to_string(), file.to_path_buf()));
3595 }
3596}
3597
3598fn builtin_output() -> Mapping {
3603 let mut m = Mapping::new();
3604 m.insert(Value::String("format".into()), Value::String("csv".into()));
3605 m
3606}
3607
3608fn project_default_output(p: &Project) -> Option<Mapping> {
3609 let yf = YamlFile::parse(
3610 std::fs::read_to_string(p.root.join(PROJECT_FILE)).ok()?,
3611 PathBuf::from(PROJECT_FILE),
3612 &mut Diagnostics::default(),
3613 )?;
3614 yf.value
3615 .get("default_output")
3616 .and_then(Value::as_mapping)
3617 .cloned()
3618}
3619
3620pub fn merge_output(base: &mut Mapping, over: &Mapping) -> Option<String> {
3631 let fmt_key = Value::String("format".into());
3632 if let Some(f) = over.get(&fmt_key)
3633 && base.get(&fmt_key) != Some(f)
3634 {
3635 base.retain(|k, _| is_one_of(k, &["destination", "template"]));
3636 }
3637 let mut problem = None;
3638 for (k, v) in over {
3639 if k.as_str() == Some("destination")
3640 && let Value::Mapping(o) = v
3641 {
3642 let profile = |m: &Mapping| m.get("profile").cloned();
3643 match base.get_mut(k) {
3644 Some(Value::Sequence(list)) if o.get("profile").is_none() && list.len() > 1 => {
3645 problem = Some(format!(
3646 "this overrides `destination` with no `profile:`, but it inherits {} destinations, so it's unclear which one to change; override the full list instead",
3647 list.len()
3648 ));
3649 continue;
3650 }
3651 Some(Value::Sequence(list))
3652 if list.len() == 1
3653 && list[0]
3654 .as_mapping()
3655 .is_some_and(|b| o.get("profile").is_none() || profile(b) == profile(o)) =>
3656 {
3657 let mut b = list[0].as_mapping().cloned().unwrap_or_default();
3658 b.extend(o.clone());
3659 base.insert(k.clone(), Value::Mapping(b));
3660 continue;
3661 }
3662 Some(Value::Mapping(b)) if o.get("profile").is_none() || profile(b) == profile(o) => {
3663 b.extend(o.clone());
3664 continue;
3665 }
3666 _ => {}
3667 }
3668 }
3669 base.insert(k.clone(), v.clone());
3670 }
3671 problem
3672}
3673
3674fn folder_layers<'a>(folders: &'a BTreeMap<Vec<String>, FolderCfg>, folder: &[String]) -> Vec<&'a FolderCfg> {
3675 (0..=folder.len())
3676 .filter_map(|n| folders.get(&folder[..n].to_vec()))
3677 .collect()
3678}
3679
3680fn folder_segments(rel: &Path) -> Vec<String> {
3681 rel.components()
3682 .skip(1)
3683 .map(|c| c.as_os_str().to_string_lossy().to_string())
3684 .collect()
3685}
3686
3687pub fn dotted(path: &[String]) -> String {
3688 path.join(".")
3689}
3690
3691fn pick(v: &Value, keys: &[&str]) -> Mapping {
3692 v.as_mapping()
3693 .map(|m| {
3694 m.iter()
3695 .filter(|(k, _)| is_one_of(k, keys))
3696 .map(|(k, v)| (k.clone(), v.clone()))
3697 .collect()
3698 })
3699 .unwrap_or_default()
3700}
3701
3702fn json_strings(v: &Json) -> Vec<String> {
3704 match v {
3705 Json::String(s) => vec![s.clone()],
3706 Json::Array(a) => a.iter().flat_map(json_strings).collect(),
3707 Json::Object(o) => o.values().flat_map(json_strings).collect(),
3708 _ => Vec::new(),
3709 }
3710}
3711
3712fn is_one_of(k: &Value, keys: &[&str]) -> bool {
3713 k.as_str().is_some_and(|k| keys.contains(&k))
3714}
3715
3716fn is_set_registry(m: &Mapping) -> bool {
3719 m.values().any(Value::is_mapping)
3720 && m.values().all(|v| {
3721 v.is_null()
3722 || v.as_mapping()
3723 .is_some_and(|e| e.keys().all(|k| is_one_of(k, &["profile", "vars"])))
3724 })
3725}
3726
3727fn string_list(v: &Value) -> Option<Vec<String>> {
3728 v.as_sequence()?
3729 .iter()
3730 .map(|i| i.as_str().map(str::to_string))
3731 .collect()
3732}
3733
3734fn entry_name(v: &Value) -> Option<String> {
3735 match v {
3736 Value::String(s) => Some(s.clone()),
3737 Value::Mapping(m) => m.get("query").and_then(Value::as_str).map(str::to_string),
3738 _ => None,
3739 }
3740}
3741
3742fn dedup(v: &mut Vec<String>) {
3743 let mut seen = BTreeSet::new();
3744 v.retain(|t| seen.insert(t.clone()));
3745}
3746
3747fn nth_item_line(text: &str, n: usize) -> Option<usize> {
3748 text.lines()
3749 .enumerate()
3750 .filter(|(_, l)| l.starts_with("- ") || l.trim() == "-")
3751 .nth(n)
3752 .map(|(i, _)| i + 1)
3753}
3754
3755pub fn yaml_to_json(v: &Value) -> Json {
3756 serde_json::to_value(v).unwrap_or(Json::Null)
3757}
3758
3759pub fn yaml_map_to_json(m: &Mapping) -> JsonMap<String, Json> {
3760 match yaml_to_json(&Value::Mapping(m.clone())) {
3761 Json::Object(o) => o,
3762 _ => JsonMap::new(),
3763 }
3764}
3765
3766fn is_identifier(s: &str) -> bool {
3768 !s.is_empty()
3769 && !s.starts_with(|c: char| c.is_ascii_digit())
3770 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
3771}