1use std::collections::{BTreeMap, BTreeSet};
16use std::path::{Path, PathBuf};
17
18use schemars::JsonSchema;
19use serde::Serialize;
20use serde_json::{Map as JsonMap, Value as Json};
21use sha2::{Digest, Sha256};
22
23use crate::diag::{Diagnostics, Severity};
24use crate::lookups::LOOKUPS_DIR;
25use crate::project::{Binding, MACROS_DIR, PluginSource, Project, QueryEntry, REPORTS_DIR, Report};
26
27pub const SCHEMA_VERSION: &str = "dre/manifest/v3";
29pub const FILE: &str = "manifest.json";
31
32pub type ReportErrors = BTreeMap<String, Vec<String>>;
34
35pub fn path(project: &Project) -> PathBuf {
37 project.target_dir.join(FILE)
38}
39
40pub fn build(project: &Project, errors: &ReportErrors) -> Json {
42 let schedules = project
43 .schedules
44 .iter()
45 .map(|e| (e.name.clone(), schedule(project, &e.name)))
46 .collect();
47 document(
48 project,
49 project
50 .reports
51 .iter()
52 .map(|r| (r, r.bindings.iter().collect()))
53 .collect(),
54 schedules,
55 errors,
56 )
57}
58
59pub fn subset(
62 project: &Project,
63 reports: Vec<(&Report, Vec<&Binding>)>,
64 schedule_names: &[String],
65 errors: &ReportErrors,
66) -> Json {
67 let schedules = schedule_names
68 .iter()
69 .map(|n| (n.clone(), schedule(project, n)))
70 .collect();
71 document(project, reports, schedules, errors)
72}
73
74type Map = JsonMap<String, Json>;
77
78#[derive(Serialize, JsonSchema)]
80#[schemars(title = "DRE project manifest")]
81pub struct Manifest {
82 #[schemars(schema_with = "manifest_version")]
84 pub schema_version: &'static str,
85 pub version: String,
87 pub project: ManifestProject,
88 pub reports: BTreeMap<String, ManifestReport>,
90 pub schedules: BTreeMap<String, Option<ManifestSchedule>>,
92 pub sources: BTreeMap<String, ManifestSource>,
94 pub plugins: Vec<ManifestPlugin>,
96 #[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 pub target: String,
106 #[serde(skip_serializing_if = "Option::is_none")]
108 pub default_profile: Option<String>,
109 #[serde(skip_serializing_if = "Option::is_none")]
111 pub timezone: Option<String>,
112 #[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 #[serde(skip_serializing_if = "Map::is_empty")]
125 pub meta: Map,
126 #[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 pub managed: bool,
133 #[schemars(schema_with = "path_schema")]
135 pub file: String,
136 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 pub depends_on: DependsOn,
146 #[schemars(schema_with = "sha256")]
148 pub checksum: String,
149 pub valid: bool,
150 #[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 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 #[serde(skip_serializing_if = "Option::is_none")]
170 pub profile: Option<String>,
171 #[serde(skip_serializing_if = "Option::is_none")]
173 pub connection: Option<Option<String>>,
174 #[serde(skip_serializing_if = "Option::is_none")]
176 pub depends_on: Option<DependsOn>,
177 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 #[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 pub set: Option<String>,
195 pub profile: Option<String>,
197 pub vars: Map,
199 pub queries: Vec<ManifestQuery>,
200 pub output: ManifestOutput,
202 pub destinations: Vec<ManifestDestination>,
204 pub outputs: Vec<ManifestOutput>,
206 pub schedules: Vec<String>,
208}
209
210#[derive(Serialize, JsonSchema, Clone, Default)]
211pub struct ManifestOutput {
212 #[serde(skip_serializing_if = "Option::is_none")]
214 pub name: Option<String>,
215 pub format: String,
216 #[serde(skip_serializing_if = "Option::is_none")]
218 pub queries: Option<Vec<String>>,
219 #[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 #[serde(skip_serializing_if = "Option::is_none")]
227 #[schemars(schema_with = "path_schema")]
228 pub template: Option<String>,
229 #[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 pub profile: String,
239 #[serde(skip_serializing_if = "Option::is_none")]
241 pub path: Option<String>,
242 #[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 pub schedule: Map,
259 #[serde(skip_serializing_if = "Option::is_none")]
261 pub timing: Option<String>,
262 pub enabled: bool,
264 pub vars: Map,
265 #[serde(skip_serializing_if = "Option::is_none")]
267 pub timezone: Option<String>,
268 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 #[schemars(schema_with = "path_schema")]
284 pub file: String,
285 #[serde(skip_serializing_if = "Option::is_none")]
287 pub profile: Option<String>,
288 #[serde(skip_serializing_if = "Option::is_none")]
290 pub database: Option<String>,
291 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 pub tables: BTreeMap<String, ManifestTable>,
299 #[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 pub identifier: String,
309 #[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 pub used_by: BTreeSet<String>,
320}
321
322#[derive(Serialize, JsonSchema)]
323pub struct ManifestPlugin {
324 pub package: String,
325 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 #[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
375fn 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 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
460fn 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 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
538fn 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
662pub fn canonical(v: &Json) -> String {
664 serde_json::to_string(&sorted(v.clone())).unwrap()
665}
666
667fn 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
680fn 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
705fn 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 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
733fn 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
748fn project_checksum(project: &Project) -> String {
752 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
780pub 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
809pub fn render(doc: &Json) -> String {
813 crate::secrets::to_json_pretty(doc).unwrap() + "\n"
814}
815
816pub 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
828pub fn remove(target_dir: &Path) {
830 let _ = std::fs::remove_file(target_dir.join(FILE));
831}