Another project
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(®istered).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}