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}
97
98#[derive(Serialize, JsonSchema)]
99pub struct ManifestProject {
100    pub name: String,
101    /// 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).
102    pub target: String,
103    /// The default connection's name, as written (it may hold Jinja).
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub default_profile: Option<String>,
106    /// The project's `timezone:` (IANA name).
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub timezone: Option<String>,
109    /// 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.
110    #[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    /// False for a bare .sql under reports/ (an unmanaged report).
119    pub managed: bool,
120    /// The defining YAML, or the .sql of an unmanaged report.
121    #[schemars(schema_with = "path_schema")]
122    pub file: String,
123    /// Folder segments under reports/.
124    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    /// Every source any of the report's Bindings reads, `source.table`.
132    pub depends_on: DependsOn,
133    /// Over the defining file, every query file and any template.
134    #[schemars(schema_with = "sha256")]
135    pub checksum: String,
136    pub valid: bool,
137    /// Present when invalid: what's wrong.
138    #[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    /// `source.table` of every `source()` used, in order of first use.
146    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    /// The query's own `profile:`, as written.
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub profile: Option<String>,
158    /// 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.
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub connection: Option<Option<String>>,
161    /// In a Binding: what the query reads.
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub depends_on: Option<DependsOn>,
164    /// Whether its result becomes a tab (false: run for its effects only).
165    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    /// Per result column, how to show it (xlsx).
173    #[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    /// Null for a report without Sets.
181    pub set: Option<String>,
182    /// The inherited connection (Set, report, folder `+profile`, `default_profile`), rendered. A query's own connection is on the query.
183    pub profile: Option<String>,
184    /// Fully merged: project < folders < report < Set.
185    pub vars: Map,
186    pub queries: Vec<ManifestQuery>,
187    /// The first output; every output is under `outputs`.
188    pub output: ManifestOutput,
189    /// Every output's destinations, in delivery order.
190    pub destinations: Vec<ManifestDestination>,
191    /// Every output, in declared order.
192    pub outputs: Vec<ManifestOutput>,
193    /// The schedules that run this Binding.
194    pub schedules: Vec<String>,
195}
196
197#[derive(Serialize, JsonSchema, Clone, Default)]
198pub struct ManifestOutput {
199    /// The output's `name:`.
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub name: Option<String>,
202    pub format: String,
203    /// The queries it formats; absent means all of them.
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub queries: Option<Vec<String>>,
206    /// The `when:` condition, unrendered.
207    #[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    /// The template file, as declared.
213    #[serde(skip_serializing_if = "Option::is_none")]
214    #[schemars(schema_with = "path_schema")]
215    pub template: Option<String>,
216    /// In delivery order. (Absent on a Binding's `output`.)
217    #[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    /// The destination profile, rendered.
225    pub profile: String,
226    /// The path template, unrendered.
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub path: Option<String>,
229    /// A message output's entry: the outputs whose files go with the message.
230    #[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    /// The resolved timing: `cron`, `every` or `rrule`, with `starting`, `at`, `except` and `also` when given. With `timing`, the shared timing's fields.
245    pub schedule: Map,
246    /// The timings.yml entry the timing comes from, when it's a shared one.
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub timing: Option<String>,
249    /// False when the schedule is paused (`enabled: false`).
250    pub enabled: bool,
251    pub vars: Map,
252    /// The schedule's `timezone:`, or its shared timing's.
253    #[serde(skip_serializing_if = "Option::is_none")]
254    pub timezone: Option<String>,
255    /// What `dre run --schedule <name>` runs.
256    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    /// The YAML file declaring it.
270    #[schemars(schema_with = "path_schema")]
271    pub file: String,
272    /// The connection it lives on, rendered. Absent: it runs wherever its query runs.
273    #[serde(skip_serializing_if = "Option::is_none")]
274    pub profile: Option<String>,
275    /// Rendered. Absent: `source()` renders two parts.
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub database: Option<String>,
278    /// Rendered; the source's name unless set.
279    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    /// By table name.
285    pub tables: BTreeMap<String, ManifestTable>,
286    /// Fields that didn't render (left as written).
287    #[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    /// The real table name, rendered; the table's name unless set.
295    pub identifier: String,
296    /// Which parts `source()` quotes, the table's over the source's.
297    #[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    /// The reports that read it. Empty: unused.
306    pub used_by: BTreeSet<String>,
307}
308
309#[derive(Serialize, JsonSchema)]
310pub struct ManifestPlugin {
311    pub package: String,
312    /// The version requirement (`*` for any).
313    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    /// Another registry's URL or path, a GitHub `owner/repo`, or a local executable's path. Absent for DRE's default registry.
323    #[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
362// -- building it --------------------------------------------------------------------------------
363
364fn 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    // Every source any Binding reads, as `depends_on` says it in dbt.
416    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
442/// A query entry; with the parse pass's result (`parsed`), also its connection and sources.
443fn 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    // Destinations are numbered across every output, as the parse pass renders them.
463    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
520/// Every declared source, with its fields rendered for the run's inputs (project vars, `--var`,
521/// the target, and `run.*` with no report: the run date, but a fixed `run.now` unless
522/// `DRE_RUN_AT` sets it, so the bytes don't change by the second). A field that doesn't render
523/// is left as written, with the problem under `errors`.
524fn 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
644/// `v` as compact JSON with every object's keys sorted: the same bytes for the same value.
645pub fn canonical(v: &Json) -> String {
646    serde_json::to_string(&sorted(v.clone())).unwrap()
647}
648
649/// `v` with every object's keys sorted, so the bytes don't depend on insertion order.
650fn 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
662// -- checksums --------------------------------------------------------------------------------
663
664/// SHA-256 (hex) over `files` (relative to the root), in sorted order, each as its path and
665/// contents with a fixed separator; a missing file hashes as its path alone.
666fn 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
687/// A report's own files: its defining file, every query file of any Binding, and templates.
688fn 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        // A message's text template counts like an xlsx template.
703        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
715/// Where a template file is read from (the run's rule): the root, else `templates/`.
716fn 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
730/// The shared inputs: `dre_project.yml`, folder config, `schedules.yml`, `dependencies.yml` and
731/// other YAML that isn't a report's own, `macros/`, `lookups/`, and every `.sql` under
732/// `reports/` that isn't a declared query (the usual `ref()` targets).
733fn project_checksum(project: &Project) -> String {
734    // An unmanaged report's `.sql` stays in: it's a `ref()` target like any other.
735    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
762// -- errors -----------------------------------------------------------------------------------
763
764/// The error diagnostics that belong to a report: those in one of its own files.
765pub 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
791// -- writing ----------------------------------------------------------------------------------
792
793/// The manifest's text: pretty JSON, secrets masked, newline-terminated.
794pub fn render(doc: &Json) -> String {
795    crate::secrets::to_json_pretty(doc).unwrap() + "\n"
796}
797
798/// Write `project`'s manifest atomically ([`crate::fs::write_atomic`]). Returns the SHA-256 of
799/// the bytes written.
800pub 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
810/// Remove a stale manifest from `target_dir` (the project didn't load).
811pub fn remove(target_dir: &Path) {
812    let _ = std::fs::remove_file(target_dir.join(FILE));
813}