Skip to main content

dre_core/
manifest.rs

1//! The project manifest, `<target>/manifest.json`: everything a project declares, as the engine
2//! resolves it, for orchestrators, CI and other tools. It's a deliberate projection of the loaded
3//! [`Project`], not a dump: built offline (no connection, no profiles, no plugins), the same bytes
4//! for the same project and the same inputs on every OS, and free of secrets and
5//! machine-specific values.
6//!
7//! Like dbt's, it's resolved for the run's inputs: Jinja in `profile:` values and source fields
8//! is rendered with the run's target, vars, environment variables and `run.*`, and each query's
9//! sources and connection come from the parse pass. Two targets can give two manifests.
10//!
11//! The format is a public, versioned contract (`docs/manifest.md`; `docs/manifest.schema.json` is
12//! generated from [`Manifest`]): adding optional fields keeps [`SCHEMA_VERSION`]; removing,
13//! renaming or re-typing a field, or changing what one means, bumps it.
14
15use 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
27/// The manifest format's version.
28pub const SCHEMA_VERSION: &str = "dre/manifest/v3";
29/// The manifest's file name in the target folder.
30pub const FILE: &str = "manifest.json";
31
32/// Per-report problems, by report name: they mark the report invalid in the manifest.
33pub type ReportErrors = BTreeMap<String, Vec<String>>;
34
35/// Where the manifest goes for `project`.
36pub fn path(project: &Project) -> PathBuf {
37    project.target_dir.join(FILE)
38}
39
40/// The whole project's manifest.
41pub 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
59/// A manifest-shaped document holding only `reports` (each with the given Bindings) and
60/// `schedules`: what `dre ls --output json` prints.
61pub 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
74// -- the format ---------------------------------------------------------------------------------
75
76type Map = JsonMap<String, Json>;
77
78/// target/manifest.json, written by `dre compile`, `dre validate` and `dre run`, and the document `dre ls --output json` prints (holding only the matching reports and schedules). Resolved for the run's inputs, as dbt's is: the target, vars, environment variables and run.* decide Jinja in `profile:` values and source fields, so the same project and the same inputs give the same bytes. New optional fields may appear within a version; consumers should ignore fields they don't know. See docs/manifest.md.
79#[derive(Serialize, JsonSchema)]
80#[schemars(title = "DRE project manifest")]
81pub struct Manifest {
82    /// The format's version: `dre/manifest/v3` (DRE 0.4). Version 2 (DRE 0.2) had `"schema": 2` instead.
83    #[schemars(schema_with = "manifest_version")]
84    pub schema_version: &'static str,
85    /// The DRE version that wrote it.
86    pub version: String,
87    pub project: ManifestProject,
88    /// By report name.
89    pub reports: BTreeMap<String, ManifestReport>,
90    /// By schedule name (schedules.yml).
91    pub schedules: BTreeMap<String, Option<ManifestSchedule>>,
92    /// Declared sources (`sources:`), by name.
93    pub sources: BTreeMap<String, ManifestSource>,
94    /// The plugin packages the project declares.
95    pub plugins: Vec<ManifestPlugin>,
96    /// The companions the project declares under `extensions:`, each with its own settings as written.
97    #[serde(skip_serializing_if = "Vec::is_empty")]
98    pub extensions: Vec<crate::config::dependencies::ExtensionEntry>,
99}
100
101#[derive(Serialize, JsonSchema)]
102pub struct ManifestProject {
103    pub name: String,
104    /// The run's target (environment), `target.name` in templates: --target, else DRE_TARGET, else `dev`. Each profile's own entry may differ (its `target:` in profiles.yml).
105    pub target: String,
106    /// The default connection's name, as written (it may hold Jinja).
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub default_profile: Option<String>,
109    /// The project's `timezone:` (IANA name).
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub timezone: Option<String>,
112    /// Over the shared inputs: dre_project.yml, folder config, schedules.yml, dependencies.yml and other YAML that isn't a report's own, macros/, lookups/, and every .sql under reports/ that isn't a declared query. When it changes, every report may have changed.
113    #[schemars(schema_with = "sha256")]
114    pub checksum: String,
115}
116
117#[derive(Serialize, JsonSchema)]
118#[schemars(rename = "report")]
119pub struct ManifestReport {
120    pub name: String,
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub description: Option<String>,
123    /// The report's own metadata, without inherited layers applied.
124    #[serde(skip_serializing_if = "Map::is_empty")]
125    pub meta: Map,
126    /// Unmerged metadata, outermost first; consumers decide its meaning.
127    #[serde(skip_serializing_if = "Vec::is_empty")]
128    pub meta_layers: Vec<crate::project::MetaLayer>,
129    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
130    pub var_schema: crate::variables::Definitions,
131    /// False for a bare .sql under reports/ (an unmanaged report).
132    pub managed: bool,
133    /// The defining YAML, or the .sql of an unmanaged report.
134    #[schemars(schema_with = "path_schema")]
135    pub file: String,
136    /// Folder segments under reports/.
137    pub folder: Vec<String>,
138    pub tags: Vec<String>,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub timezone: Option<String>,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub default_set: Option<String>,
143    pub queries: Vec<ManifestQuery>,
144    /// Every source any of the report's Bindings reads, `source.table`.
145    pub depends_on: DependsOn,
146    /// Over the defining file, every query file and any template.
147    #[schemars(schema_with = "sha256")]
148    pub checksum: String,
149    pub valid: bool,
150    /// Present when invalid: what's wrong.
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub errors: Option<Vec<String>>,
153    pub bindings: Vec<ManifestBinding>,
154}
155
156#[derive(Serialize, JsonSchema)]
157pub struct DependsOn {
158    /// `source.table` of every `source()` used, in order of first use.
159    pub sources: Vec<String>,
160}
161
162#[derive(Serialize, JsonSchema)]
163#[schemars(rename = "query")]
164pub struct ManifestQuery {
165    pub query: String,
166    #[schemars(schema_with = "path_schema")]
167    pub file: String,
168    /// The query's own `profile:`, as written.
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub profile: Option<String>,
171    /// In a Binding: the connection the query runs on, from the parse pass (its own `profile:`, a source's, else the inherited one). Null when none resolves.
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub connection: Option<Option<String>>,
174    /// In a Binding: what the query reads.
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub depends_on: Option<DependsOn>,
177    /// Whether its result becomes a tab (false: run for its effects only).
178    pub tab: bool,
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub tab_name: Option<String>,
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub anchor: Option<String>,
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub header: Option<bool>,
185    /// Per result column, how to show it (xlsx).
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub columns: Option<Json>,
188}
189
190#[derive(Serialize, JsonSchema)]
191#[schemars(rename = "binding")]
192pub struct ManifestBinding {
193    /// Null for a report without Sets.
194    pub set: Option<String>,
195    /// The inherited connection (Set, report, folder `+profile`, `default_profile`), rendered. A query's own connection is on the query.
196    pub profile: Option<String>,
197    /// Fully merged: project < folders < report < Set.
198    pub vars: Map,
199    pub queries: Vec<ManifestQuery>,
200    /// The first output; every output is under `outputs`.
201    pub output: ManifestOutput,
202    /// Every output's destinations, in delivery order.
203    pub destinations: Vec<ManifestDestination>,
204    /// Every output, in declared order.
205    pub outputs: Vec<ManifestOutput>,
206    /// The schedules that run this Binding.
207    pub schedules: Vec<String>,
208}
209
210#[derive(Serialize, JsonSchema, Clone, Default)]
211pub struct ManifestOutput {
212    /// The output's `name:`.
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub name: Option<String>,
215    pub format: String,
216    /// The queries it formats; absent means all of them.
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub queries: Option<Vec<String>>,
219    /// The `when:` condition, unrendered.
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub when: Option<String>,
222    pub options: Map,
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub extension: Option<String>,
225    /// The template file, as declared.
226    #[serde(skip_serializing_if = "Option::is_none")]
227    #[schemars(schema_with = "path_schema")]
228    pub template: Option<String>,
229    /// In delivery order. (Absent on a Binding's `output`.)
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub destinations: Option<Vec<ManifestDestination>>,
232}
233
234#[derive(Serialize, JsonSchema, Clone)]
235#[schemars(rename = "destination")]
236pub struct ManifestDestination {
237    /// The destination profile, rendered.
238    pub profile: String,
239    /// The path template, unrendered.
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub path: Option<String>,
242    /// A message output's entry: the outputs whose files go with the message.
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub attach: Option<Vec<String>>,
245}
246
247#[derive(Serialize, JsonSchema)]
248#[schemars(rename = "schedule")]
249pub struct ManifestSchedule {
250    pub name: String,
251    #[serde(skip_serializing_if = "Option::is_none")]
252    pub report: Option<String>,
253    #[serde(skip_serializing_if = "Option::is_none")]
254    pub select: Option<String>,
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub set: Option<String>,
257    /// The resolved timing: `cron`, `every` or `rrule`, with `starting`, `at`, `except` and `also` when given. With `timing`, the shared timing's fields.
258    pub schedule: Map,
259    /// The timings.yml entry the timing comes from, when it's a shared one.
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub timing: Option<String>,
262    /// False when the schedule is paused (`enabled: false`).
263    pub enabled: bool,
264    pub vars: Map,
265    /// The schedule's `timezone:`, or its shared timing's.
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub timezone: Option<String>,
268    /// What `dre run --schedule <name>` runs.
269    pub bindings: Vec<ScheduledBinding>,
270}
271
272#[derive(Serialize, JsonSchema)]
273pub struct ScheduledBinding {
274    pub report: String,
275    pub set: Option<String>,
276}
277
278#[derive(Serialize, JsonSchema)]
279#[schemars(rename = "source")]
280pub struct ManifestSource {
281    pub name: String,
282    /// The YAML file declaring it.
283    #[schemars(schema_with = "path_schema")]
284    pub file: String,
285    /// The connection it lives on, rendered. Absent: it runs wherever its query runs.
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub profile: Option<String>,
288    /// Rendered. Absent: `source()` renders two parts.
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub database: Option<String>,
291    /// Rendered; the source's name unless set.
292    pub schema: String,
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub description: Option<String>,
295    pub tags: Vec<String>,
296    pub meta: Map,
297    /// By table name.
298    pub tables: BTreeMap<String, ManifestTable>,
299    /// Fields that didn't render (left as written).
300    #[serde(skip_serializing_if = "Option::is_none")]
301    pub errors: Option<Vec<String>>,
302}
303
304#[derive(Serialize, JsonSchema)]
305pub struct ManifestTable {
306    pub name: String,
307    /// The real table name, rendered; the table's name unless set.
308    pub identifier: String,
309    /// Which parts `source()` quotes, the table's over the source's.
310    #[schemars(schema_with = "quoting")]
311    pub quoting: crate::render::Quoting,
312    #[serde(skip_serializing_if = "Option::is_none")]
313    pub description: Option<String>,
314    pub tags: Vec<String>,
315    pub meta: Map,
316    #[schemars(schema_with = "columns")]
317    pub columns: Vec<crate::project::SourceColumn>,
318    /// The reports that read it. Empty: unused.
319    pub used_by: BTreeSet<String>,
320}
321
322#[derive(Serialize, JsonSchema)]
323pub struct ManifestPlugin {
324    pub package: String,
325    /// The version requirement (`*` for any).
326    pub version: String,
327    pub source: PluginSourceRecord,
328}
329
330#[derive(Serialize, JsonSchema)]
331pub struct PluginSourceRecord {
332    #[serde(rename = "type")]
333    #[schemars(schema_with = "plugin_source_type")]
334    pub kind: &'static str,
335    /// Another registry's URL or path, a GitHub `owner/repo`, or a local executable's path. Absent for DRE's default registry.
336    #[serde(skip_serializing_if = "Option::is_none")]
337    pub location: Option<String>,
338}
339
340fn manifest_version(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
341    schemars::json_schema!({"const": SCHEMA_VERSION})
342}
343
344fn sha256(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
345    schemars::json_schema!({"type": "string", "pattern": "^[0-9a-f]{64}$"})
346}
347
348fn path_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
349    schemars::json_schema!({"type": "string", "description": "Relative to the project root, with forward slashes."})
350}
351
352fn plugin_source_type(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
353    schemars::json_schema!({"enum": ["registry", "github", "local"]})
354}
355
356fn quoting(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
357    schemars::json_schema!({
358        "type": "object",
359        "properties": {"database": {"type": "boolean"}, "schema": {"type": "boolean"}, "identifier": {"type": "boolean"}},
360        "required": ["database", "schema", "identifier"]
361    })
362}
363
364fn columns(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
365    schemars::json_schema!({
366        "type": "array",
367        "items": {
368            "type": "object",
369            "properties": {"name": {"type": "string"}, "description": {"type": "string"}, "data_type": {"type": "string"}},
370            "required": ["name"]
371        }
372    })
373}
374
375// -- building it --------------------------------------------------------------------------------
376
377fn document(
378    project: &Project,
379    reports: Vec<(&Report, Vec<&Binding>)>,
380    schedules: BTreeMap<String, Option<ManifestSchedule>>,
381    errors: &ReportErrors,
382) -> Json {
383    let reports = reports
384        .into_iter()
385        .map(|(r, bs)| (r.name.clone(), report(project, r, &bs, errors.get(&r.name))))
386        .collect();
387    let plugins = project
388        .plugins
389        .iter()
390        .map(|p| {
391            let (kind, location) = match &p.source {
392                PluginSource::Default => ("registry", None),
393                PluginSource::Registry(u) => ("registry", Some(u.clone())),
394                PluginSource::Github(r) => ("github", Some(r.clone())),
395                PluginSource::Local(l) => ("local", Some(l.clone())),
396            };
397            ManifestPlugin {
398                package: p.name.clone(),
399                version: p.version.clone(),
400                source: PluginSourceRecord { kind, location },
401            }
402        })
403        .collect();
404    let doc = Manifest {
405        schema_version: SCHEMA_VERSION,
406        version: crate::version().to_string(),
407        project: ManifestProject {
408            name: project.name.clone(),
409            target: project.target_name.clone(),
410            default_profile: project.default_profile.clone(),
411            timezone: project.timezone.clone(),
412            checksum: project_checksum(project),
413        },
414        reports,
415        schedules,
416        sources: sources(project),
417        plugins,
418        extensions: crate::packages::declared_extensions(&project.root).unwrap_or_default(),
419    };
420    sorted(serde_json::to_value(doc).expect("a manifest serializes"))
421}
422
423fn report(
424    project: &Project,
425    r: &Report,
426    bindings: &[&Binding],
427    errors: Option<&Vec<String>>,
428) -> ManifestReport {
429    // Every source any Binding reads, as `depends_on` says it in dbt.
430    let mut used: Vec<String> = Vec::new();
431    for p in r.bindings.iter().filter_map(|b| b.parsed.as_ref()) {
432        for k in p.source_keys() {
433            if !used.iter().any(|u| u == k) {
434                used.push(k.to_string());
435            }
436        }
437    }
438    let errors = errors.cloned().unwrap_or_default();
439    ManifestReport {
440        name: r.name.clone(),
441        description: r.description.clone(),
442        meta: r.meta.clone(),
443        meta_layers: r.meta_layers.clone(),
444        var_schema: r.var_schema.clone(),
445        managed: r.managed,
446        file: slash(&r.file),
447        folder: r.folder.clone(),
448        tags: r.tags.clone(),
449        timezone: r.timezone.clone(),
450        default_set: r.default_set.clone(),
451        queries: r.queries.iter().map(|q| query(q, None)).collect(),
452        depends_on: DependsOn { sources: used },
453        checksum: report_checksum(project, r),
454        valid: errors.is_empty(),
455        errors: (!errors.is_empty()).then_some(errors),
456        bindings: bindings.iter().map(|b| binding(b)).collect(),
457    }
458}
459
460/// A query entry; with the parse pass's result (`parsed`), also its connection and sources.
461fn query(q: &QueryEntry, parsed: Option<&crate::parse::ParsedQuery>) -> ManifestQuery {
462    ManifestQuery {
463        query: q.query.clone(),
464        file: slash(&q.path),
465        profile: q.profile.clone(),
466        connection: parsed.map(|p| p.connection.clone()),
467        depends_on: parsed.map(|p| DependsOn {
468            sources: p.sources.clone(),
469        }),
470        tab: q.tab,
471        tab_name: q.tab_name.clone(),
472        anchor: q.anchor.clone(),
473        header: q.header,
474        columns: (!q.columns.is_empty()).then(|| serde_json::to_value(&q.columns).unwrap_or(Json::Null)),
475    }
476}
477
478fn binding(b: &Binding) -> ManifestBinding {
479    let parsed = b.parsed.as_deref();
480    // Destinations are numbered across every output, as the parse pass renders them.
481    let mut next = 0;
482    let outputs: Vec<ManifestOutput> = b
483        .outputs
484        .iter()
485        .map(|o| {
486            let destinations = o
487                .destinations
488                .iter()
489                .map(|d| {
490                    let rendered = parsed.and_then(|p| p.destinations.get(next).cloned().flatten());
491                    next += 1;
492                    ManifestDestination {
493                        profile: rendered.unwrap_or_else(|| d.profile.clone()),
494                        path: d.path.clone(),
495                        attach: (!d.attach.is_empty()).then(|| d.attach.clone()),
496                    }
497                })
498                .collect();
499            ManifestOutput {
500                name: o.name.clone(),
501                format: o.format.clone(),
502                queries: o.queries.clone(),
503                when: o.when.clone(),
504                options: o.options.clone(),
505                extension: o.extension.clone(),
506                template: o.template.as_ref().map(|t| t.file.clone()),
507                destinations: Some(destinations),
508            }
509        })
510        .collect();
511    let output = outputs
512        .first()
513        .map(|o| ManifestOutput {
514            destinations: None,
515            ..o.clone()
516        })
517        .unwrap_or_default();
518    let destinations = outputs
519        .iter()
520        .flat_map(|o| o.destinations.clone().unwrap_or_default())
521        .collect();
522    ManifestBinding {
523        set: b.set.clone(),
524        profile: parsed.map_or(b.profile.clone(), |p| p.inherited.clone()),
525        vars: b.vars.clone(),
526        queries: b
527            .queries
528            .iter()
529            .map(|q| query(q, parsed.and_then(|p| p.query(&q.query))))
530            .collect(),
531        output,
532        destinations,
533        outputs,
534        schedules: b.schedules.clone(),
535    }
536}
537
538/// Every declared source, with its fields rendered for the run's inputs (project vars, `--var`,
539/// the target, and `run.*` with no report: the run date, but a fixed `run.now` unless
540/// `DRE_RUN_AT` sets it, so the bytes don't change by the second). A field that doesn't render
541/// is left as written, with the problem under `errors`.
542fn sources(project: &Project) -> BTreeMap<String, ManifestSource> {
543    if project.sources.is_empty() {
544        return BTreeMap::new();
545    }
546    let inputs = &project.inputs;
547    let calendar = crate::dates::Calendar {
548        tz: inputs
549            .timezone
550            .as_deref()
551            .or(project.timezone.as_deref())
552            .and_then(|t| crate::dates::parse_tz(t).ok())
553            .unwrap_or(chrono_tz::Tz::UTC),
554        week_start: project.week_start,
555        numbering: project.week_numbering,
556    };
557    let context = crate::render::RunContext {
558        report: String::new(),
559        set: None,
560        target: project.target_name.clone(),
561        schedule: None,
562        date: crate::parse::run_date(&calendar, inputs.date, inputs.scheduled_at),
563        now: inputs
564            .scheduled_at
565            .unwrap_or(chrono::DateTime::<chrono::Utc>::UNIX_EPOCH),
566        scheduled_at: inputs.scheduled_at,
567        calendar,
568        locale: Default::default(),
569    };
570    let limited = crate::render::Limited::new(context, project.vars.clone(), inputs.cli_vars.clone());
571    let mut used: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
572    for r in &project.reports {
573        for p in r.bindings.iter().filter_map(|b| b.parsed.as_ref()) {
574            for k in p.source_keys() {
575                used.entry(k.to_string()).or_default().insert(r.name.clone());
576            }
577        }
578    }
579    let mut out = BTreeMap::new();
580    for (name, s) in &project.sources {
581        let mut errors: Vec<String> = Vec::new();
582        let mut render = |key: &str, v: &str| match limited.render(&format!("`{key}`"), v) {
583            Ok(r) => r,
584            Err(e) => {
585                errors.push(e);
586                v.to_string()
587            }
588        };
589        let profile = s.profile.as_ref().map(|p| render("profile", p));
590        let database = s.database.as_ref().map(|d| render("database", d));
591        let schema = render("schema", s.schema.as_deref().unwrap_or(name));
592        let tables = s
593            .tables
594            .iter()
595            .map(|t| {
596                let key = format!("{name}.{}", t.name);
597                let table = ManifestTable {
598                    name: t.name.clone(),
599                    identifier: render("identifier", t.identifier.as_deref().unwrap_or(&t.name)),
600                    quoting: t.quoting.over(&s.quoting),
601                    description: t.description.clone(),
602                    tags: t.tags.clone(),
603                    meta: t.meta.clone(),
604                    columns: t.columns.clone(),
605                    used_by: used.get(&key).cloned().unwrap_or_default(),
606                };
607                (t.name.clone(), table)
608            })
609            .collect();
610        out.insert(
611            name.clone(),
612            ManifestSource {
613                name: name.clone(),
614                file: slash(&s.file),
615                profile,
616                database,
617                schema,
618                description: s.description.clone(),
619                tags: s.tags.clone(),
620                meta: s.meta.clone(),
621                tables,
622                errors: (!errors.is_empty()).then_some(errors),
623            },
624        );
625    }
626    out
627}
628
629fn schedule(project: &Project, name: &str) -> Option<ManifestSchedule> {
630    let e = project.schedules.iter().find(|e| e.name == name)?;
631    let bindings = project
632        .reports
633        .iter()
634        .flat_map(|r| {
635            r.bindings
636                .iter()
637                .filter(|b| b.schedules.iter().any(|s| s == name))
638                .map(|b| ScheduledBinding {
639                    report: r.name.clone(),
640                    set: b.set.clone(),
641                })
642        })
643        .collect();
644    Some(ManifestSchedule {
645        name: e.name.clone(),
646        report: e.report.clone(),
647        select: e.select.clone(),
648        set: e.set.clone(),
649        schedule: e.schedule.clone(),
650        timing: e.timing.clone(),
651        enabled: e.enabled,
652        vars: e.vars.clone(),
653        timezone: e.timezone.clone(),
654        bindings,
655    })
656}
657
658fn slash(p: &Path) -> String {
659    crate::slash(p).to_string_lossy().into_owned()
660}
661
662/// `v` as compact JSON with every object's keys sorted: the same bytes for the same value.
663pub fn canonical(v: &Json) -> String {
664    serde_json::to_string(&sorted(v.clone())).unwrap()
665}
666
667/// `v` with every object's keys sorted, so the bytes don't depend on insertion order.
668fn sorted(v: Json) -> Json {
669    match v {
670        Json::Object(m) => {
671            let mut entries: Vec<(String, Json)> = m.into_iter().collect();
672            entries.sort_by(|a, b| a.0.cmp(&b.0));
673            Json::Object(entries.into_iter().map(|(k, v)| (k, sorted(v))).collect())
674        }
675        Json::Array(a) => Json::Array(a.into_iter().map(sorted).collect()),
676        v => v,
677    }
678}
679
680// -- checksums --------------------------------------------------------------------------------
681
682/// SHA-256 (hex) over `files` (relative to the root), in sorted order, each as its path and
683/// contents with a fixed separator; a missing file hashes as its path alone.
684fn checksum(root: &Path, files: &BTreeSet<String>) -> String {
685    let mut h = Sha256::new();
686    for f in files {
687        h.update(f.as_bytes());
688        h.update([0u8]);
689        match std::fs::read(root.join(f)) {
690            Ok(bytes) => {
691                h.update([1u8]);
692                h.update((bytes.len() as u64).to_le_bytes());
693                h.update(&bytes);
694            }
695            Err(_) => h.update([0u8]),
696        }
697    }
698    hex(&h.finalize())
699}
700
701pub fn hex(bytes: &[u8]) -> String {
702    bytes.iter().map(|b| format!("{b:02x}")).collect()
703}
704
705/// A report's own files: its defining file, every query file of any Binding, and templates.
706fn report_files(project: &Project, r: &Report) -> BTreeSet<String> {
707    let mut files = BTreeSet::new();
708    files.insert(slash(&r.file));
709    for q in r
710        .queries
711        .iter()
712        .chain(r.bindings.iter().flat_map(|b| b.queries.iter()))
713    {
714        files.insert(slash(&q.path));
715    }
716    for o in r.bindings.iter().flat_map(|b| b.outputs.iter()) {
717        if let Some(t) = &o.template {
718            files.insert(template_file(&project.root, &t.file));
719        }
720        // A message's text template counts like an xlsx template.
721        if let Some(f) = o
722            .options
723            .get("file")
724            .and_then(Json::as_str)
725            .filter(|_| o.is_message())
726        {
727            files.insert(template_file(&project.root, f));
728        }
729    }
730    files
731}
732
733/// Where a template file is read from (the run's rule): the root, else `templates/`.
734fn template_file(root: &Path, file: &str) -> String {
735    let direct = Path::new(file);
736    let under = Path::new("templates").join(file);
737    if !root.join(direct).is_file() && root.join(&under).is_file() {
738        slash(&under)
739    } else {
740        slash(direct)
741    }
742}
743
744fn report_checksum(project: &Project, r: &Report) -> String {
745    checksum(&project.root, &report_files(project, r))
746}
747
748/// The shared inputs: `dre_project.yml`, folder config, `schedules.yml`, `dependencies.yml` and
749/// other YAML that isn't a report's own, `macros/`, `lookups/`, and every `.sql` under
750/// `reports/` that isn't a declared query (the usual `ref()` targets).
751fn project_checksum(project: &Project) -> String {
752    // An unmanaged report's `.sql` stays in: it's a `ref()` target like any other.
753    let owned: BTreeSet<String> = project
754        .reports
755        .iter()
756        .flat_map(|r| {
757            let mut files = report_files(project, r);
758            if !r.managed {
759                files.remove(&slash(&r.file));
760            }
761            files
762        })
763        .collect();
764    let files: BTreeSet<String> = project
765        .files
766        .iter()
767        .map(|p| slash(p))
768        .filter(|p| !owned.contains(p))
769        .filter(|p| {
770            let p = Path::new(p);
771            let yaml = matches!(p.extension().and_then(|e| e.to_str()), Some("yml" | "yaml"));
772            yaml || p.starts_with(MACROS_DIR)
773                || p.starts_with(LOOKUPS_DIR)
774                || (p.starts_with(REPORTS_DIR) && p.extension().is_some_and(|e| e == "sql"))
775        })
776        .collect();
777    checksum(&project.root, &files)
778}
779
780// -- errors -----------------------------------------------------------------------------------
781
782/// The error diagnostics that belong to a report: those in one of its own files.
783pub fn report_errors(project: &Project, diags: &Diagnostics) -> ReportErrors {
784    let mut out = ReportErrors::new();
785    for r in &project.reports {
786        let files = report_files(project, r);
787        let msgs: Vec<String> = diags
788            .sorted()
789            .into_iter()
790            .filter(|d| d.severity == Severity::Error)
791            .filter(|d| d.file.as_ref().is_some_and(|f| files.contains(&slash(f))))
792            .map(|d| d.to_string())
793            .chain(
794                project
795                    .parse_errors
796                    .get(&r.name)
797                    .into_iter()
798                    .flatten()
799                    .map(|d| d.to_string()),
800            )
801            .collect();
802        if !msgs.is_empty() {
803            out.insert(r.name.clone(), msgs);
804        }
805    }
806    out
807}
808
809// -- writing ----------------------------------------------------------------------------------
810
811/// The manifest's text: pretty JSON, secrets masked, newline-terminated.
812pub fn render(doc: &Json) -> String {
813    crate::secrets::to_json_pretty(doc).unwrap() + "\n"
814}
815
816/// Write `project`'s manifest atomically ([`crate::fs::write_atomic`]). Returns the SHA-256 of
817/// the bytes written.
818pub fn write(project: &Project, errors: &ReportErrors) -> Result<String, String> {
819    let text = render(&build(project, errors));
820    let dir = &project.target_dir;
821    crate::target::ensure(dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?;
822    let dst = dir.join(FILE);
823    crate::fs::write_atomic(&dst, text.as_bytes())
824        .map_err(|e| format!("can't write {}: {e}", dst.display()))?;
825    Ok(hex(&Sha256::digest(text.as_bytes())))
826}
827
828/// Remove a stale manifest from `target_dir` (the project didn't load).
829pub fn remove(target_dir: &Path) {
830    let _ = std::fs::remove_file(target_dir.join(FILE));
831}