1use std::collections::{BTreeMap, BTreeSet};
16use std::path::{Path, PathBuf};
17
18use schemars::JsonSchema;
19use serde::Serialize;
20use serde_json::{Map as JsonMap, Value as Json};
21use sha2::{Digest, Sha256};
22
23use crate::diag::{Diagnostics, Severity};
24use crate::lookups::LOOKUPS_DIR;
25use crate::project::{Binding, MACROS_DIR, PluginSource, Project, QueryEntry, REPORTS_DIR, Report};
26
27pub const SCHEMA_VERSION: &str = "dre/manifest/v3";
29pub const FILE: &str = "manifest.json";
31
32pub type ReportErrors = BTreeMap<String, Vec<String>>;
34
35pub fn path(project: &Project) -> PathBuf {
37 project.target_dir.join(FILE)
38}
39
40pub fn build(project: &Project, errors: &ReportErrors) -> Json {
42 let schedules = project
43 .schedules
44 .iter()
45 .map(|e| (e.name.clone(), schedule(project, &e.name)))
46 .collect();
47 document(
48 project,
49 project
50 .reports
51 .iter()
52 .map(|r| (r, r.bindings.iter().collect()))
53 .collect(),
54 schedules,
55 errors,
56 )
57}
58
59pub fn subset(
62 project: &Project,
63 reports: Vec<(&Report, Vec<&Binding>)>,
64 schedule_names: &[String],
65 errors: &ReportErrors,
66) -> Json {
67 let schedules = schedule_names
68 .iter()
69 .map(|n| (n.clone(), schedule(project, n)))
70 .collect();
71 document(project, reports, schedules, errors)
72}
73
74type Map = JsonMap<String, Json>;
77
78#[derive(Serialize, JsonSchema)]
80#[schemars(title = "DRE project manifest")]
81pub struct Manifest {
82 #[schemars(schema_with = "manifest_version")]
84 pub schema_version: &'static str,
85 pub version: String,
87 pub project: ManifestProject,
88 pub reports: BTreeMap<String, ManifestReport>,
90 pub schedules: BTreeMap<String, Option<ManifestSchedule>>,
92 pub sources: BTreeMap<String, ManifestSource>,
94 pub plugins: Vec<ManifestPlugin>,
96}
97
98#[derive(Serialize, JsonSchema)]
99pub struct ManifestProject {
100 pub name: String,
101 pub target: String,
103 #[serde(skip_serializing_if = "Option::is_none")]
105 pub default_profile: Option<String>,
106 #[serde(skip_serializing_if = "Option::is_none")]
108 pub timezone: Option<String>,
109 #[schemars(schema_with = "sha256")]
111 pub checksum: String,
112}
113
114#[derive(Serialize, JsonSchema)]
115#[schemars(rename = "report")]
116pub struct ManifestReport {
117 pub name: String,
118 #[serde(skip_serializing_if = "Option::is_none")]
119 pub description: Option<String>,
120 #[serde(skip_serializing_if = "Map::is_empty")]
122 pub meta: Map,
123 #[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 pub managed: bool,
130 #[schemars(schema_with = "path_schema")]
132 pub file: String,
133 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 pub depends_on: DependsOn,
143 #[schemars(schema_with = "sha256")]
145 pub checksum: String,
146 pub valid: bool,
147 #[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 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 #[serde(skip_serializing_if = "Option::is_none")]
167 pub profile: Option<String>,
168 #[serde(skip_serializing_if = "Option::is_none")]
170 pub connection: Option<Option<String>>,
171 #[serde(skip_serializing_if = "Option::is_none")]
173 pub depends_on: Option<DependsOn>,
174 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 #[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 pub set: Option<String>,
192 pub profile: Option<String>,
194 pub vars: Map,
196 pub queries: Vec<ManifestQuery>,
197 pub output: ManifestOutput,
199 pub destinations: Vec<ManifestDestination>,
201 pub outputs: Vec<ManifestOutput>,
203 pub schedules: Vec<String>,
205}
206
207#[derive(Serialize, JsonSchema, Clone, Default)]
208pub struct ManifestOutput {
209 #[serde(skip_serializing_if = "Option::is_none")]
211 pub name: Option<String>,
212 pub format: String,
213 #[serde(skip_serializing_if = "Option::is_none")]
215 pub queries: Option<Vec<String>>,
216 #[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 #[serde(skip_serializing_if = "Option::is_none")]
224 #[schemars(schema_with = "path_schema")]
225 pub template: Option<String>,
226 #[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 pub profile: String,
236 #[serde(skip_serializing_if = "Option::is_none")]
238 pub path: Option<String>,
239 #[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 pub schedule: Map,
256 #[serde(skip_serializing_if = "Option::is_none")]
258 pub timing: Option<String>,
259 pub enabled: bool,
261 pub vars: Map,
262 #[serde(skip_serializing_if = "Option::is_none")]
264 pub timezone: Option<String>,
265 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 #[schemars(schema_with = "path_schema")]
281 pub file: String,
282 #[serde(skip_serializing_if = "Option::is_none")]
284 pub profile: Option<String>,
285 #[serde(skip_serializing_if = "Option::is_none")]
287 pub database: Option<String>,
288 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 pub tables: BTreeMap<String, ManifestTable>,
296 #[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 pub identifier: String,
306 #[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 pub used_by: BTreeSet<String>,
317}
318
319#[derive(Serialize, JsonSchema)]
320pub struct ManifestPlugin {
321 pub package: String,
322 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 #[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
372fn 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 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
456fn 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 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
534fn 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
658pub fn canonical(v: &Json) -> String {
660 serde_json::to_string(&sorted(v.clone())).unwrap()
661}
662
663fn 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
676fn 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
701fn 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 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
729fn 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
744fn project_checksum(project: &Project) -> String {
748 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
776pub 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
805pub fn render(doc: &Json) -> String {
809 crate::secrets::to_json_pretty(doc).unwrap() + "\n"
810}
811
812pub 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
824pub fn remove(target_dir: &Path) {
826 let _ = std::fs::remove_file(target_dir.join(FILE));
827}