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