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