Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(interop): cancelable step read/write

Lewis: May this revision serve well! <lu5a@proton.me>

author
Lewis
date (Jun 8, 2026, 7:13 PM +0300) commit 729c5f3b parent 079ac7b2 change-id orylmwvt
+384 -36
+35
crates/bone-interop/src/cancel.rs
··· 1 + use std::sync::atomic::{AtomicBool, Ordering}; 2 + 3 + static NEVER: AtomicBool = AtomicBool::new(false); 4 + 5 + pub trait Cancel { 6 + fn is_canceled(&self) -> bool; 7 + } 8 + 9 + impl Cancel for AtomicBool { 10 + fn is_canceled(&self) -> bool { 11 + self.load(Ordering::Relaxed) 12 + } 13 + } 14 + 15 + #[derive(Copy, Clone)] 16 + pub struct CancelFlag<'a>(&'a (dyn Cancel + Sync)); 17 + 18 + impl<'a> CancelFlag<'a> { 19 + #[must_use] 20 + pub fn new(source: &'a (dyn Cancel + Sync)) -> Self { 21 + Self(source) 22 + } 23 + 24 + #[must_use] 25 + pub fn is_canceled(self) -> bool { 26 + self.0.is_canceled() 27 + } 28 + } 29 + 30 + impl CancelFlag<'static> { 31 + #[must_use] 32 + pub fn never() -> Self { 33 + Self(&NEVER) 34 + } 35 + }
+2
crates/bone-interop/src/lib.rs
··· 1 + pub mod cancel; 1 2 pub mod step; 2 3 4 + pub use cancel::{Cancel, CancelFlag}; 3 5 pub use step::{HeaderDefect, StepError, body_of, read, write};
+47 -6
crates/bone-interop/src/step.rs
··· 11 11 StepOriginatingSystem, StepSchema, 12 12 }; 13 13 14 + use crate::cancel::CancelFlag; 15 + 14 16 const PINNED_TIMESTAMP: &str = "1970-01-01T00:00:00"; 15 17 const ORIGINATING_SYSTEM: &str = concat!("Bone ", env!("CARGO_PKG_VERSION")); 16 18 ··· 68 70 MalformedHeader { reason: HeaderDefect }, 69 71 #[error("step file carries no importable solid")] 70 72 IncompleteFile, 73 + #[error("step operation canceled before it completed")] 74 + Canceled, 71 75 #[error("step file carries {solids} solids; assemblies are not yet imported")] 72 76 UnsupportedAssembly { solids: usize }, 73 77 #[error("io at {path}: {source}")] ··· 86 90 }, 87 91 } 88 92 89 - pub fn write(document: &Document, path: &Path, schema: StepSchema) -> Result<(), StepError> { 93 + fn guard(cancel: CancelFlag) -> Result<(), StepError> { 94 + if cancel.is_canceled() { 95 + return Err(StepError::Canceled); 96 + } 97 + Ok(()) 98 + } 99 + 100 + pub fn write( 101 + document: &Document, 102 + path: &Path, 103 + schema: StepSchema, 104 + cancel: CancelFlag, 105 + ) -> Result<(), StepError> { 90 106 if schema != StepSchema::Ap214 { 91 107 return Err(StepError::SchemaUnsupported(schema)); 92 108 } 109 + guard(cancel)?; 93 110 let solid = body_of(document)?; 94 111 let body = solid.to_step_body()?; 112 + guard(cancel)?; 95 113 let contents = envelope( 96 114 &render_header(&export_header(document.name(), schema)), 97 115 &body, ··· 104 122 source: Box::new(source), 105 123 })?; 106 124 write_file(&labels_path, sidecar.as_bytes())?; 107 - write_file(path, contents.as_bytes()) 125 + commit_step(path, contents.as_bytes()).inspect_err(|_| { 126 + let _ = std::fs::remove_file(&labels_path); 127 + }) 108 128 } 109 129 110 130 pub fn body_of(document: &Document) -> Result<BrepSolid, StepError> { ··· 137 157 .map_err(|source| StepError::Evaluation { feature, source }) 138 158 } 139 159 140 - pub fn read(path: &Path) -> Result<Document, StepError> { 160 + pub fn read(path: &Path, cancel: CancelFlag) -> Result<Document, StepError> { 161 + guard(cancel)?; 141 162 let text = read_file(path)?; 142 163 classify_schema(&text)?; 143 164 let sidecar = read_sidecar(path)?; 165 + guard(cancel)?; 144 166 let mut document = Document::new(DocumentId::default(), document_name(path)); 145 167 document.import_body(|feature| { 146 168 BrepSolid::from_step( ··· 150 172 ) 151 173 .map_err(read_geometry_error) 152 174 })?; 175 + guard(cancel)?; 153 176 Ok(document) 154 177 } 155 178 ··· 309 332 } 310 333 } 311 334 312 - fn sidecar_path(step: &Path) -> PathBuf { 313 - let mut name = step.as_os_str().to_os_string(); 314 - name.push(".labels"); 335 + fn suffixed(path: &Path, suffix: &str) -> PathBuf { 336 + let mut name = path.as_os_str().to_os_string(); 337 + name.push(suffix); 315 338 PathBuf::from(name) 339 + } 340 + 341 + fn sidecar_path(step: &Path) -> PathBuf { 342 + suffixed(step, ".labels") 316 343 } 317 344 318 345 fn write_file(path: &Path, bytes: &[u8]) -> Result<(), StepError> { ··· 326 353 path: path.to_path_buf(), 327 354 source, 328 355 }) 356 + } 357 + 358 + fn commit_step(path: &Path, bytes: &[u8]) -> Result<(), StepError> { 359 + let staging = suffixed(path, ".staging"); 360 + write_file(&staging, bytes) 361 + .and_then(|()| { 362 + std::fs::rename(&staging, path).map_err(|source| StepError::Io { 363 + path: path.to_path_buf(), 364 + source, 365 + }) 366 + }) 367 + .inspect_err(|_| { 368 + let _ = std::fs::remove_file(&staging); 369 + }) 329 370 } 330 371 331 372 fn read_file(path: &Path) -> Result<String, StepError> {
+300 -30
crates/bone-interop/tests/step.rs
··· 1 1 use std::path::{Path, PathBuf}; 2 + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; 3 + use std::time::{Duration, Instant}; 2 4 3 5 use bone_document::{Document, EditOutcome, Sketch, SketchEdit, SketchEntity}; 4 - use bone_interop::{HeaderDefect, StepError, body_of, read, write}; 6 + use bone_interop::{Cancel, CancelFlag, HeaderDefect, StepError, body_of, read, write}; 5 7 use bone_kernel::{ 6 8 BrepFace, BrepSolid, ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, ExtrudeSense, 7 9 MergeResult, ··· 125 127 126 128 fn write_to_temp(document: &Document, dir: &Path, name: &str) -> PathBuf { 127 129 let path = dir.join(name); 128 - let Ok(()) = write(document, &path, StepSchema::Ap214) else { 130 + let Ok(()) = write(document, &path, StepSchema::Ap214, CancelFlag::never()) else { 129 131 panic!("write step"); 130 132 }; 131 133 path ··· 205 207 panic!("temp dir"); 206 208 }; 207 209 let path = write_to_temp(seed, dir.path(), "part.step"); 208 - let Ok(imported) = read(&path) else { 210 + let Ok(imported) = read(&path, CancelFlag::never()) else { 209 211 panic!("read step"); 210 212 }; 211 213 let Ok(solid) = body_of(&imported) else { ··· 240 242 panic!("temp dir"); 241 243 }; 242 244 let path = write_to_temp(seed, dir.path(), "part.step"); 243 - let Ok(imported) = read(&path) else { 245 + let Ok(imported) = read(&path, CancelFlag::never()) else { 244 246 panic!("read step"); 245 247 }; 246 248 imported ··· 252 254 panic!("temp dir"); 253 255 }; 254 256 let path = write_to_temp(&imported, dir.path(), "part.step"); 255 - let Ok(round) = read(&path) else { 257 + let Ok(round) = read(&path, CancelFlag::never()) else { 256 258 panic!("re-read step"); 257 259 }; 258 260 assert_eq!( ··· 306 308 panic!("temp dir"); 307 309 }; 308 310 let path = write_to_temp(&document, dir.path(), "widget.step"); 309 - let Ok(imported) = read(&path) else { 311 + let Ok(imported) = read(&path, CancelFlag::never()) else { 310 312 panic!("read step"); 311 313 }; 312 314 assert_eq!(imported.name(), "widget"); ··· 326 328 text.contains("FILE_NAME('nel''s bracket'"), 327 329 "an apostrophe is doubled per ISO 10303-21" 328 330 ); 329 - let Ok(imported) = read(&path) else { 331 + let Ok(imported) = read(&path, CancelFlag::never()) else { 330 332 panic!("read step"); 331 333 }; 332 334 let Ok(solid) = body_of(&imported) else { ··· 342 344 panic!("temp dir"); 343 345 }; 344 346 let path = dir.path().join("part.step"); 345 - let Ok(()) = write(&document, &path, StepSchema::Ap214) else { 347 + let Ok(()) = write(&document, &path, StepSchema::Ap214, CancelFlag::never()) else { 346 348 panic!("write first"); 347 349 }; 348 350 let Ok(a) = std::fs::read(&path) else { 349 351 panic!("read first"); 350 352 }; 351 - let Ok(()) = write(&document, &path, StepSchema::Ap214) else { 353 + let Ok(()) = write(&document, &path, StepSchema::Ap214, CancelFlag::never()) else { 352 354 panic!("write second"); 353 355 }; 354 356 let Ok(b) = std::fs::read(&path) else { ··· 372 374 let Ok(()) = std::fs::remove_file(PathBuf::from(labels)) else { 373 375 panic!("remove sidecar"); 374 376 }; 375 - let Ok(imported) = read(&path) else { 377 + let Ok(imported) = read(&path, CancelFlag::never()) else { 376 378 panic!("read step"); 377 379 }; 378 380 let Ok(solid) = body_of(&imported) else { ··· 405 407 panic!("write swapped"); 406 408 }; 407 409 assert!(matches!( 408 - read(&foreign), 410 + read(&foreign, CancelFlag::never()), 409 411 Err(StepError::SchemaMismatch { 410 412 found: StepSchema::Ap242E2, 411 413 .. ··· 432 434 panic!("write swapped"); 433 435 }; 434 436 assert!( 435 - read(&foreign).is_ok(), 437 + read(&foreign, CancelFlag::never()).is_ok(), 436 438 "an unmodeled schema token still attempts a best-effort import" 437 439 ); 438 440 } ··· 447 449 panic!("write"); 448 450 }; 449 451 assert!(matches!( 450 - read(&path), 452 + read(&path, CancelFlag::never()), 451 453 Err(StepError::MalformedHeader { 452 454 reason: HeaderDefect::NoHeaderSection 453 455 }) ··· 465 467 panic!("write"); 466 468 }; 467 469 assert!(matches!( 468 - read(&path), 470 + read(&path, CancelFlag::never()), 469 471 Err(StepError::MalformedHeader { 470 472 reason: HeaderDefect::NoFileSchema 471 473 }) ··· 482 484 let Ok(()) = std::fs::write(&path, text.as_bytes()) else { 483 485 panic!("write"); 484 486 }; 485 - assert!(matches!(read(&path), Err(StepError::IncompleteFile))); 487 + assert!(matches!( 488 + read(&path, CancelFlag::never()), 489 + Err(StepError::IncompleteFile) 490 + )); 486 491 } 487 492 488 493 #[test] ··· 502 507 let Ok(()) = std::fs::write(&path, commented.as_bytes()) else { 503 508 panic!("rewrite with a header comment"); 504 509 }; 505 - let Ok(imported) = read(&path) else { 510 + let Ok(imported) = read(&path, CancelFlag::never()) else { 506 511 panic!("a lone apostrophe inside a header comment must not desync the scan"); 507 512 }; 508 513 let Ok(solid) = body_of(&imported) else { ··· 531 536 let Ok(()) = std::fs::write(&path, commented.as_bytes()) else { 532 537 panic!("rewrite with a header comment"); 533 538 }; 534 - let Ok(imported) = read(&path) else { 539 + let Ok(imported) = read(&path, CancelFlag::never()) else { 535 540 panic!("a FILE_SCHEMA statement buried in a comment must not classify the file"); 536 541 }; 537 542 let Ok(solid) = body_of(&imported) else { ··· 551 556 }; 552 557 let path = dir.path().join("part.step"); 553 558 assert!(matches!( 554 - write(&document, &path, StepSchema::Ap242E2), 559 + write(&document, &path, StepSchema::Ap242E2, CancelFlag::never()), 555 560 Err(StepError::SchemaUnsupported(StepSchema::Ap242E2)) 556 561 )); 557 562 } ··· 564 569 }; 565 570 let path = dir.path().join("part.step"); 566 571 assert!(matches!( 567 - write(&document, &path, StepSchema::Ap214), 572 + write(&document, &path, StepSchema::Ap214, CancelFlag::never()), 568 573 Err(StepError::BodyCount { count: 0 }) 569 574 )); 570 575 } ··· 583 588 }; 584 589 let path = dir.path().join("part.step"); 585 590 assert!(matches!( 586 - write(&document, &path, StepSchema::Ap214), 591 + write(&document, &path, StepSchema::Ap214, CancelFlag::never()), 587 592 Err(StepError::BodyCount { count: 2 }) 588 593 )); 589 594 } ··· 597 602 }; 598 603 let path = dir.path().join("part.step"); 599 604 assert!(matches!( 600 - write(&document, &path, StepSchema::Ap214), 605 + write(&document, &path, StepSchema::Ap214, CancelFlag::never()), 601 606 Err(StepError::DanglingExtrude { .. }) 602 607 )); 603 608 } ··· 613 618 }; 614 619 let path = dir.path().join("part.step"); 615 620 assert!(matches!( 616 - write(&document, &path, StepSchema::Ap214), 621 + write(&document, &path, StepSchema::Ap214, CancelFlag::never()), 617 622 Err(StepError::BodyCount { count: 2 }) 618 623 )); 619 624 assert!(!path.exists(), "a rejected export leaves no file behind"); ··· 631 636 let Ok(()) = std::fs::create_dir(PathBuf::from(blocker)) else { 632 637 panic!("block the sidecar path with a directory"); 633 638 }; 634 - assert!(write(&document, &path, StepSchema::Ap214).is_err()); 639 + assert!(write(&document, &path, StepSchema::Ap214, CancelFlag::never()).is_err()); 635 640 assert!( 636 641 !path.exists(), 637 642 "no labelless step is written when the sidecar cannot be" ··· 696 701 ); 697 702 files.iter().for_each(|path| { 698 703 let label = path.display(); 699 - let document = match read(path) { 704 + let document = match read(path, CancelFlag::never()) { 700 705 Ok(document) => document, 701 706 Err(error) => panic!("inbound {label} imports: {error}"), 702 707 }; ··· 735 740 let Ok(()) = std::fs::write(&path, text.as_bytes()) else { 736 741 panic!("write probe step"); 737 742 }; 738 - read(&path) 743 + read(&path, CancelFlag::never()) 739 744 } 740 745 741 746 #[test] ··· 845 850 panic!("write swapped"); 846 851 }; 847 852 assert!(matches!( 848 - read(&foreign), 853 + read(&foreign, CancelFlag::never()), 849 854 Err(StepError::SchemaMismatch { 850 855 found: StepSchema::Ap242E2, 851 856 .. ··· 865 870 let Ok(()) = std::fs::write(PathBuf::from(labels), b"not valid ron @@@ {") else { 866 871 panic!("overwrite sidecar with garbage"); 867 872 }; 868 - let Ok(imported) = read(&path) else { 873 + let Ok(imported) = read(&path, CancelFlag::never()) else { 869 874 panic!("a corrupt sidecar still imports best-effort"); 870 875 }; 871 876 let Ok(solid) = body_of(&imported) else { ··· 877 882 #[test] 878 883 fn imported_body_survives_folder_round_trip() { 879 884 let wedge = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/goldens/inbound/wedge.step"); 880 - let Ok(imported) = read(&wedge) else { 885 + let Ok(imported) = read(&wedge, CancelFlag::never()) else { 881 886 panic!("import wedge"); 882 887 }; 883 888 let Ok(expected) = body_of(&imported) else { ··· 933 938 panic!("temp dir"); 934 939 }; 935 940 let path = write_to_temp(&document, dir.path(), "part.step"); 936 - let Ok(imported) = read(&path) else { 941 + let Ok(imported) = read(&path, CancelFlag::never()) else { 937 942 panic!("a name carrying '{keyword}' must not derail header parsing"); 938 943 }; 939 944 let Ok(solid) = body_of(&imported) else { ··· 985 990 panic!("temp dir"); 986 991 }; 987 992 let path = write_to_temp(&seed, dir.path(), "part.step"); 988 - let Ok(imported) = read(&path) else { 993 + let Ok(imported) = read(&path, CancelFlag::never()) else { 989 994 panic!("read step"); 990 995 }; 991 996 let Ok(round) = body_of(&imported) else { ··· 1008 1013 "the EndCap label lands on the z=depth face" 1009 1014 ); 1010 1015 } 1016 + 1017 + #[test] 1018 + fn a_set_flag_cancels_export_and_leaves_no_file() { 1019 + let document = document("cube", rectangle_sketch(), 4.0); 1020 + let Ok(dir) = tempfile::tempdir() else { 1021 + panic!("temp dir"); 1022 + }; 1023 + let path = dir.path().join("part.step"); 1024 + let flag = AtomicBool::new(true); 1025 + assert!(matches!( 1026 + write(&document, &path, StepSchema::Ap214, CancelFlag::new(&flag)), 1027 + Err(StepError::Canceled) 1028 + )); 1029 + assert!(!path.exists(), "a canceled export writes no step file"); 1030 + let mut labels = path.into_os_string(); 1031 + labels.push(".labels"); 1032 + assert!( 1033 + !PathBuf::from(labels).exists(), 1034 + "a canceled export writes no sidecar either" 1035 + ); 1036 + } 1037 + 1038 + #[test] 1039 + fn a_set_flag_cancels_import() { 1040 + let document = document("cube", rectangle_sketch(), 4.0); 1041 + let Ok(dir) = tempfile::tempdir() else { 1042 + panic!("temp dir"); 1043 + }; 1044 + let path = write_to_temp(&document, dir.path(), "part.step"); 1045 + let flag = AtomicBool::new(true); 1046 + assert!(matches!( 1047 + read(&path, CancelFlag::new(&flag)), 1048 + Err(StepError::Canceled) 1049 + )); 1050 + } 1051 + 1052 + #[test] 1053 + fn a_clear_flag_does_not_block_a_round_trip() { 1054 + let document = document("cube", rectangle_sketch(), 4.0); 1055 + let Ok(dir) = tempfile::tempdir() else { 1056 + panic!("temp dir"); 1057 + }; 1058 + let path = dir.path().join("part.step"); 1059 + let flag = AtomicBool::new(false); 1060 + let observed = CancelFlag::new(&flag); 1061 + let Ok(()) = write(&document, &path, StepSchema::Ap214, observed) else { 1062 + panic!("a clear flag permits export"); 1063 + }; 1064 + let Ok(imported) = read(&path, observed) else { 1065 + panic!("a clear flag permits import"); 1066 + }; 1067 + let Ok(solid) = body_of(&imported) else { 1068 + panic!("one body"); 1069 + }; 1070 + assert!(!is_dumb(&solid)); 1071 + } 1072 + 1073 + struct CancelAfter { 1074 + seen: AtomicUsize, 1075 + trip: usize, 1076 + } 1077 + 1078 + impl CancelAfter { 1079 + fn new(trip: usize) -> Self { 1080 + Self { 1081 + seen: AtomicUsize::new(0), 1082 + trip, 1083 + } 1084 + } 1085 + 1086 + fn observations(&self) -> usize { 1087 + self.seen.load(Ordering::Relaxed) 1088 + } 1089 + } 1090 + 1091 + impl Cancel for CancelAfter { 1092 + fn is_canceled(&self) -> bool { 1093 + self.seen.fetch_add(1, Ordering::Relaxed) >= self.trip 1094 + } 1095 + } 1096 + 1097 + #[test] 1098 + fn a_cancel_after_evaluation_stops_export_before_writing() { 1099 + let document = document("cube", rectangle_sketch(), 4.0); 1100 + let Ok(dir) = tempfile::tempdir() else { 1101 + panic!("temp dir"); 1102 + }; 1103 + let path = dir.path().join("part.step"); 1104 + let cancel = CancelAfter::new(1); 1105 + assert!(matches!( 1106 + write( 1107 + &document, 1108 + &path, 1109 + StepSchema::Ap214, 1110 + CancelFlag::new(&cancel) 1111 + ), 1112 + Err(StepError::Canceled) 1113 + )); 1114 + assert_eq!( 1115 + cancel.observations(), 1116 + 2, 1117 + "the export clears the first guard, evaluates, then trips on the second" 1118 + ); 1119 + assert!( 1120 + !path.exists(), 1121 + "a cancel seen after evaluation writes no step file" 1122 + ); 1123 + let mut labels = path.into_os_string(); 1124 + labels.push(".labels"); 1125 + assert!( 1126 + !PathBuf::from(labels).exists(), 1127 + "and writes no label sidecar" 1128 + ); 1129 + } 1130 + 1131 + #[test] 1132 + fn a_cancel_after_read_stops_import_before_assembling() { 1133 + let document = document("cube", rectangle_sketch(), 4.0); 1134 + let Ok(dir) = tempfile::tempdir() else { 1135 + panic!("temp dir"); 1136 + }; 1137 + let path = write_to_temp(&document, dir.path(), "part.step"); 1138 + let cancel = CancelAfter::new(1); 1139 + assert!(matches!( 1140 + read(&path, CancelFlag::new(&cancel)), 1141 + Err(StepError::Canceled) 1142 + )); 1143 + assert_eq!( 1144 + cancel.observations(), 1145 + 2, 1146 + "the import clears the first guard, reads the file, then trips on the second" 1147 + ); 1148 + } 1149 + 1150 + const WRITE_BUDGET: Duration = Duration::from_millis(100); 1151 + const BUDGET_SAMPLES: u32 = 8; 1152 + 1153 + #[test] 1154 + fn rectangle_extrude_write_stays_within_budget() { 1155 + let document = document("cube", rectangle_sketch(), 4.0); 1156 + let Ok(dir) = tempfile::tempdir() else { 1157 + panic!("temp dir"); 1158 + }; 1159 + let never = CancelFlag::never(); 1160 + let Ok(()) = write( 1161 + &document, 1162 + &dir.path().join("warmup.step"), 1163 + StepSchema::Ap214, 1164 + never, 1165 + ) else { 1166 + panic!("warmup write"); 1167 + }; 1168 + let best = (0..BUDGET_SAMPLES).fold(Duration::MAX, |best, sample| { 1169 + let path = dir.path().join(format!("bench{sample}.step")); 1170 + let start = Instant::now(); 1171 + let outcome = write(&document, &path, StepSchema::Ap214, never); 1172 + let elapsed = start.elapsed(); 1173 + let Ok(()) = outcome else { 1174 + panic!("benchmark write"); 1175 + }; 1176 + best.min(elapsed) 1177 + }); 1178 + assert!( 1179 + cfg!(debug_assertions) || best < WRITE_BUDGET, 1180 + "rectangle extrude step write took {best:?} over the {WRITE_BUDGET:?} release budget" 1181 + ); 1182 + } 1183 + 1184 + #[test] 1185 + fn a_cancel_seen_after_parsing_discards_the_import() { 1186 + let document = document("cube", rectangle_sketch(), 4.0); 1187 + let Ok(dir) = tempfile::tempdir() else { 1188 + panic!("temp dir"); 1189 + }; 1190 + let path = write_to_temp(&document, dir.path(), "part.step"); 1191 + let cancel = CancelAfter::new(2); 1192 + assert!(matches!( 1193 + read(&path, CancelFlag::new(&cancel)), 1194 + Err(StepError::Canceled) 1195 + )); 1196 + assert_eq!( 1197 + cancel.observations(), 1198 + 3, 1199 + "the import clears both pre-parse guards, parses, then trips on the post-parse guard" 1200 + ); 1201 + } 1202 + 1203 + #[test] 1204 + fn a_failed_step_write_leaves_no_orphan_sidecar() { 1205 + let document = document("cube", rectangle_sketch(), 4.0); 1206 + let Ok(dir) = tempfile::tempdir() else { 1207 + panic!("temp dir"); 1208 + }; 1209 + let path = dir.path().join("part.step"); 1210 + let Ok(()) = std::fs::create_dir(&path) else { 1211 + panic!("occupy the step path with a directory so the body write fails"); 1212 + }; 1213 + assert!(matches!( 1214 + write(&document, &path, StepSchema::Ap214, CancelFlag::never()), 1215 + Err(StepError::Io { .. }) 1216 + )); 1217 + assert_eq!( 1218 + read_dir_names(dir.path()), 1219 + vec!["part.step".to_owned()], 1220 + "a failed commit rolls back the sidecar and leaves no staging file" 1221 + ); 1222 + } 1223 + 1224 + fn read_dir_names(dir: &Path) -> Vec<String> { 1225 + let Ok(entries) = std::fs::read_dir(dir) else { 1226 + panic!("read temp dir"); 1227 + }; 1228 + entries 1229 + .filter_map(Result::ok) 1230 + .map(|entry| entry.file_name().to_string_lossy().into_owned()) 1231 + .collect::<std::collections::BTreeSet<_>>() 1232 + .into_iter() 1233 + .collect() 1234 + } 1235 + 1236 + #[test] 1237 + fn a_clean_export_renames_its_staging_file_into_place() { 1238 + let document = document("cube", rectangle_sketch(), 4.0); 1239 + let Ok(dir) = tempfile::tempdir() else { 1240 + panic!("temp dir"); 1241 + }; 1242 + let path = dir.path().join("part.step"); 1243 + let Ok(()) = write(&document, &path, StepSchema::Ap214, CancelFlag::never()) else { 1244 + panic!("export"); 1245 + }; 1246 + assert_eq!( 1247 + read_dir_names(dir.path()), 1248 + vec!["part.step".to_owned(), "part.step.labels".to_owned()], 1249 + "a clean export leaves the step and its sidecar with no staging temp behind" 1250 + ); 1251 + } 1252 + 1253 + #[test] 1254 + fn a_failed_re_export_keeps_the_prior_step_file_intact() { 1255 + let document = document("cube", rectangle_sketch(), 4.0); 1256 + let Ok(dir) = tempfile::tempdir() else { 1257 + panic!("temp dir"); 1258 + }; 1259 + let path = dir.path().join("part.step"); 1260 + let Ok(()) = std::fs::write(&path, b"PRIOR GOOD STEP") else { 1261 + panic!("seed a prior export"); 1262 + }; 1263 + let mut staging = path.clone().into_os_string(); 1264 + staging.push(".staging"); 1265 + let Ok(()) = std::fs::create_dir(PathBuf::from(&staging)) else { 1266 + panic!("block the staging path so the commit cannot stage the new body"); 1267 + }; 1268 + assert!(matches!( 1269 + write(&document, &path, StepSchema::Ap214, CancelFlag::never()), 1270 + Err(StepError::Io { .. }) 1271 + )); 1272 + let Ok(kept) = std::fs::read(&path) else { 1273 + panic!("the prior export vanished"); 1274 + }; 1275 + assert_eq!( 1276 + kept, 1277 + b"PRIOR GOOD STEP".to_vec(), 1278 + "a failed commit must not truncate or clobber the previous export" 1279 + ); 1280 + }