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