1use std::collections::{BTreeMap, BTreeSet};
16use std::path::{Path, PathBuf};
17
18use schemars::JsonSchema;
19use serde::Serialize;
20use serde_json::{Map as JsonMap, Value as Json};
21use sha2::{Digest, Sha256};
22
23use crate::diag::{Diagnostics, Severity};
24use crate::lookups::LOOKUPS_DIR;
25use crate::project::{Binding, MACROS_DIR, PluginSource, Project, QueryEntry, REPORTS_DIR, Report};
26
27pub const SCHEMA_VERSION: &str = "dre/manifest/v3";
29pub const FILE: &str = "manifest.json";
31
32pub type ReportErrors = BTreeMap<String, Vec<String>>;
34
35pub fn path(project: &Project) -> PathBuf {
37 project.target_dir.join(FILE)
38}
39
40pub fn build(project: &Project, errors: &ReportErrors) -> Json {
42 let schedules = project
43 .schedules
44 .iter()
45 .map(|e| (e.name.clone(), schedule(project, &e.name)))
46 .collect();
47 document(
48 project,
49 project
50 .reports
51 .iter()
52 .map(|r| (r, r.bindings.iter().collect()))
53 .collect(),
54 schedules,
55 errors,
56 )
57}
58
59pub fn subset(
62 project: &Project,
63 reports: Vec<(&Report, Vec<&Binding>)>,
64 schedule_names: &[String],
65 errors: &ReportErrors,
66) -> Json {
67 let schedules = schedule_names
68 .iter()
69 .map(|n| (n.clone(), schedule(project, n)))
70 .collect();
71 document(project, reports, schedules, errors)
72}
73
74type Map = JsonMap<String, Json>;
77
78#[derive(Serialize, JsonSchema)]
80#[schemars(title = "DRE project manifest")]
81pub struct Manifest {
82 #[schemars(schema_with = "manifest_version")]
84 pub schema_version: &'static str,
85 pub version: String,
87 pub project: ManifestProject,
88 pub reports: BTreeMap<String, ManifestReport>,
90 pub schedules: BTreeMap<String, Option<ManifestSchedule>>,
92 pub sources: BTreeMap<String, ManifestSource>,
94 pub plugins: Vec<ManifestPlugin>,
96}
97
98#[derive(Serialize, JsonSchema)]
99pub struct ManifestProject {
100 pub name: String,
101 pub target: String,
103 #[serde(skip_serializing_if = "Option::is_none")]
105 pub default_profile: Option<String>,
106 #[serde(skip_serializing_if = "Option::is_none")]
108 pub timezone: Option<String>,
109 #[schemars(schema_with = "sha256")]
111 pub checksum: String,
112}
113
114#[derive(Serialize, JsonSchema)]
115#[schemars(rename = "report")]
116pub struct ManifestReport {
117 pub name: String,
118 pub managed: bool,
120 #[schemars(schema_with = "path_schema")]
122 pub file: String,
123 pub folder: Vec<String>,
125 pub tags: Vec<String>,
126 #[serde(skip_serializing_if = "Option::is_none")]
127 pub timezone: Option<String>,
128 #[serde(skip_serializing_if = "Option::is_none")]
129 pub default_set: Option<String>,
130 pub queries: Vec<ManifestQuery>,
131 pub depends_on: DependsOn,
133 #[schemars(schema_with = "sha256")]
135 pub checksum: String,
136 pub valid: bool,
137 #[serde(skip_serializing_if = "Option::is_none")]
139 pub errors: Option<Vec<String>>,
140 pub bindings: Vec<ManifestBinding>,
141}
142
143#[derive(Serialize, JsonSchema)]
144pub struct DependsOn {
145 pub sources: Vec<String>,
147}
148
149#[derive(Serialize, JsonSchema)]
150#[schemars(rename = "query")]
151pub struct ManifestQuery {
152 pub query: String,
153 #[schemars(schema_with = "path_schema")]
154 pub file: String,
155 #[serde(skip_serializing_if = "Option::is_none")]
157 pub profile: Option<String>,
158 #[serde(skip_serializing_if = "Option::is_none")]
160 pub connection: Option<Option<String>>,
161 #[serde(skip_serializing_if = "Option::is_none")]
163 pub depends_on: Option<DependsOn>,
164 pub tab: bool,
166 #[serde(skip_serializing_if = "Option::is_none")]
167 pub tab_name: Option<String>,
168 #[serde(skip_serializing_if = "Option::is_none")]
169 pub anchor: Option<String>,
170 #[serde(skip_serializing_if = "Option::is_none")]
171 pub header: Option<bool>,
172 #[serde(skip_serializing_if = "Option::is_none")]
174 pub columns: Option<Json>,
175}
176
177#[derive(Serialize, JsonSchema)]
178#[schemars(rename = "binding")]
179pub struct ManifestBinding {
180 pub set: Option<String>,
182 pub profile: Option<String>,
184 pub vars: Map,
186 pub queries: Vec<ManifestQuery>,
187 pub output: ManifestOutput,
189 pub destinations: Vec<ManifestDestination>,
191 pub outputs: Vec<ManifestOutput>,
193 pub schedules: Vec<String>,
195}
196
197#[derive(Serialize, JsonSchema, Clone, Default)]
198pub struct ManifestOutput {
199 #[serde(skip_serializing_if = "Option::is_none")]
201 pub name: Option<String>,
202 pub format: String,
203 #[serde(skip_serializing_if = "Option::is_none")]
205 pub queries: Option<Vec<String>>,
206 #[serde(skip_serializing_if = "Option::is_none")]
208 pub when: Option<String>,
209 pub options: Map,
210 #[serde(skip_serializing_if = "Option::is_none")]
211 pub extension: Option<String>,
212 #[serde(skip_serializing_if = "Option::is_none")]
214 #[schemars(schema_with = "path_schema")]
215 pub template: Option<String>,
216 #[serde(skip_serializing_if = "Option::is_none")]
218 pub destinations: Option<Vec<ManifestDestination>>,
219}
220
221#[derive(Serialize, JsonSchema, Clone)]
222#[schemars(rename = "destination")]
223pub struct ManifestDestination {
224 pub profile: String,
226 #[serde(skip_serializing_if = "Option::is_none")]
228 pub path: Option<String>,
229 #[serde(skip_serializing_if = "Option::is_none")]
231 pub attach: Option<Vec<String>>,
232}
233
234#[derive(Serialize, JsonSchema)]
235#[schemars(rename = "schedule")]
236pub struct ManifestSchedule {
237 pub name: String,
238 #[serde(skip_serializing_if = "Option::is_none")]
239 pub report: Option<String>,
240 #[serde(skip_serializing_if = "Option::is_none")]
241 pub select: Option<String>,
242 #[serde(skip_serializing_if = "Option::is_none")]
243 pub set: Option<String>,
244 pub schedule: Map,
246 #[serde(skip_serializing_if = "Option::is_none")]
248 pub timing: Option<String>,
249 pub enabled: bool,
251 pub vars: Map,
252 #[serde(skip_serializing_if = "Option::is_none")]
254 pub timezone: Option<String>,
255 pub bindings: Vec<ScheduledBinding>,
257}
258
259#[derive(Serialize, JsonSchema)]
260pub struct ScheduledBinding {
261 pub report: String,
262 pub set: Option<String>,
263}
264
265#[derive(Serialize, JsonSchema)]
266#[schemars(rename = "source")]
267pub struct ManifestSource {
268 pub name: String,
269 #[schemars(schema_with = "path_schema")]
271 pub file: String,
272 #[serde(skip_serializing_if = "Option::is_none")]
274 pub profile: Option<String>,
275 #[serde(skip_serializing_if = "Option::is_none")]
277 pub database: Option<String>,
278 pub schema: String,
280 #[serde(skip_serializing_if = "Option::is_none")]
281 pub description: Option<String>,
282 pub tags: Vec<String>,
283 pub meta: Map,
284 pub tables: BTreeMap<String, ManifestTable>,
286 #[serde(skip_serializing_if = "Option::is_none")]
288 pub errors: Option<Vec<String>>,
289}
290
291#[derive(Serialize, JsonSchema)]
292pub struct ManifestTable {
293 pub name: String,
294 pub identifier: String,
296 #[schemars(schema_with = "quoting")]
298 pub quoting: crate::render::Quoting,
299 #[serde(skip_serializing_if = "Option::is_none")]
300 pub description: Option<String>,
301 pub tags: Vec<String>,
302 pub meta: Map,
303 #[schemars(schema_with = "columns")]
304 pub columns: Vec<crate::project::SourceColumn>,
305 pub used_by: BTreeSet<String>,
307}
308
309#[derive(Serialize, JsonSchema)]
310pub struct ManifestPlugin {
311 pub package: String,
312 pub version: String,
314 pub source: PluginSourceRecord,
315}
316
317#[derive(Serialize, JsonSchema)]
318pub struct PluginSourceRecord {
319 #[serde(rename = "type")]
320 #[schemars(schema_with = "plugin_source_type")]
321 pub kind: &'static str,
322 #[serde(skip_serializing_if = "Option::is_none")]
324 pub location: Option<String>,
325}
326
327fn manifest_version(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
328 schemars::json_schema!({"const": SCHEMA_VERSION})
329}
330
331fn sha256(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
332 schemars::json_schema!({"type": "string", "pattern": "^[0-9a-f]{64}$"})
333}
334
335fn path_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
336 schemars::json_schema!({"type": "string", "description": "Relative to the project root, with forward slashes."})
337}
338
339fn plugin_source_type(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
340 schemars::json_schema!({"enum": ["registry", "github", "local"]})
341}
342
343fn quoting(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
344 schemars::json_schema!({
345 "type": "object",
346 "properties": {"database": {"type": "boolean"}, "schema": {"type": "boolean"}, "identifier": {"type": "boolean"}},
347 "required": ["database", "schema", "identifier"]
348 })
349}
350
351fn columns(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
352 schemars::json_schema!({
353 "type": "array",
354 "items": {
355 "type": "object",
356 "properties": {"name": {"type": "string"}, "description": {"type": "string"}, "data_type": {"type": "string"}},
357 "required": ["name"]
358 }
359 })
360}
361
362fn document(
365 project: &Project,
366 reports: Vec<(&Report, Vec<&Binding>)>,
367 schedules: BTreeMap<String, Option<ManifestSchedule>>,
368 errors: &ReportErrors,
369) -> Json {
370 let reports = reports
371 .into_iter()
372 .map(|(r, bs)| (r.name.clone(), report(project, r, &bs, errors.get(&r.name))))
373 .collect();
374 let plugins = project
375 .plugins
376 .iter()
377 .map(|p| {
378 let (kind, location) = match &p.source {
379 PluginSource::Default => ("registry", None),
380 PluginSource::Registry(u) => ("registry", Some(u.clone())),
381 PluginSource::Github(r) => ("github", Some(r.clone())),
382 PluginSource::Local(l) => ("local", Some(l.clone())),
383 };
384 ManifestPlugin {
385 package: p.name.clone(),
386 version: p.version.clone(),
387 source: PluginSourceRecord { kind, location },
388 }
389 })
390 .collect();
391 let doc = Manifest {
392 schema_version: SCHEMA_VERSION,
393 version: crate::version().to_string(),
394 project: ManifestProject {
395 name: project.name.clone(),
396 target: project.target_name.clone(),
397 default_profile: project.default_profile.clone(),
398 timezone: project.timezone.clone(),
399 checksum: project_checksum(project),
400 },
401 reports,
402 schedules,
403 sources: sources(project),
404 plugins,
405 };
406 sorted(serde_json::to_value(doc).expect("a manifest serializes"))
407}
408
409fn report(
410 project: &Project,
411 r: &Report,
412 bindings: &[&Binding],
413 errors: Option<&Vec<String>>,
414) -> ManifestReport {
415 let mut used: Vec<String> = Vec::new();
417 for p in r.bindings.iter().filter_map(|b| b.parsed.as_ref()) {
418 for k in p.source_keys() {
419 if !used.iter().any(|u| u == k) {
420 used.push(k.to_string());
421 }
422 }
423 }
424 let errors = errors.cloned().unwrap_or_default();
425 ManifestReport {
426 name: r.name.clone(),
427 managed: r.managed,
428 file: slash(&r.file),
429 folder: r.folder.clone(),
430 tags: r.tags.clone(),
431 timezone: r.timezone.clone(),
432 default_set: r.default_set.clone(),
433 queries: r.queries.iter().map(|q| query(q, None)).collect(),
434 depends_on: DependsOn { sources: used },
435 checksum: report_checksum(project, r),
436 valid: errors.is_empty(),
437 errors: (!errors.is_empty()).then_some(errors),
438 bindings: bindings.iter().map(|b| binding(b)).collect(),
439 }
440}
441
442fn query(q: &QueryEntry, parsed: Option<&crate::parse::ParsedQuery>) -> ManifestQuery {
444 ManifestQuery {
445 query: q.query.clone(),
446 file: slash(&q.path),
447 profile: q.profile.clone(),
448 connection: parsed.map(|p| p.connection.clone()),
449 depends_on: parsed.map(|p| DependsOn {
450 sources: p.sources.clone(),
451 }),
452 tab: q.tab,
453 tab_name: q.tab_name.clone(),
454 anchor: q.anchor.clone(),
455 header: q.header,
456 columns: (!q.columns.is_empty()).then(|| serde_json::to_value(&q.columns).unwrap_or(Json::Null)),
457 }
458}
459
460fn binding(b: &Binding) -> ManifestBinding {
461 let parsed = b.parsed.as_deref();
462 let mut next = 0;
464 let outputs: Vec<ManifestOutput> = b
465 .outputs
466 .iter()
467 .map(|o| {
468 let destinations = o
469 .destinations
470 .iter()
471 .map(|d| {
472 let rendered = parsed.and_then(|p| p.destinations.get(next).cloned().flatten());
473 next += 1;
474 ManifestDestination {
475 profile: rendered.unwrap_or_else(|| d.profile.clone()),
476 path: d.path.clone(),
477 attach: (!d.attach.is_empty()).then(|| d.attach.clone()),
478 }
479 })
480 .collect();
481 ManifestOutput {
482 name: o.name.clone(),
483 format: o.format.clone(),
484 queries: o.queries.clone(),
485 when: o.when.clone(),
486 options: o.options.clone(),
487 extension: o.extension.clone(),
488 template: o.template.as_ref().map(|t| t.file.clone()),
489 destinations: Some(destinations),
490 }
491 })
492 .collect();
493 let output = outputs
494 .first()
495 .map(|o| ManifestOutput {
496 destinations: None,
497 ..o.clone()
498 })
499 .unwrap_or_default();
500 let destinations = outputs
501 .iter()
502 .flat_map(|o| o.destinations.clone().unwrap_or_default())
503 .collect();
504 ManifestBinding {
505 set: b.set.clone(),
506 profile: parsed.map_or(b.profile.clone(), |p| p.inherited.clone()),
507 vars: b.vars.clone(),
508 queries: b
509 .queries
510 .iter()
511 .map(|q| query(q, parsed.and_then(|p| p.query(&q.query))))
512 .collect(),
513 output,
514 destinations,
515 outputs,
516 schedules: b.schedules.clone(),
517 }
518}
519
520fn sources(project: &Project) -> BTreeMap<String, ManifestSource> {
525 if project.sources.is_empty() {
526 return BTreeMap::new();
527 }
528 let inputs = &project.inputs;
529 let calendar = crate::dates::Calendar {
530 tz: inputs
531 .timezone
532 .as_deref()
533 .or(project.timezone.as_deref())
534 .and_then(|t| crate::dates::parse_tz(t).ok())
535 .unwrap_or(chrono_tz::Tz::UTC),
536 week_start: project.week_start,
537 numbering: project.week_numbering,
538 };
539 let context = crate::render::RunContext {
540 report: String::new(),
541 set: None,
542 target: project.target_name.clone(),
543 schedule: None,
544 date: crate::parse::run_date(&calendar, inputs.date, inputs.scheduled_at),
545 now: inputs
546 .scheduled_at
547 .unwrap_or(chrono::DateTime::<chrono::Utc>::UNIX_EPOCH),
548 scheduled_at: inputs.scheduled_at,
549 calendar,
550 locale: Default::default(),
551 };
552 let limited = crate::render::Limited::new(context, project.vars.clone(), inputs.cli_vars.clone());
553 let mut used: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
554 for r in &project.reports {
555 for p in r.bindings.iter().filter_map(|b| b.parsed.as_ref()) {
556 for k in p.source_keys() {
557 used.entry(k.to_string()).or_default().insert(r.name.clone());
558 }
559 }
560 }
561 let mut out = BTreeMap::new();
562 for (name, s) in &project.sources {
563 let mut errors: Vec<String> = Vec::new();
564 let mut render = |key: &str, v: &str| match limited.render(&format!("`{key}`"), v) {
565 Ok(r) => r,
566 Err(e) => {
567 errors.push(e);
568 v.to_string()
569 }
570 };
571 let profile = s.profile.as_ref().map(|p| render("profile", p));
572 let database = s.database.as_ref().map(|d| render("database", d));
573 let schema = render("schema", s.schema.as_deref().unwrap_or(name));
574 let tables = s
575 .tables
576 .iter()
577 .map(|t| {
578 let key = format!("{name}.{}", t.name);
579 let table = ManifestTable {
580 name: t.name.clone(),
581 identifier: render("identifier", t.identifier.as_deref().unwrap_or(&t.name)),
582 quoting: t.quoting.over(&s.quoting),
583 description: t.description.clone(),
584 tags: t.tags.clone(),
585 meta: t.meta.clone(),
586 columns: t.columns.clone(),
587 used_by: used.get(&key).cloned().unwrap_or_default(),
588 };
589 (t.name.clone(), table)
590 })
591 .collect();
592 out.insert(
593 name.clone(),
594 ManifestSource {
595 name: name.clone(),
596 file: slash(&s.file),
597 profile,
598 database,
599 schema,
600 description: s.description.clone(),
601 tags: s.tags.clone(),
602 meta: s.meta.clone(),
603 tables,
604 errors: (!errors.is_empty()).then_some(errors),
605 },
606 );
607 }
608 out
609}
610
611fn schedule(project: &Project, name: &str) -> Option<ManifestSchedule> {
612 let e = project.schedules.iter().find(|e| e.name == name)?;
613 let bindings = project
614 .reports
615 .iter()
616 .flat_map(|r| {
617 r.bindings
618 .iter()
619 .filter(|b| b.schedules.iter().any(|s| s == name))
620 .map(|b| ScheduledBinding {
621 report: r.name.clone(),
622 set: b.set.clone(),
623 })
624 })
625 .collect();
626 Some(ManifestSchedule {
627 name: e.name.clone(),
628 report: e.report.clone(),
629 select: e.select.clone(),
630 set: e.set.clone(),
631 schedule: e.schedule.clone(),
632 timing: e.timing.clone(),
633 enabled: e.enabled,
634 vars: e.vars.clone(),
635 timezone: e.timezone.clone(),
636 bindings,
637 })
638}
639
640fn slash(p: &Path) -> String {
641 crate::slash(p).to_string_lossy().into_owned()
642}
643
644pub fn canonical(v: &Json) -> String {
646 serde_json::to_string(&sorted(v.clone())).unwrap()
647}
648
649fn sorted(v: Json) -> Json {
651 match v {
652 Json::Object(m) => {
653 let mut entries: Vec<(String, Json)> = m.into_iter().collect();
654 entries.sort_by(|a, b| a.0.cmp(&b.0));
655 Json::Object(entries.into_iter().map(|(k, v)| (k, sorted(v))).collect())
656 }
657 Json::Array(a) => Json::Array(a.into_iter().map(sorted).collect()),
658 v => v,
659 }
660}
661
662fn checksum(root: &Path, files: &BTreeSet<String>) -> String {
667 let mut h = Sha256::new();
668 for f in files {
669 h.update(f.as_bytes());
670 h.update([0u8]);
671 match std::fs::read(root.join(f)) {
672 Ok(bytes) => {
673 h.update([1u8]);
674 h.update((bytes.len() as u64).to_le_bytes());
675 h.update(&bytes);
676 }
677 Err(_) => h.update([0u8]),
678 }
679 }
680 hex(&h.finalize())
681}
682
683pub fn hex(bytes: &[u8]) -> String {
684 bytes.iter().map(|b| format!("{b:02x}")).collect()
685}
686
687fn report_files(project: &Project, r: &Report) -> BTreeSet<String> {
689 let mut files = BTreeSet::new();
690 files.insert(slash(&r.file));
691 for q in r
692 .queries
693 .iter()
694 .chain(r.bindings.iter().flat_map(|b| b.queries.iter()))
695 {
696 files.insert(slash(&q.path));
697 }
698 for o in r.bindings.iter().flat_map(|b| b.outputs.iter()) {
699 if let Some(t) = &o.template {
700 files.insert(template_file(&project.root, &t.file));
701 }
702 if let Some(f) = o
704 .options
705 .get("file")
706 .and_then(Json::as_str)
707 .filter(|_| o.is_message())
708 {
709 files.insert(template_file(&project.root, f));
710 }
711 }
712 files
713}
714
715fn template_file(root: &Path, file: &str) -> String {
717 let direct = Path::new(file);
718 let under = Path::new("templates").join(file);
719 if !root.join(direct).is_file() && root.join(&under).is_file() {
720 slash(&under)
721 } else {
722 slash(direct)
723 }
724}
725
726fn report_checksum(project: &Project, r: &Report) -> String {
727 checksum(&project.root, &report_files(project, r))
728}
729
730fn project_checksum(project: &Project) -> String {
734 let owned: BTreeSet<String> = project
736 .reports
737 .iter()
738 .flat_map(|r| {
739 let mut files = report_files(project, r);
740 if !r.managed {
741 files.remove(&slash(&r.file));
742 }
743 files
744 })
745 .collect();
746 let files: BTreeSet<String> = project
747 .files
748 .iter()
749 .map(|p| slash(p))
750 .filter(|p| !owned.contains(p))
751 .filter(|p| {
752 let p = Path::new(p);
753 let yaml = matches!(p.extension().and_then(|e| e.to_str()), Some("yml" | "yaml"));
754 yaml || p.starts_with(MACROS_DIR)
755 || p.starts_with(LOOKUPS_DIR)
756 || (p.starts_with(REPORTS_DIR) && p.extension().is_some_and(|e| e == "sql"))
757 })
758 .collect();
759 checksum(&project.root, &files)
760}
761
762pub fn report_errors(project: &Project, diags: &Diagnostics) -> ReportErrors {
766 let mut out = ReportErrors::new();
767 for r in &project.reports {
768 let files = report_files(project, r);
769 let msgs: Vec<String> = diags
770 .sorted()
771 .into_iter()
772 .filter(|d| d.severity == Severity::Error)
773 .filter(|d| d.file.as_ref().is_some_and(|f| files.contains(&slash(f))))
774 .map(|d| d.to_string())
775 .chain(
776 project
777 .parse_errors
778 .get(&r.name)
779 .into_iter()
780 .flatten()
781 .map(|d| d.to_string()),
782 )
783 .collect();
784 if !msgs.is_empty() {
785 out.insert(r.name.clone(), msgs);
786 }
787 }
788 out
789}
790
791pub fn render(doc: &Json) -> String {
795 crate::secrets::to_json_pretty(doc).unwrap() + "\n"
796}
797
798pub fn write(project: &Project, errors: &ReportErrors) -> Result<String, String> {
801 let text = render(&build(project, errors));
802 let dir = &project.target_dir;
803 crate::target::ensure(dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?;
804 let dst = dir.join(FILE);
805 crate::fs::write_atomic(&dst, text.as_bytes())
806 .map_err(|e| format!("can't write {}: {e}", dst.display()))?;
807 Ok(hex(&Sha256::digest(text.as_bytes())))
808}
809
810pub fn remove(target_dir: &Path) {
812 let _ = std::fs::remove_file(target_dir.join(FILE));
813}