Another project
0

Configure Feed

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

at main 31 kB View raw
1use std::collections::{BTreeMap, BTreeSet}; 2use std::io::Write; 3use std::path::{Path, PathBuf}; 4use std::sync::atomic::{AtomicU64, Ordering}; 5use std::{fs, io}; 6 7use bone_kernel::{BrepSolid, ExtrudeFeature}; 8use bone_types::{ 9 AngleTolerance, BodyId, ChordHeightTolerance, ExtrudeId, FeatureId, SchemaHeader, 10 SchemaVersion, SketchId, 11}; 12 13use crate::document::{ 14 Document, DocumentHeader, ExtrudeFile, FeatureNode, FeatureTree, ImportedSolid, SketchFile, 15 body_brep_filename, body_labels_filename, extrude_filename, sketch_filename, 16}; 17use crate::io::blob::{BlobHash, BlobKind}; 18use crate::io::labels::LabelSidecar; 19use crate::io::ron_io::{RonError, from_str, to_string}; 20use crate::sketch::SketchEditError; 21 22pub const DOCUMENT_FILE: &str = "document.ron"; 23pub const SKETCHES_DIR: &str = "sketches"; 24pub const EXTRUDES_DIR: &str = "extrudes"; 25pub const BODIES_DIR: &str = "bodies"; 26pub const BLOBS_DIR: &str = "blobs"; 27pub const CACHES_DIR: &str = "caches"; 28pub const TESSELLATIONS_DIR: &str = "tessellations"; 29 30const ROOT_GITIGNORE: &str = "caches/\n"; 31const ROOT_GITATTRIBUTES: &str = "* text=auto eol=lf\n*.brep text eol=lf\n*.labels text eol=lf\n"; 32const CACHES_GITIGNORE: &str = "*\n!.gitignore\n!CACHEDIR.TAG\n"; 33const CACHEDIR_TAG: &str = concat!( 34 "Signature: 8a477f597d28d172789f06886806bc55\n", 35 "# This file is a cache directory tag automatically created by bone.\n", 36 "# For information about cache directory tags see https://bford.info/cachedir/\n", 37); 38 39#[derive(Debug, thiserror::Error)] 40#[error(transparent)] 41pub struct FolderError(Box<FolderErrorKind>); 42 43impl FolderError { 44 #[must_use] 45 pub fn kind(&self) -> &FolderErrorKind { 46 &self.0 47 } 48 49 #[must_use] 50 pub fn into_kind(self) -> FolderErrorKind { 51 *self.0 52 } 53} 54 55impl From<FolderErrorKind> for FolderError { 56 fn from(kind: FolderErrorKind) -> Self { 57 Self(Box::new(kind)) 58 } 59} 60 61impl FolderErrorKind { 62 fn wrap(self) -> FolderError { 63 FolderError(Box::new(self)) 64 } 65} 66 67#[derive(Debug, thiserror::Error)] 68pub enum FolderErrorKind { 69 #[error("io at {path}: {source}")] 70 Io { 71 path: PathBuf, 72 #[source] 73 source: io::Error, 74 }, 75 #[error("ron at {path}: {source}")] 76 Ron { 77 path: PathBuf, 78 #[source] 79 source: RonError, 80 }, 81 #[error("unknown schema {found} (expected name {expected_name})")] 82 UnknownSchema { 83 found: String, 84 expected_name: &'static str, 85 }, 86 #[error("schema {name} major v{found} is unsupported (this build supports v{supported})")] 87 UnsupportedMajor { 88 name: String, 89 found: SchemaVersion, 90 supported: SchemaVersion, 91 }, 92 #[error("registry references sketch {id:?} with no file on disk")] 93 MissingSketchFile { id: SketchId }, 94 #[error("integrity of {path}: {source}")] 95 SketchIntegrity { 96 path: PathBuf, 97 #[source] 98 source: SketchEditError, 99 }, 100 #[error("feature tree lists feature {id:?} more than once")] 101 DuplicateFeatureId { id: FeatureId }, 102 #[error("feature tree has a dependency cycle through feature {id:?}")] 103 FeatureCycle { id: FeatureId }, 104 #[error("feature {child:?} precedes its parent {parent:?} in feature order")] 105 FeatureOrderViolation { parent: FeatureId, child: FeatureId }, 106 #[error("rollback marker references feature {id:?} absent from the feature tree")] 107 DanglingRollback { id: FeatureId }, 108 #[error("rollback marker sits on datum feature {id:?}, which is never rollable")] 109 RollbackOnDatum { id: FeatureId }, 110 #[error("suppressed set references feature {id:?} absent from the feature tree")] 111 DanglingSuppressed { id: FeatureId }, 112 #[error("suppressed set lists datum feature {id:?}, which is never suppressible")] 113 SuppressedDatum { id: FeatureId }, 114 #[error("feature tree references sketch {id:?} not in registry")] 115 DanglingTreeSketch { id: SketchId }, 116 #[error("registry has sketch {id:?} absent from feature tree")] 117 OrphanRegistered { id: SketchId }, 118 #[error("feature tree references extrude {id:?} with no file on disk")] 119 MissingExtrudeFile { id: ExtrudeId }, 120 #[error("feature tree references imported body {id:?} with no blob on disk")] 121 MissingBodyFile { id: BodyId }, 122 #[error("stored extrude {extrude:?} references sketch {sketch:?} absent from the document")] 123 DanglingExtrudeSketch { 124 extrude: ExtrudeId, 125 sketch: SketchId, 126 }, 127 #[error("geometry blob at {path}: {source}")] 128 Blob { 129 path: PathBuf, 130 #[source] 131 source: bone_kernel::BrepError, 132 }, 133 #[error("geometry blob at {path} hashes to {found}, expected {expected}")] 134 BlobHashMismatch { 135 path: PathBuf, 136 found: String, 137 expected: String, 138 }, 139 #[error("label sidecar at {path} does not match its geometry blob")] 140 SidecarMismatch { path: PathBuf }, 141} 142 143#[derive(Clone, Debug, PartialEq, Eq, Hash)] 144pub struct DocumentFolder { 145 path: PathBuf, 146} 147 148impl DocumentFolder { 149 #[must_use] 150 pub fn new(path: impl Into<PathBuf>) -> Self { 151 Self { path: path.into() } 152 } 153 154 #[must_use] 155 pub fn path(&self) -> &Path { 156 &self.path 157 } 158 159 #[must_use] 160 pub fn document_file(&self) -> PathBuf { 161 self.path.join(DOCUMENT_FILE) 162 } 163 164 #[must_use] 165 pub fn sketches_dir(&self) -> PathBuf { 166 self.path.join(SKETCHES_DIR) 167 } 168 169 #[must_use] 170 pub fn extrudes_dir(&self) -> PathBuf { 171 self.path.join(EXTRUDES_DIR) 172 } 173 174 #[must_use] 175 pub fn bodies_dir(&self) -> PathBuf { 176 self.path.join(BODIES_DIR) 177 } 178 179 #[must_use] 180 pub fn blobs_dir(&self) -> PathBuf { 181 self.path.join(BLOBS_DIR) 182 } 183 184 #[must_use] 185 pub fn caches_dir(&self) -> PathBuf { 186 self.path.join(CACHES_DIR) 187 } 188 189 #[must_use] 190 pub fn sketch_path(&self, id: SketchId) -> PathBuf { 191 self.sketches_dir().join(sketch_filename(id)) 192 } 193 194 #[must_use] 195 pub fn extrude_path(&self, id: ExtrudeId) -> PathBuf { 196 self.extrudes_dir().join(extrude_filename(id)) 197 } 198 199 #[must_use] 200 pub fn body_brep_path(&self, id: BodyId) -> PathBuf { 201 self.bodies_dir().join(body_brep_filename(id)) 202 } 203 204 #[must_use] 205 pub fn body_labels_path(&self, id: BodyId) -> PathBuf { 206 self.bodies_dir().join(body_labels_filename(id)) 207 } 208 209 #[must_use] 210 pub fn blob_path(&self, hash: BlobHash, kind: BlobKind) -> PathBuf { 211 self.blobs_dir().join(hash.relative_path(kind)) 212 } 213 214 #[must_use] 215 pub fn tessellation_path( 216 &self, 217 hash: BlobHash, 218 chord: ChordHeightTolerance, 219 angle: AngleTolerance, 220 ) -> PathBuf { 221 self.caches_dir().join(TESSELLATIONS_DIR).join(format!( 222 "{}.{}.{}", 223 hash.truncated_128_hex(), 224 tessellation_tier_hex(chord, angle), 225 BlobKind::TESS.as_str() 226 )) 227 } 228} 229 230fn tessellation_tier_hex(chord: ChordHeightTolerance, angle: AngleTolerance) -> String { 231 format!( 232 "{:016x}{:016x}", 233 chord.millimeters().to_bits(), 234 angle.radians().to_bits() 235 ) 236} 237 238pub fn save(document: &Document, folder: &DocumentFolder) -> Result<(), FolderError> { 239 ensure_scaffold(folder)?; 240 241 document 242 .sketches() 243 .try_for_each(|(id, sketch)| -> Result<(), FolderError> { 244 let file = SketchFile::new(sketch.clone()); 245 let ron = to_ron(&folder.sketch_path(id), &file)?; 246 write_if_different(&folder.sketch_path(id), &ron) 247 })?; 248 249 let tree_extrudes = tree_extrude_ids(document.header()); 250 document 251 .header() 252 .extrudes 253 .iter() 254 .filter(|(id, _)| tree_extrudes.contains(*id)) 255 .try_for_each(|(id, feature)| -> Result<(), FolderError> { 256 let path = folder.extrude_path(*id); 257 let label = document.extrude_label(*id).unwrap_or_default().to_owned(); 258 let ron = to_ron(&path, &ExtrudeFile::new(*feature, label))?; 259 write_if_different(&path, &ron) 260 })?; 261 262 document 263 .imported_bodies() 264 .try_for_each(|(id, solid)| write_body(folder, id, solid))?; 265 266 let document_ron = to_ron(&folder.document_file(), document.header())?; 267 write_if_different(&folder.document_file(), &document_ron)?; 268 269 let live_sketches = document 270 .registry() 271 .order() 272 .iter() 273 .copied() 274 .map(sketch_filename) 275 .collect(); 276 remove_stale_files(&folder.sketches_dir(), &live_sketches, is_ron)?; 277 let live_extrudes = tree_extrudes 278 .iter() 279 .copied() 280 .map(extrude_filename) 281 .collect(); 282 remove_stale_files(&folder.extrudes_dir(), &live_extrudes, is_ron)?; 283 let live_bodies = tree_body_ids(document.header()) 284 .iter() 285 .flat_map(|id| [body_brep_filename(*id), body_labels_filename(*id)]) 286 .collect(); 287 remove_stale_files(&folder.bodies_dir(), &live_bodies, is_body_blob)?; 288 289 Ok(()) 290} 291 292fn write_body(folder: &DocumentFolder, id: BodyId, solid: &BrepSolid) -> Result<(), FolderError> { 293 let brep_path = folder.body_brep_path(id); 294 let labels_path = folder.body_labels_path(id); 295 let blob = solid.to_blob().map_err(|source| { 296 FolderErrorKind::Blob { 297 path: brep_path.clone(), 298 source, 299 } 300 .wrap() 301 })?; 302 let sidecar = LabelSidecar::capture(solid).to_ron().map_err(|source| { 303 FolderErrorKind::Ron { 304 path: labels_path.clone(), 305 source, 306 } 307 .wrap() 308 })?; 309 write_bytes_if_different(&brep_path, &blob)?; 310 write_bytes_if_different(&labels_path, sidecar.as_bytes()) 311} 312 313fn is_ron(ext: &str) -> bool { 314 ext.eq_ignore_ascii_case("ron") 315} 316 317fn is_body_blob(ext: &str) -> bool { 318 ext.eq_ignore_ascii_case("brep") || ext.eq_ignore_ascii_case("labels") 319} 320 321fn tree_extrude_ids(header: &DocumentHeader) -> BTreeSet<ExtrudeId> { 322 header 323 .feature_tree 324 .iter() 325 .filter_map(|(_, node)| match node { 326 FeatureNode::Extrude(id) => Some(id), 327 FeatureNode::Origin 328 | FeatureNode::PrincipalPlane(_) 329 | FeatureNode::Sketch(_) 330 | FeatureNode::ImportedBody(_) => None, 331 }) 332 .collect() 333} 334 335fn tree_body_ids(header: &DocumentHeader) -> BTreeSet<BodyId> { 336 header 337 .feature_tree 338 .iter() 339 .filter_map(|(_, node)| match node { 340 FeatureNode::ImportedBody(id) => Some(id), 341 FeatureNode::Origin 342 | FeatureNode::PrincipalPlane(_) 343 | FeatureNode::Sketch(_) 344 | FeatureNode::Extrude(_) => None, 345 }) 346 .collect() 347} 348 349pub fn load(folder: &DocumentFolder) -> Result<Document, FolderError> { 350 let header_path = folder.document_file(); 351 let header_text = read_to_string(&header_path)?; 352 check_schema(&peek_schema(&header_path, &header_text)?)?; 353 let mut header: DocumentHeader = from_ron(&header_path, &header_text)?; 354 let (extrudes, extrude_labels) = read_extrudes(folder, &header)?; 355 header.extrudes = extrudes; 356 header.extrude_labels = extrude_labels; 357 validate_header(&header)?; 358 359 let sketches = 360 header 361 .sketches 362 .order() 363 .iter() 364 .copied() 365 .try_fold(BTreeMap::new(), |mut acc, id| { 366 let path = folder.sketch_path(id); 367 let text = read_to_string(&path).map_err(|e| match e.into_kind() { 368 FolderErrorKind::Io { source, .. } 369 if source.kind() == io::ErrorKind::NotFound => 370 { 371 FolderErrorKind::MissingSketchFile { id }.wrap() 372 } 373 other => other.wrap(), 374 })?; 375 let file: SketchFile = from_ron(&path, &text)?; 376 check_schema(&file.schema)?; 377 file.sketch.validate().map_err(|source| { 378 FolderErrorKind::SketchIntegrity { 379 path: path.clone(), 380 source, 381 } 382 .wrap() 383 })?; 384 acc.insert(id, file.sketch); 385 Ok::<_, FolderError>(acc) 386 })?; 387 388 let bodies = read_bodies(folder, &header)?; 389 390 let document = Document::from_parts(header, sketches, bodies); 391 ensure_acyclic(document.feature_tree())?; 392 ensure_ordered(document.feature_tree())?; 393 Ok(document) 394} 395 396fn ensure_acyclic(tree: &FeatureTree) -> Result<(), FolderError> { 397 match tree.find_cycle() { 398 Some(id) => Err(FolderErrorKind::FeatureCycle { id }.wrap()), 399 None => Ok(()), 400 } 401} 402 403fn ensure_ordered(tree: &FeatureTree) -> Result<(), FolderError> { 404 match tree.order_violation() { 405 Some(edge) => { 406 let (parent, child) = edge.endpoints(); 407 Err(FolderErrorKind::FeatureOrderViolation { parent, child }.wrap()) 408 } 409 None => Ok(()), 410 } 411} 412 413fn read_bodies( 414 folder: &DocumentFolder, 415 header: &DocumentHeader, 416) -> Result<BTreeMap<BodyId, ImportedSolid>, FolderError> { 417 tree_body_ids(header) 418 .into_iter() 419 .try_fold(BTreeMap::new(), |mut acc, id| { 420 acc.insert(id, ImportedSolid::new(read_body(folder, id)?)); 421 Ok::<_, FolderError>(acc) 422 }) 423} 424 425fn read_body(folder: &DocumentFolder, id: BodyId) -> Result<BrepSolid, FolderError> { 426 let brep_path = folder.body_brep_path(id); 427 let labels_path = folder.body_labels_path(id); 428 let blob = read_bytes(&brep_path).map_err(|e| missing_body(e, id))?; 429 let sidecar_text = read_to_string(&labels_path).map_err(|e| missing_body(e, id))?; 430 let sidecar = LabelSidecar::from_ron(&sidecar_text).map_err(|source| { 431 FolderErrorKind::Ron { 432 path: labels_path.clone(), 433 source, 434 } 435 .wrap() 436 })?; 437 let solid = BrepSolid::from_blob(&blob, sidecar.reattach()).map_err(|source| { 438 FolderErrorKind::Blob { 439 path: brep_path, 440 source, 441 } 442 .wrap() 443 })?; 444 if sidecar.matches(solid.content_key()) { 445 Ok(solid) 446 } else { 447 Err(FolderErrorKind::SidecarMismatch { path: labels_path }.wrap()) 448 } 449} 450 451fn missing_body(error: FolderError, id: BodyId) -> FolderError { 452 match error.into_kind() { 453 FolderErrorKind::Io { source, .. } if source.kind() == io::ErrorKind::NotFound => { 454 FolderErrorKind::MissingBodyFile { id }.wrap() 455 } 456 other => other.wrap(), 457 } 458} 459 460type ExtrudeData = ( 461 BTreeMap<ExtrudeId, ExtrudeFeature>, 462 BTreeMap<ExtrudeId, String>, 463); 464 465fn read_extrudes( 466 folder: &DocumentFolder, 467 header: &DocumentHeader, 468) -> Result<ExtrudeData, FolderError> { 469 tree_extrude_ids(header).into_iter().try_fold( 470 (BTreeMap::new(), BTreeMap::new()), 471 |(mut features, mut labels), id| { 472 let path = folder.extrude_path(id); 473 let text = read_to_string(&path).map_err(|e| match e.into_kind() { 474 FolderErrorKind::Io { source, .. } if source.kind() == io::ErrorKind::NotFound => { 475 FolderErrorKind::MissingExtrudeFile { id }.wrap() 476 } 477 other => other.wrap(), 478 })?; 479 let file: ExtrudeFile = from_ron(&path, &text)?; 480 check_schema(&file.schema)?; 481 features.insert(id, file.feature); 482 labels.insert(id, file.label); 483 Ok::<_, FolderError>((features, labels)) 484 }, 485 ) 486} 487 488fn validate_header(header: &DocumentHeader) -> Result<(), FolderError> { 489 let tree = &header.feature_tree; 490 491 let duplicate = tree 492 .iter() 493 .map(|(id, _)| id) 494 .scan(BTreeSet::new(), |seen, id| Some((id, !seen.insert(id)))) 495 .find_map(|(id, repeated)| repeated.then_some(id)); 496 if let Some(id) = duplicate { 497 return Err(FolderErrorKind::DuplicateFeatureId { id }.wrap()); 498 } 499 500 let registered: BTreeSet<SketchId> = header.sketches.order().iter().copied().collect(); 501 let tree_sketches: BTreeSet<SketchId> = tree 502 .iter() 503 .filter_map(|(_, node)| match node { 504 FeatureNode::Sketch(id) => Some(id), 505 FeatureNode::Origin 506 | FeatureNode::PrincipalPlane(_) 507 | FeatureNode::Extrude(_) 508 | FeatureNode::ImportedBody(_) => None, 509 }) 510 .collect(); 511 if let Some(&id) = tree_sketches.difference(&registered).next() { 512 return Err(FolderErrorKind::DanglingTreeSketch { id }.wrap()); 513 } 514 if let Some(&id) = registered.difference(&tree_sketches).next() { 515 return Err(FolderErrorKind::OrphanRegistered { id }.wrap()); 516 } 517 518 if let Some((&extrude, feature)) = header 519 .extrudes 520 .iter() 521 .find(|(_, feature)| !registered.contains(&feature.sketch)) 522 { 523 return Err(FolderErrorKind::DanglingExtrudeSketch { 524 extrude, 525 sketch: feature.sketch, 526 } 527 .wrap()); 528 } 529 530 let live: BTreeSet<FeatureId> = tree.iter().map(|(id, _)| id).collect(); 531 if let Some(id) = header.rollback.feature() { 532 if !live.contains(&id) { 533 return Err(FolderErrorKind::DanglingRollback { id }.wrap()); 534 } 535 if tree.is_datum(id) { 536 return Err(FolderErrorKind::RollbackOnDatum { id }.wrap()); 537 } 538 } 539 if let Some(&id) = header.suppressed.difference(&live).next() { 540 return Err(FolderErrorKind::DanglingSuppressed { id }.wrap()); 541 } 542 if let Some(&id) = header.suppressed.iter().find(|&&id| tree.is_datum(id)) { 543 return Err(FolderErrorKind::SuppressedDatum { id }.wrap()); 544 } 545 546 Ok(()) 547} 548 549#[derive(serde::Deserialize)] 550#[serde(rename = "DocumentHeader")] 551struct SchemaProbe { 552 schema: SchemaHeader, 553} 554 555fn peek_schema(path: &Path, text: &str) -> Result<SchemaHeader, FolderError> { 556 from_ron::<SchemaProbe>(path, text).map(|probe| probe.schema) 557} 558 559fn check_schema(schema: &SchemaHeader) -> Result<(), FolderError> { 560 if !schema.is_bone_document() { 561 return Err(FolderErrorKind::UnknownSchema { 562 found: schema.name.clone(), 563 expected_name: SchemaHeader::BONE_DOCUMENT_NAME, 564 } 565 .wrap()); 566 } 567 let supported = SchemaVersion::new( 568 SchemaHeader::BONE_DOCUMENT_MAJOR, 569 SchemaHeader::BONE_DOCUMENT_MINOR, 570 ); 571 if schema.version.major != SchemaHeader::BONE_DOCUMENT_MAJOR { 572 return Err(FolderErrorKind::UnsupportedMajor { 573 name: schema.name.clone(), 574 found: schema.version, 575 supported, 576 } 577 .wrap()); 578 } 579 if schema.version.minor > SchemaHeader::BONE_DOCUMENT_MINOR { 580 tracing::info!( 581 name = %schema.name, 582 found = %schema.version, 583 supported = %supported, 584 "accepting a newer minor schema version than this build writes" 585 ); 586 } 587 Ok(()) 588} 589 590pub(crate) fn ensure_scaffold(folder: &DocumentFolder) -> Result<(), FolderError> { 591 write_if_different(&folder.path().join(".gitignore"), ROOT_GITIGNORE)?; 592 write_if_different(&folder.path().join(".gitattributes"), ROOT_GITATTRIBUTES)?; 593 write_if_different(&folder.caches_dir().join("CACHEDIR.TAG"), CACHEDIR_TAG)?; 594 write_if_different(&folder.caches_dir().join(".gitignore"), CACHES_GITIGNORE) 595} 596 597pub(crate) fn ensure_dir(path: &Path) -> Result<(), FolderError> { 598 fs::create_dir_all(path).map_err(|source| { 599 FolderErrorKind::Io { 600 path: path.to_path_buf(), 601 source, 602 } 603 .wrap() 604 }) 605} 606 607fn write_if_different(path: &Path, contents: &str) -> Result<(), FolderError> { 608 if let Ok(existing) = fs::read_to_string(path) 609 && existing == contents 610 { 611 return Ok(()); 612 } 613 atomic_write(path, contents) 614} 615 616fn write_bytes_if_different(path: &Path, contents: &[u8]) -> Result<(), FolderError> { 617 if let Ok(existing) = fs::read(path) 618 && existing == contents 619 { 620 return Ok(()); 621 } 622 atomic_write_bytes(path, contents) 623} 624 625pub(crate) fn write_if_absent(path: &Path, contents: &[u8]) -> Result<(), FolderError> { 626 match path.try_exists() { 627 Ok(true) => Ok(()), 628 Ok(false) => atomic_write_bytes(path, contents), 629 Err(source) => Err(FolderErrorKind::Io { 630 path: path.to_path_buf(), 631 source, 632 } 633 .wrap()), 634 } 635} 636 637fn atomic_write(path: &Path, contents: &str) -> Result<(), FolderError> { 638 atomic_write_bytes(path, contents.as_bytes()) 639} 640 641#[derive(Copy, Clone)] 642enum Durability { 643 Fsync, 644 Fast, 645} 646 647pub(crate) fn atomic_write_bytes(path: &Path, contents: &[u8]) -> Result<(), FolderError> { 648 atomic_write_with(path, contents, Durability::Fsync) 649} 650 651pub(crate) fn atomic_write_cache(path: &Path, contents: &[u8]) -> Result<(), FolderError> { 652 atomic_write_with(path, contents, Durability::Fast) 653} 654 655fn atomic_write_with( 656 path: &Path, 657 contents: &[u8], 658 durability: Durability, 659) -> Result<(), FolderError> { 660 let parent = path.parent().unwrap_or_else(|| Path::new(".")); 661 ensure_dir(parent)?; 662 let tmp = tmp_sibling(path); 663 write_tmp(&tmp, contents, durability)?; 664 fs::rename(&tmp, path).map_err(|source| { 665 let _ = fs::remove_file(&tmp); 666 FolderErrorKind::Io { 667 path: path.to_path_buf(), 668 source, 669 } 670 .wrap() 671 })?; 672 match durability { 673 Durability::Fsync => sync_dir(parent), 674 Durability::Fast => Ok(()), 675 } 676} 677 678fn write_tmp(path: &Path, contents: &[u8], durability: Durability) -> Result<(), FolderError> { 679 let mut file = fs::File::create(path).map_err(|source| { 680 FolderErrorKind::Io { 681 path: path.to_path_buf(), 682 source, 683 } 684 .wrap() 685 })?; 686 file.write_all(contents).map_err(|source| { 687 FolderErrorKind::Io { 688 path: path.to_path_buf(), 689 source, 690 } 691 .wrap() 692 })?; 693 match durability { 694 Durability::Fsync => file.sync_all().map_err(|source| { 695 FolderErrorKind::Io { 696 path: path.to_path_buf(), 697 source, 698 } 699 .wrap() 700 }), 701 Durability::Fast => Ok(()), 702 } 703} 704 705pub(crate) fn read_bytes(path: &Path) -> Result<Vec<u8>, FolderError> { 706 fs::read(path).map_err(|source| { 707 FolderErrorKind::Io { 708 path: path.to_path_buf(), 709 source, 710 } 711 .wrap() 712 }) 713} 714 715#[cfg(unix)] 716fn sync_dir(path: &Path) -> Result<(), FolderError> { 717 fs::File::open(path) 718 .and_then(|f| f.sync_all()) 719 .map_err(|source| { 720 FolderErrorKind::Io { 721 path: path.to_path_buf(), 722 source, 723 } 724 .wrap() 725 }) 726} 727 728#[cfg(not(unix))] 729fn sync_dir(_: &Path) -> Result<(), FolderError> { 730 Ok(()) 731} 732 733static TMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); 734 735fn tmp_sibling(path: &Path) -> PathBuf { 736 let file_name = path 737 .file_name() 738 .map(std::ffi::OsStr::to_os_string) 739 .unwrap_or_default(); 740 let sequence = TMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); 741 let mut tmp_name = file_name; 742 tmp_name.push(format!(".{}.{sequence}.tmp", std::process::id())); 743 path.with_file_name(tmp_name) 744} 745 746pub(crate) fn read_to_string(path: &Path) -> Result<String, FolderError> { 747 fs::read_to_string(path).map_err(|source| { 748 FolderErrorKind::Io { 749 path: path.to_path_buf(), 750 source, 751 } 752 .wrap() 753 }) 754} 755 756fn to_ron<T: serde::Serialize>(path: &Path, value: &T) -> Result<String, FolderError> { 757 to_string(value).map_err(|source| { 758 FolderErrorKind::Ron { 759 path: path.to_path_buf(), 760 source, 761 } 762 .wrap() 763 }) 764} 765 766fn from_ron<T: serde::de::DeserializeOwned>(path: &Path, text: &str) -> Result<T, FolderError> { 767 from_str(text).map_err(|source| { 768 FolderErrorKind::Ron { 769 path: path.to_path_buf(), 770 source, 771 } 772 .wrap() 773 }) 774} 775 776fn remove_stale_files( 777 dir: &Path, 778 live_names: &BTreeSet<String>, 779 is_managed_ext: impl Fn(&str) -> bool, 780) -> Result<(), FolderError> { 781 let entries = match fs::read_dir(dir) { 782 Ok(iter) => iter, 783 Err(ref source) if source.kind() == io::ErrorKind::NotFound => return Ok(()), 784 Err(source) => { 785 return Err(FolderErrorKind::Io { 786 path: dir.to_path_buf(), 787 source, 788 } 789 .into()); 790 } 791 }; 792 let modified = entries.into_iter().try_fold(false, |modified, entry| { 793 let entry = entry.map_err(|source| { 794 FolderErrorKind::Io { 795 path: dir.to_path_buf(), 796 source, 797 } 798 .wrap() 799 })?; 800 let name = entry.file_name().to_string_lossy().into_owned(); 801 let stale = Path::new(&name) 802 .extension() 803 .and_then(|ext| ext.to_str()) 804 .is_some_and(&is_managed_ext) 805 && !live_names.contains(&name); 806 if stale { 807 let path = entry.path(); 808 fs::remove_file(&path).map_err(|source| FolderErrorKind::Io { path, source }.wrap())?; 809 Ok::<_, FolderError>(true) 810 } else { 811 Ok(modified) 812 } 813 })?; 814 if modified { 815 sync_dir(dir)?; 816 } 817 Ok(()) 818} 819 820#[cfg(test)] 821mod tree_sourced_save { 822 use super::{DocumentFolder, ensure_dir, load, save}; 823 use crate::document::{Document, DocumentHeader}; 824 use bone_kernel::{ 825 ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, ExtrudeSense, MergeResult, 826 }; 827 use bone_types::{DocumentId, ExtrudeId, Length, PositiveLength, SketchId, millimeter}; 828 use slotmap::{Key, KeyData}; 829 use std::collections::BTreeMap; 830 831 fn extrude_id(idx: u32) -> ExtrudeId { 832 ExtrudeId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) 833 } 834 835 fn document_id(idx: u32) -> DocumentId { 836 DocumentId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) 837 } 838 839 fn blind(sketch: SketchId) -> ExtrudeFeature { 840 let Ok(depth) = PositiveLength::new(Length::new::<millimeter>(10.0)) else { 841 panic!("positive depth"); 842 }; 843 ExtrudeFeature { 844 sketch, 845 direction: ExtrudeDirection::Normal { 846 sense: ExtrudeSense::Forward, 847 }, 848 end_condition: ExtrudeEndCondition::Blind { depth }, 849 draft: None, 850 thin_wall: None, 851 merge_result: MergeResult::Merge, 852 } 853 } 854 855 #[test] 856 fn extrude_in_map_without_tree_node_is_not_persisted_and_stale_file_is_reaped() { 857 let Ok(dir) = tempfile::tempdir() else { 858 panic!("tempdir"); 859 }; 860 let folder = DocumentFolder::new(dir.path().join("orphan.bone")); 861 862 let mut header = DocumentHeader::new(document_id(1), "orphan".to_owned()); 863 let orphan = extrude_id(1); 864 header.extrudes.insert(orphan, blind(SketchId::null())); 865 assert!( 866 header.feature_tree.feature_of_extrude(orphan).is_none(), 867 "the orphan starts with no feature-tree node" 868 ); 869 let doc = Document::from_parts(header, BTreeMap::new(), BTreeMap::new()); 870 871 let Ok(()) = save(&doc, &folder) else { 872 panic!("save"); 873 }; 874 assert!( 875 !folder.extrude_path(orphan).exists(), 876 "an extrude absent from the feature tree is not part of the document and is not written" 877 ); 878 879 let Ok(()) = ensure_dir(&folder.extrudes_dir()) else { 880 panic!("extrudes dir"); 881 }; 882 let Ok(()) = std::fs::write(folder.extrude_path(orphan), "ExtrudeFile()") else { 883 panic!("plant stale file"); 884 }; 885 let Ok(()) = save(&doc, &folder) else { 886 panic!("resave"); 887 }; 888 assert!( 889 !folder.extrude_path(orphan).exists(), 890 "a stale extrude file with no tree node is reaped even while the map still lists it" 891 ); 892 893 let Ok(loaded) = load(&folder) else { 894 panic!("load"); 895 }; 896 assert!(!loaded.header().extrudes.contains_key(&orphan)); 897 } 898 899 #[test] 900 fn validate_header_rejects_duplicate_feature_id() { 901 use super::{FolderErrorKind, validate_header}; 902 use bone_types::BodyId; 903 904 let mut header = DocumentHeader::new(document_id(1), "dup".to_owned()); 905 let Some((existing, _)) = header.feature_tree.iter().next() else { 906 panic!("the seeded tree has feature nodes"); 907 }; 908 let body = BodyId::from(KeyData::from_ffi((1u64 << 32) | 1)); 909 header.feature_tree.push_imported_body(existing, body); 910 911 let Err(err) = validate_header(&header) else { 912 panic!("a feature tree with a repeated id must be rejected"); 913 }; 914 assert!(matches!( 915 err.into_kind(), 916 FolderErrorKind::DuplicateFeatureId { id } if id == existing 917 )); 918 } 919 920 #[test] 921 fn validate_header_rejects_rollback_on_a_datum() { 922 use super::{FolderErrorKind, validate_header}; 923 use bone_types::RollbackMarker; 924 925 let mut header = DocumentHeader::new(document_id(1), "datum".to_owned()); 926 let Some((datum, _)) = header.feature_tree.iter().next() else { 927 panic!("the seeded tree opens with a datum"); 928 }; 929 header.rollback = RollbackMarker::Above(datum); 930 931 let Err(err) = validate_header(&header) else { 932 panic!("a rollback marker sitting on a datum must be rejected"); 933 }; 934 assert!(matches!( 935 err.into_kind(), 936 FolderErrorKind::RollbackOnDatum { id } if id == datum 937 )); 938 } 939 940 #[test] 941 fn ensure_ordered_rejects_a_child_before_its_parent() { 942 use super::{FolderErrorKind, ensure_ordered}; 943 use crate::document::Document; 944 945 let mut header = DocumentHeader::new(document_id(1), "order".to_owned()); 946 let sketch = SketchId::from(KeyData::from_ffi((1u64 << 32) | 1)); 947 let sketch_feature = header.feature_tree.push_sketch(sketch); 948 let extrude = extrude_id(1); 949 let extrude_feature = header.feature_tree.push_extrude(extrude, &blind(sketch)); 950 header.extrudes.insert(extrude, blind(sketch)); 951 header 952 .feature_tree 953 .move_before(extrude_feature, sketch_feature); 954 955 let doc = Document::from_parts(header, BTreeMap::new(), BTreeMap::new()); 956 let Err(err) = ensure_ordered(doc.feature_tree()) else { 957 panic!("a child ordered before its parent must be rejected"); 958 }; 959 assert!(matches!( 960 err.into_kind(), 961 FolderErrorKind::FeatureOrderViolation { parent, child } 962 if parent == sketch_feature && child == extrude_feature 963 )); 964 } 965}