1mod build;
2mod config;
3mod diff;
4mod emit;
5mod load;
6mod resolve;
7mod validate;
8
9use std::collections::BTreeMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use anyhow::{Context, Result, bail};
14use clap::{Args, Parser, Subcommand, ValueEnum};
15use serde::Serialize;
16use sha2::{Digest, Sha256};
17
18use crate::build::{BuildOptions, BuildReport};
19use crate::config::Project;
20use crate::diff::ContractDiff;
21use crate::load::load_contract;
22
23const LOCK_VERSION: u32 = 2;
24
25fn atomic_sibling(path: &Path, suffix: &str) -> Result<PathBuf> {
26 let parent = path
27 .parent()
28 .with_context(|| format!("{} has no parent directory", path.display()))?;
29 let name = path
30 .file_name()
31 .and_then(|name| name.to_str())
32 .with_context(|| format!("{} has no valid UTF-8 file name", path.display()))?;
33 let hidden_prefix = if name.starts_with('.') { "" } else { "." };
34 Ok(parent.join(format!("{hidden_prefix}{name}.{suffix}")))
35}
36
37#[derive(Debug, Parser)]
38#[command(
39 name = "rspyts",
40 version,
41 about = "Compile one Rust API for Python and TypeScript"
42)]
43pub struct Cli {
44 #[command(subcommand)]
45 command: Command,
46}
47
48#[derive(Debug, Subcommand)]
49enum Command {
50 Build(BuildArgs),
52 Check(CheckArgs),
54 Lock(ProjectArgs),
56 Inspect(ProjectArgs),
58 Clean(ProjectArgs),
60}
61
62#[derive(Debug, Args)]
63struct ProjectArgs {
64 #[arg(long, default_value = "rspyts.toml")]
66 config: PathBuf,
67}
68
69#[derive(Debug, Args)]
70struct BuildArgs {
71 #[command(flatten)]
72 project: ProjectArgs,
73 #[arg(long)]
75 staging: Option<PathBuf>,
76 #[arg(long, value_enum, default_value_t = BuildTarget::All)]
78 target: BuildTarget,
79}
80
81#[derive(Debug, Args)]
82struct CheckArgs {
83 #[command(flatten)]
84 project: ProjectArgs,
85 #[arg(long)]
87 locked: bool,
88 #[arg(long, value_enum, default_value_t = BuildTarget::All)]
90 target: BuildTarget,
91}
92
93#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]
94pub(crate) enum BuildTarget {
95 Python,
96 Typescript,
97 #[default]
98 All,
99}
100
101impl BuildTarget {
102 pub(crate) fn includes_python(self) -> bool {
103 matches!(self, Self::Python | Self::All)
104 }
105
106 pub(crate) fn includes_typescript(self) -> bool {
107 matches!(self, Self::Typescript | Self::All)
108 }
109}
110
111#[derive(Debug, Serialize)]
112#[serde(rename_all = "camelCase")]
113struct Inspection<'a> {
114 schema_version: u32,
115 fingerprint: String,
116 manifest: &'a rspyts::ir::Manifest,
117 dependencies: &'a BTreeMap<String, LockedDependency>,
118 hosts: &'a LockedHosts,
119}
120
121#[derive(Debug, Serialize, serde::Deserialize)]
122#[serde(rename_all = "camelCase", deny_unknown_fields)]
123pub(crate) struct ContractLock {
124 schema_version: u32,
125 fingerprint: String,
126 hosts: LockedHosts,
127 dependencies: BTreeMap<String, LockedDependency>,
128 manifest: rspyts::ir::Manifest,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
132#[serde(rename_all = "camelCase", deny_unknown_fields)]
133pub(crate) struct LockedDependency {
134 #[serde(rename = "crate")]
135 pub owner: rspyts::ir::CargoPackageId,
136 pub fingerprint: String,
137 pub python: Option<String>,
138 pub typescript: Option<String>,
139 pub types: Vec<rspyts::ir::TypeDef>,
140 pub errors: Vec<rspyts::ir::ErrorDef>,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
144#[serde(rename_all = "camelCase", deny_unknown_fields)]
145pub(crate) struct LockedHosts {
146 pub python: Option<String>,
147 pub typescript: Option<LockedTypeScriptHost>,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
151#[serde(rename_all = "camelCase", deny_unknown_fields)]
152pub(crate) struct LockedTypeScriptHost {
153 pub package: String,
154 pub mode: crate::config::TypeScriptMode,
155}
156
157pub fn run() -> Result<()> {
158 run_from(Cli::parse())
159}
160
161fn run_from(cli: Cli) -> Result<()> {
162 match cli.command {
163 Command::Build(args) => {
164 let project = Project::read(&args.project.config)?;
165 let report = build::build(
166 &project,
167 BuildOptions {
168 staging: args.staging,
169 target: args.target,
170 },
171 )?;
172 print_json(&report)
173 }
174 Command::Check(args) => {
175 let project = Project::read(&args.project.config)?;
176 let report = build::build(
177 &project,
178 BuildOptions {
179 staging: None,
180 target: args.target,
181 },
182 )?;
183 if args.locked {
184 check_lock(&project, &report)?;
185 }
186 print_json(&report)
187 }
188 Command::Lock(args) => {
189 let project = Project::read(&args.config)?;
190 let loaded = load_contract(&project)?;
191 validate::manifest(&loaded.manifest)?;
192 let resolved = resolve::contract(&project, loaded.manifest)?;
193 let lock = create_lock(resolved)?;
194 write_atomic_file(&project.lock_path(), &pretty_json_line(&lock)?)?;
195 print_json(&lock)
196 }
197 Command::Inspect(args) => {
198 let project = Project::read(&args.config)?;
199 let loaded = load_contract(&project)?;
200 validate::manifest(&loaded.manifest)?;
201 let resolved = resolve::contract(&project, loaded.manifest)?;
202 let inspection = Inspection {
203 schema_version: LOCK_VERSION,
204 fingerprint: fingerprint(
205 &resolved.manifest,
206 &resolved.hosts,
207 &resolved.dependencies,
208 )?,
209 manifest: &resolved.manifest,
210 dependencies: &resolved.dependencies,
211 hosts: &resolved.hosts,
212 };
213 let rendered = serde_json::to_string_pretty(&inspection)?;
214 println!("{rendered}");
215 Ok(())
216 }
217 Command::Clean(args) => {
218 let project = Project::read(&args.config)?;
219 let output = project.output_dir();
220 if output.exists() {
221 fs::remove_dir_all(&output)
222 .with_context(|| format!("failed to remove {}", output.display()))?;
223 }
224 print_json(&CleanReport {
225 schema_version: 1,
226 removed: output,
227 })
228 }
229 }
230}
231
232fn create_lock(resolved: resolve::ResolvedContract) -> Result<ContractLock> {
233 Ok(ContractLock {
234 schema_version: LOCK_VERSION,
235 fingerprint: fingerprint(&resolved.manifest, &resolved.hosts, &resolved.dependencies)?,
236 hosts: resolved.hosts,
237 dependencies: resolved.dependencies,
238 manifest: resolved.manifest,
241 })
242}
243
244#[derive(Debug, Serialize)]
245#[serde(rename_all = "camelCase")]
246struct CleanReport {
247 schema_version: u32,
248 removed: PathBuf,
249}
250
251fn check_lock(project: &Project, report: &BuildReport) -> Result<()> {
252 let path = project.lock_path();
253 let metadata = fs::symlink_metadata(&path)
254 .with_context(|| format!("locked check requires {}", path.display()))?;
255 if metadata.file_type().is_symlink() || !metadata.is_file() {
256 bail!(
257 "contract lock must be a regular non-symlink file: {}",
258 path.display()
259 );
260 }
261 let source = fs::read_to_string(&path)
262 .with_context(|| format!("locked check requires {}", path.display()))?;
263 let lock: ContractLock = serde_json::from_str(&source)
264 .with_context(|| format!("invalid contract lock {}", path.display()))?;
265 if lock.schema_version != LOCK_VERSION {
266 bail!(
267 "unsupported rspyts.lock schema {}; expected {LOCK_VERSION}",
268 lock.schema_version
269 );
270 }
271 validate::manifest(&lock.manifest).context("rspyts.lock contains an invalid manifest")?;
272 let locked_fingerprint = fingerprint(&lock.manifest, &lock.hosts, &lock.dependencies)?;
273 if locked_fingerprint != lock.fingerprint {
274 bail!(
275 "contract lock fingerprint mismatch: recorded {}, computed {locked_fingerprint}",
276 lock.fingerprint
277 );
278 }
279 let semantic_current = semantic_manifest(&report.manifest);
280 if semantic_manifest(&lock.manifest) == semantic_current
281 && lock.dependencies == report.dependencies
282 && lock.hosts == report.hosts
283 && lock.fingerprint == report.fingerprint
284 {
285 return Ok(());
286 }
287
288 let diff = ContractDiff::between(&lock.manifest, &report.manifest);
289 bail!(
290 "compiled contract does not match {}\n{}",
291 path.display(),
292 diff
293 )
294}
295
296#[derive(Serialize)]
297#[serde(rename_all = "camelCase")]
298struct FingerprintInput<'a> {
299 schema_version: u32,
300 hosts: &'a LockedHosts,
301 manifest: rspyts::ir::Manifest,
302 dependencies: &'a BTreeMap<String, LockedDependency>,
303}
304
305pub(crate) fn fingerprint(
306 manifest: &rspyts::ir::Manifest,
307 hosts: &LockedHosts,
308 dependencies: &BTreeMap<String, LockedDependency>,
309) -> Result<String> {
310 let canonical = serde_json::to_vec(&FingerprintInput {
311 schema_version: LOCK_VERSION,
312 hosts,
313 manifest: semantic_manifest(manifest),
314 dependencies,
315 })?;
316 Ok(format!("sha256:{:x}", Sha256::digest(canonical)))
317}
318
319fn semantic_manifest(manifest: &rspyts::ir::Manifest) -> rspyts::ir::Manifest {
320 let mut semantic = manifest.clone();
321 semantic.crate_version.clear();
322 semantic.types = semantic.types.iter().map(semantic_type_def).collect();
323 semantic.errors = semantic.errors.iter().map(semantic_error_def).collect();
324 for import in &mut semantic.imports {
325 import.types = import.types.iter().map(semantic_type_def).collect();
326 import.errors = import.errors.iter().map(semantic_error_def).collect();
327 import
328 .types
329 .sort_by(|left, right| (&left.owner, &left.id).cmp(&(&right.owner, &right.id)));
330 import
331 .errors
332 .sort_by(|left, right| (&left.owner, &left.id).cmp(&(&right.owner, &right.id)));
333 }
334 for function in &mut semantic.functions {
335 function.docs = None;
336 }
337 for resource in &mut semantic.resources {
338 resource.docs = None;
339 for constructor in &mut resource.constructors {
340 constructor.docs = None;
341 }
342 for method in &mut resource.methods {
343 method.docs = None;
344 }
345 }
346 for constant in &mut semantic.constants {
347 constant.docs = None;
348 canonicalize_json(&mut constant.value);
349 }
350 semantic
351 .imports
352 .sort_by(|left, right| left.owner.cmp(&right.owner));
353 semantic
354 .types
355 .sort_by(|left, right| (&left.owner, &left.id).cmp(&(&right.owner, &right.id)));
356 semantic
357 .errors
358 .sort_by(|left, right| (&left.owner, &left.id).cmp(&(&right.owner, &right.id)));
359 semantic.functions.sort_by(|left, right| {
360 (&left.owner, &left.host_name, &left.rust_name).cmp(&(
361 &right.owner,
362 &right.host_name,
363 &right.rust_name,
364 ))
365 });
366 semantic
367 .resources
368 .sort_by(|left, right| (&left.owner, &left.id).cmp(&(&right.owner, &right.id)));
369 semantic.constants.sort_by(|left, right| {
370 (&left.owner, &left.host_name, &left.rust_name).cmp(&(
371 &right.owner,
372 &right.host_name,
373 &right.rust_name,
374 ))
375 });
376 semantic
377}
378
379pub(crate) fn semantic_type_def(item: &rspyts::ir::TypeDef) -> rspyts::ir::TypeDef {
380 let mut item = item.clone();
381 item.docs = None;
382 match &mut item.shape {
383 rspyts::ir::TypeShape::Struct { fields } => clear_field_docs(fields),
384 rspyts::ir::TypeShape::StringEnum { variants }
385 | rspyts::ir::TypeShape::TaggedEnum { variants, .. } => {
386 for variant in variants {
387 variant.docs = None;
388 clear_field_docs(&mut variant.fields);
389 }
390 }
391 rspyts::ir::TypeShape::Alias { .. } => {}
392 }
393 item
394}
395
396pub(crate) fn semantic_error_def(item: &rspyts::ir::ErrorDef) -> rspyts::ir::ErrorDef {
397 let mut item = item.clone();
398 item.docs = None;
399 for variant in &mut item.variants {
400 variant.docs = None;
401 clear_field_docs(&mut variant.fields);
402 }
403 item
404}
405
406fn canonicalize_json(value: &mut serde_json::Value) {
407 match value {
408 serde_json::Value::Array(items) => {
409 for item in items {
410 canonicalize_json(item);
411 }
412 }
413 serde_json::Value::Object(items) => {
414 let mut sorted = std::mem::take(items).into_iter().collect::<Vec<_>>();
415 sorted.sort_by(|left, right| left.0.cmp(&right.0));
416 for (_, value) in &mut sorted {
417 canonicalize_json(value);
418 }
419 items.extend(sorted);
420 }
421 _ => {}
422 }
423}
424
425fn clear_field_docs(fields: &mut [rspyts::ir::FieldDef]) {
426 for field in fields {
427 field.docs = None;
428 }
429}
430
431fn pretty_json_line<T: Serialize>(value: &T) -> Result<Vec<u8>> {
432 let mut bytes = serde_json::to_vec_pretty(value)?;
433 bytes.push(b'\n');
434 Ok(bytes)
435}
436
437fn print_json<T: Serialize>(value: &T) -> Result<()> {
438 println!("{}", serde_json::to_string(value)?);
439 Ok(())
440}
441
442fn write_atomic_file(path: &Path, bytes: &[u8]) -> Result<()> {
443 let parent = path
444 .parent()
445 .with_context(|| format!("{} has no parent directory", path.display()))?;
446 fs::create_dir_all(parent)?;
447 let temporary = atomic_sibling(path, &format!("tmp-{}", std::process::id()))?;
448 fs::write(&temporary, bytes)
449 .with_context(|| format!("failed to write {}", temporary.display()))?;
450 let backup = atomic_sibling(path, &format!("old-{}", std::process::id()))?;
451 if backup.exists() {
452 fs::remove_file(&backup)?;
453 }
454 let had_existing = path.exists();
455 if had_existing {
456 fs::rename(path, &backup)
457 .with_context(|| format!("failed to stage replacement of {}", path.display()))?;
458 }
459 if let Err(error) = fs::rename(&temporary, path) {
460 if had_existing {
461 let _ = fs::rename(&backup, path);
462 }
463 let _ = fs::remove_file(&temporary);
464 return Err(error).with_context(|| format!("failed to replace {}", path.display()));
465 }
466 if had_existing {
467 fs::remove_file(backup)?;
468 }
469 Ok(())
470}
471
472#[cfg(test)]
473mod tests {
474 use std::time::{SystemTime, UNIX_EPOCH};
475
476 use rspyts::ir::Manifest;
477
478 use super::*;
479
480 fn no_hosts() -> LockedHosts {
481 LockedHosts {
482 python: None,
483 typescript: None,
484 }
485 }
486
487 fn empty_dependencies() -> BTreeMap<String, LockedDependency> {
488 BTreeMap::new()
489 }
490
491 fn test_fingerprint(manifest: &Manifest) -> String {
492 fingerprint(manifest, &no_hosts(), &empty_dependencies()).unwrap()
493 }
494
495 #[test]
496 fn hidden_lock_uses_single_dot_siblings_and_is_replaced_atomically() {
497 let root = std::env::temp_dir().join(format!(
498 "rspyts-lock-atomic-{}-{}",
499 std::process::id(),
500 SystemTime::now()
501 .duration_since(UNIX_EPOCH)
502 .unwrap()
503 .as_nanos()
504 ));
505 fs::create_dir_all(&root).unwrap();
506 let lock = root.join(".rspyts.lock");
507 fs::write(&lock, "old").unwrap();
508 let temporary = atomic_sibling(&lock, &format!("tmp-{}", std::process::id())).unwrap();
509 let backup = atomic_sibling(&lock, &format!("old-{}", std::process::id())).unwrap();
510 assert_eq!(
511 temporary.file_name().unwrap().to_string_lossy(),
512 format!(".rspyts.lock.tmp-{}", std::process::id())
513 );
514 assert_eq!(
515 backup.file_name().unwrap().to_string_lossy(),
516 format!(".rspyts.lock.old-{}", std::process::id())
517 );
518
519 write_atomic_file(&lock, b"new").unwrap();
520
521 assert_eq!(fs::read_to_string(&lock).unwrap(), "new");
522 assert!(!temporary.exists());
523 assert!(!backup.exists());
524 fs::remove_dir_all(root).unwrap();
525 }
526
527 #[test]
528 fn visible_atomic_outputs_are_hidden_siblings() {
529 let path = Path::new("rspyts.lock");
530 assert_eq!(
531 atomic_sibling(path, "tmp-123").unwrap(),
532 PathBuf::from(".rspyts.lock.tmp-123")
533 );
534 assert_eq!(
535 atomic_sibling(path, "old-123").unwrap(),
536 PathBuf::from(".rspyts.lock.old-123")
537 );
538 }
539
540 #[test]
541 fn build_and_check_parse_host_targets() {
542 let build = Cli::try_parse_from(["rspyts", "build", "--target", "python"])
543 .expect("python build target should parse");
544 assert!(matches!(
545 build.command,
546 Command::Build(BuildArgs {
547 target: BuildTarget::Python,
548 ..
549 })
550 ));
551
552 let check = Cli::try_parse_from(["rspyts", "check", "--target", "typescript"])
553 .expect("TypeScript check target should parse");
554 assert!(matches!(
555 check.command,
556 Command::Check(CheckArgs {
557 target: BuildTarget::Typescript,
558 ..
559 })
560 ));
561
562 let default = Cli::try_parse_from(["rspyts", "build"]).unwrap();
563 assert!(matches!(
564 default.command,
565 Command::Build(BuildArgs {
566 target: BuildTarget::All,
567 ..
568 })
569 ));
570 }
571
572 #[test]
573 fn fingerprints_are_stable() {
574 let manifest = Manifest {
575 ir_version: 4,
576 crate_name: "sample".into(),
577 crate_version: "1.0.0".into(),
578 module_name: "sample".into(),
579 imports: vec![],
580 types: vec![],
581 errors: vec![],
582 functions: vec![],
583 resources: vec![],
584 constants: vec![],
585 };
586 assert_eq!(test_fingerprint(&manifest), test_fingerprint(&manifest));
587 assert!(test_fingerprint(&manifest).starts_with("sha256:"));
588 }
589
590 #[test]
591 fn lock_retains_the_compiled_package_version() {
592 let manifest = Manifest {
593 ir_version: 4,
594 crate_name: "sample".into(),
595 crate_version: "1.2.3".into(),
596 module_name: "sample".into(),
597 imports: vec![],
598 types: vec![],
599 errors: vec![],
600 functions: vec![],
601 resources: vec![],
602 constants: vec![],
603 };
604 let lock = create_lock(resolve::ResolvedContract {
605 manifest,
606 dependencies: BTreeMap::new(),
607 hosts: no_hosts(),
608 foreign_types: BTreeMap::new(),
609 foreign_errors: BTreeMap::new(),
610 })
611 .unwrap();
612
613 assert_eq!(lock.manifest.crate_version, "1.2.3");
614 let encoded = serde_json::to_value(&lock).unwrap();
615 assert_eq!(encoded["manifest"]["crateVersion"], "1.2.3");
616
617 let first = pretty_json_line(&lock).unwrap();
618 let second = pretty_json_line(&lock).unwrap();
619 assert_eq!(first, second);
620 assert!(first.ends_with(b"\n"));
621 assert!(String::from_utf8_lossy(&first).contains("\n \"schemaVersion\""));
622 let decoded: ContractLock = serde_json::from_slice(&first).unwrap();
623 assert_eq!(serde_json::to_value(decoded).unwrap(), encoded);
624 }
625
626 #[test]
627 fn documentation_and_package_version_do_not_change_semantic_fingerprint() {
628 let mut before = Manifest {
629 ir_version: 4,
630 crate_name: "sample".into(),
631 crate_version: "1.0.0".into(),
632 module_name: "sample".into(),
633 imports: vec![],
634 types: vec![rspyts::ir::TypeDef {
635 owner: rspyts::ir::CargoPackageId::new("sample"),
636 id: "sample::Value".into(),
637 name: "Value".into(),
638 docs: None,
639 shape: rspyts::ir::TypeShape::Struct { fields: vec![] },
640 }],
641 errors: vec![],
642 functions: vec![],
643 resources: vec![],
644 constants: vec![],
645 };
646 let expected = test_fingerprint(&before);
647 before.crate_version = "1.1.0".into();
648 before.types[0].docs = Some("Better docs".into());
649 assert_eq!(test_fingerprint(&before), expected);
650 }
651
652 #[test]
653 fn locked_check_accepts_documentation_and_package_version_changes() {
654 let root = std::env::temp_dir().join(format!(
655 "rspyts-lock-semantic-{}-{}",
656 std::process::id(),
657 SystemTime::now()
658 .duration_since(UNIX_EPOCH)
659 .unwrap()
660 .as_nanos()
661 ));
662 fs::create_dir_all(root.join("rust/src")).unwrap();
663 fs::write(
664 root.join("rust/Cargo.toml"),
665 "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
666 )
667 .unwrap();
668 fs::write(root.join("rust/src/lib.rs"), "").unwrap();
669 fs::write(
670 root.join("rspyts.toml"),
671 "[crate]\npath = \"rust\"\n\n[typescript]\npackage = \"fixture\"\nmode = \"static\"\n",
672 )
673 .unwrap();
674 let project = Project::read(&root.join("rspyts.toml")).unwrap();
675 let mut manifest = Manifest {
676 ir_version: 4,
677 crate_name: "fixture".into(),
678 crate_version: "1.0.0".into(),
679 module_name: "fixture".into(),
680 imports: vec![],
681 types: vec![rspyts::ir::TypeDef {
682 owner: rspyts::ir::CargoPackageId::new("fixture"),
683 id: "fixture::Value".into(),
684 name: "Value".into(),
685 docs: None,
686 shape: rspyts::ir::TypeShape::Struct { fields: vec![] },
687 }],
688 errors: vec![],
689 functions: vec![],
690 resources: vec![],
691 constants: vec![],
692 };
693 let hosts = LockedHosts {
694 python: None,
695 typescript: Some(LockedTypeScriptHost {
696 package: "fixture".into(),
697 mode: crate::config::TypeScriptMode::Static,
698 }),
699 };
700 let dependencies = empty_dependencies();
701 let mut lock = ContractLock {
702 schema_version: LOCK_VERSION,
703 fingerprint: fingerprint(&manifest, &hosts, &dependencies).unwrap(),
704 hosts: hosts.clone(),
705 dependencies: dependencies.clone(),
706 manifest: manifest.clone(),
707 };
708 fs::write(
709 project.lock_path(),
710 pretty_json_line(&lock).expect("serialize lock"),
711 )
712 .unwrap();
713
714 manifest.crate_version = "2.0.0".into();
715 manifest.types[0].docs = Some("New documentation".into());
716 let report = BuildReport {
717 schema_version: 1,
718 status: "ok",
719 fingerprint: fingerprint(&manifest, &hosts, &dependencies).unwrap(),
720 contract: root.join(".rspyts/contract.json"),
721 staging: root.join(".rspyts"),
722 python: None,
723 typescript: None,
724 manifest,
725 dependencies,
726 hosts,
727 };
728 check_lock(&project, &report).unwrap();
729
730 lock.fingerprint = "sha256:tampered".into();
731 fs::write(
732 project.lock_path(),
733 pretty_json_line(&lock).expect("serialize tampered lock"),
734 )
735 .unwrap();
736 assert!(
737 check_lock(&project, &report)
738 .unwrap_err()
739 .to_string()
740 .contains("fingerprint mismatch")
741 );
742 fs::remove_dir_all(root).unwrap();
743 }
744}