hdf5_pure/writer.rs
1//! Writing API: FileBuilder and GroupBuilder for creating HDF5 files.
2
3use std::io::Write;
4
5use crate::chunked_write::ByteSink;
6use crate::file_writer::FileWriter as FormatWriter;
7use crate::type_builders::{
8 AttrValue, DatasetBuilder as FormatDatasetBuilder, FinishedGroup,
9 GroupBuilder as FormatGroupBuilder,
10};
11
12use crate::error::{Error, FormatError};
13use crate::file_create_properties::FileCreateProperties;
14use crate::file_space_info::FileSpaceStrategy;
15use crate::libver::LibVer;
16
17/// Builder for creating a new HDF5 file.
18///
19/// # Example
20///
21/// ```no_run
22/// use hdf5_pure::FileBuilder;
23/// use hdf5_pure::AttrValue;
24///
25/// let mut builder = FileBuilder::new();
26/// builder.create_dataset("data").with_f64_data(&[1.0, 2.0, 3.0]);
27/// builder.set_attr("version", AttrValue::I64(1));
28/// builder.write("output.h5").unwrap();
29/// ```
30pub struct FileBuilder {
31 writer: FormatWriter,
32}
33
34impl FileBuilder {
35 /// Create a new file builder.
36 pub fn new() -> Self {
37 Self {
38 writer: FormatWriter::new(),
39 }
40 }
41
42 /// Create a dataset at the root level. Returns a mutable reference to
43 /// a `DatasetBuilder` for configuring data, shape, and attributes.
44 pub fn create_dataset(&mut self, name: &str) -> &mut FormatDatasetBuilder {
45 self.writer.create_dataset(name)
46 }
47
48 /// Create a group builder. Call `.finish()` on the returned builder
49 /// to complete it, then pass to `add_group()`.
50 pub fn create_group(&mut self, name: &str) -> FormatGroupBuilder {
51 self.writer.create_group(name)
52 }
53
54 /// Add a finished group to the file.
55 pub fn add_group(&mut self, group: FinishedGroup) {
56 self.writer.add_group(group);
57 }
58
59 /// Apply every creation property in `properties` at once — the `fcpl`
60 /// analogue of handing a property list to `H5Fcreate`.
61 ///
62 /// Each property is applied exactly as the individual setter would, so this
63 /// **overwrites** any value set individually before the call. The two
64 /// spellings interoperate: apply a shared [`FileCreateProperties`] first,
65 /// then override one property for this file.
66 #[doc(alias = "fcpl")]
67 pub fn with_create_properties(&mut self, properties: FileCreateProperties) -> &mut Self {
68 self.with_userblock(properties.userblock());
69 if let Some((low, high)) = properties.libver_bounds() {
70 self.with_libver_bounds(low, high);
71 }
72 if let Some((strategy, persist, threshold)) = properties.file_space_strategy() {
73 self.with_file_space_strategy(strategy, persist, threshold);
74 }
75 if let Some(page_size) = properties.file_space_page_size() {
76 self.with_file_space_page_size(page_size);
77 }
78 self
79 }
80
81 /// Former name of [`with_create_properties`](Self::with_create_properties).
82 #[deprecated(since = "0.26.0", note = "renamed to `with_create_properties`")]
83 pub fn with_create_options(&mut self, properties: FileCreateProperties) -> &mut Self {
84 self.with_create_properties(properties)
85 }
86
87 /// Set the userblock size in bytes: zero (no userblock), or a power of two of
88 /// at least 512. The region is filled with zeros.
89 ///
90 /// Any other size is refused by [`finish`](Self::finish) /
91 /// [`finish_to`](Self::finish_to) / [`write`](Self::write) with
92 /// [`FormatError::InvalidUserblockSize`](crate::FormatError::InvalidUserblockSize).
93 /// The size *is* the superblock's base address, and a reader scans for the
94 /// signature at 0, 512, 1024, and so on doubling — so an unaligned size would
95 /// hide the superblock where nothing looks for it.
96 ///
97 /// To put something in it, prefer
98 /// [`with_userblock_content`](Self::with_userblock_content), which works on
99 /// every output path. Patching the bytes afterwards only works with the
100 /// buffered [`finish`](Self::finish); the streaming
101 /// [`finish_to`](Self::finish_to) / [`write`](Self::write) have already emitted
102 /// the region by the time they return.
103 pub fn with_userblock(&mut self, size: u64) -> &mut Self {
104 self.writer.with_userblock(size);
105 self
106 }
107
108 /// Set the bytes that occupy the head of the userblock region, so the writer
109 /// emits them as part of the file. The remainder of the region stays
110 /// zero-filled, and content longer than the userblock set by
111 /// [`with_userblock`](Self::with_userblock) is refused by every output path —
112 /// [`finish`](Self::finish), [`finish_to`](Self::finish_to), and
113 /// [`write`](Self::write) — with
114 /// [`FormatError::UserblockContentTooLarge`](crate::FormatError::UserblockContentTooLarge).
115 ///
116 /// Because the userblock leads the file in address order, this is what lets a
117 /// wrapper format's header — MATLAB v7.3's, for instance — be produced by the
118 /// non-seekable [`finish_to`](Self::finish_to) with no second pass.
119 ///
120 /// # Example
121 ///
122 /// ```
123 /// use hdf5_pure::FileBuilder;
124 ///
125 /// let mut builder = FileBuilder::new();
126 /// builder.with_userblock(512);
127 /// builder.with_userblock_content(b"my wrapper format's header");
128 /// builder.create_dataset("x").with_f64_data(&[1.0, 2.0]);
129 ///
130 /// let bytes = builder.finish().unwrap();
131 /// assert_eq!(&bytes[..26], b"my wrapper format's header");
132 /// // The rest of the region is zero-filled, and the HDF5 signature follows it.
133 /// assert!(bytes[26..512].iter().all(|&b| b == 0));
134 /// assert_eq!(&bytes[512..516], b"\x89HDF");
135 /// ```
136 pub fn with_userblock_content(&mut self, content: &[u8]) -> &mut Self {
137 self.writer.with_userblock_content(content);
138 self
139 }
140
141 /// Constrain the on-disk format version of the file, mirroring HDF5's
142 /// `H5Pset_libver_bounds`. The produced file must fall within `[low, high]`,
143 /// or [`finish`](Self::finish) / [`write`](Self::write) fails with
144 /// [`Error::Format`] wrapping
145 /// [`FormatError::LibverBoundsUnsatisfiable`](crate::FormatError::LibverBoundsUnsatisfiable).
146 ///
147 /// This crate writes exactly one format — the version 3 superblock from
148 /// HDF5 1.10 ([`LibVer::WRITER_OUTPUT`]) — so this is a compatibility
149 /// assertion, not a format selector: a bound that excludes 1.10 (an upper
150 /// bound older than it, or a lower bound newer than it) is rejected.
151 pub fn with_libver_bounds(&mut self, low: LibVer, high: LibVer) -> &mut Self {
152 self.writer.with_libver_bounds(low, high);
153 self
154 }
155
156 /// Set the file-space management strategy, mirroring HDF5's
157 /// `H5Pset_file_space_strategy`. The strategy, persist flag, and free-space
158 /// section `threshold` are recorded in the file's superblock extension, so
159 /// the reference C library and a later reopen observe the choice.
160 ///
161 /// `persist = true` records that freed space should be tracked on disk across
162 /// closes. A brand-new file has nothing to track, so this only records the
163 /// intent; freeing space in a later [`File::open_rw`](crate::File::open_rw) then
164 /// writes the on-disk free-space-manager blocks that survive a reopen.
165 pub fn with_file_space_strategy(
166 &mut self,
167 strategy: FileSpaceStrategy,
168 persist: bool,
169 threshold: u64,
170 ) -> &mut Self {
171 self.writer
172 .with_file_space_strategy(strategy, persist, threshold);
173 self
174 }
175
176 /// Set the file-space page size, mirroring HDF5's
177 /// `H5Pset_file_space_page_size`. Recorded in the superblock extension.
178 pub fn with_file_space_page_size(&mut self, page_size: u64) -> &mut Self {
179 self.writer.with_file_space_page_size(page_size);
180 self
181 }
182
183 /// Set an attribute on the root group.
184 pub fn set_attr(&mut self, name: &str, value: AttrValue) {
185 self.writer.set_root_attr(name, value);
186 }
187
188 /// Serialize the file to bytes in memory.
189 pub fn finish(self) -> Result<Vec<u8>, Error> {
190 Ok(self.writer.finish()?)
191 }
192
193 /// Serialize the file directly to a [`Write`] sink, without first buffering
194 /// the whole file in memory.
195 ///
196 /// Produces byte-for-byte the same file as [`finish`](Self::finish), but a
197 /// dataset staged for verbatim chunk *streaming* (repack's out-of-core path)
198 /// has its chunks pulled from the source and written one at a time, so peak
199 /// memory stays bounded by a single chunk plus the file metadata rather than
200 /// the whole dataset.
201 ///
202 /// The sink is written front-to-back and never seeked, so it can be a socket
203 /// or a pipe as readily as a file. That is possible because the writer
204 /// computes every object's address before it emits a byte, rather than
205 /// seeking back to patch addresses the way a backpatching writer would.
206 ///
207 /// A failure partway leaves whatever was already written on the sink. With a
208 /// non-seekable sink there is nothing to roll back, so a caller needing
209 /// all-or-nothing should write to a temporary path and rename on success.
210 ///
211 /// # Example
212 ///
213 /// ```
214 /// use hdf5_pure::FileBuilder;
215 ///
216 /// let build = || {
217 /// let mut b = FileBuilder::new();
218 /// b.create_dataset("x").with_f64_data(&[1.0, 2.0, 3.0]);
219 /// b
220 /// };
221 ///
222 /// let mut streamed: Vec<u8> = Vec::new();
223 /// build().finish_to(&mut streamed).unwrap();
224 /// assert_eq!(build().finish().unwrap(), streamed);
225 /// ```
226 pub fn finish_to<W: Write>(self, w: W) -> Result<(), Error> {
227 let mut sink = WriteSink::new(std::io::BufWriter::new(w));
228 if let Err(fe) = self.writer.finish_to_sink(&mut sink) {
229 // If the failure came from the sink's I/O, surface the real
230 // `io::Error`; otherwise it is a genuine format error.
231 return match sink.err.take() {
232 Some(io_err) => Err(Error::Io(io_err)),
233 None => Err(Error::Format(fe)),
234 };
235 }
236 sink.into_inner().flush().map_err(Error::Io)
237 }
238
239 /// Serialize and write the file to the given path.
240 ///
241 /// Streams the file to disk (see [`finish_to`](Self::finish_to)), so a repack
242 /// staging streamed chunks does not hold the whole output in memory.
243 pub fn write<P: AsRef<std::path::Path>>(self, path: P) -> Result<(), Error> {
244 let file = std::fs::File::create(path).map_err(Error::Io)?;
245 self.finish_to(file)
246 }
247}
248
249/// Adapts a [`std::io::Write`] to the writer's [`ByteSink`] so a file can be
250/// assembled straight onto the sink. Because `ByteSink` is `no_std` and cannot
251/// carry a `std::io::Error`, an I/O failure is stashed here and the surrounding
252/// [`FileBuilder::finish_to`] turns it back into [`Error::Io`].
253struct WriteSink<W: Write> {
254 inner: W,
255 written: u64,
256 err: Option<std::io::Error>,
257}
258
259impl<W: Write> WriteSink<W> {
260 fn new(inner: W) -> Self {
261 Self {
262 inner,
263 written: 0,
264 err: None,
265 }
266 }
267
268 fn into_inner(self) -> W {
269 self.inner
270 }
271}
272
273impl<W: Write> ByteSink for WriteSink<W> {
274 fn put(&mut self, bytes: &[u8]) -> Result<(), FormatError> {
275 match self.inner.write_all(bytes) {
276 Ok(()) => {
277 self.written += bytes.len() as u64;
278 Ok(())
279 }
280 Err(e) => {
281 self.err = Some(e);
282 // A placeholder format error; `finish_to` replaces it with the
283 // stashed `io::Error` above, so its message is never surfaced.
284 Err(FormatError::SerializationError(
285 "streaming output write failed".into(),
286 ))
287 }
288 }
289 }
290
291 fn put_zeros(&mut self, n: usize) -> Result<(), FormatError> {
292 // Emit padding in bounded blocks so a large userblock never allocates a
293 // matching buffer.
294 const ZEROS: [u8; 4096] = [0u8; 4096];
295 let mut remaining = n;
296 while remaining > 0 {
297 let take = remaining.min(ZEROS.len());
298 self.put(&ZEROS[..take])?;
299 remaining -= take;
300 }
301 Ok(())
302 }
303
304 fn position(&self) -> u64 {
305 self.written
306 }
307}
308
309impl Default for FileBuilder {
310 fn default() -> Self {
311 Self::new()
312 }
313}
314
315#[cfg(test)]
316mod streaming_tests {
317 use super::*;
318 use crate::chunked_write::{ChunkMeta, ChunkProvider};
319 use std::sync::{Arc, Mutex};
320
321 type Calls = Arc<Mutex<Vec<usize>>>;
322
323 /// A test [`ChunkProvider`] serving fixed in-memory chunk bytes, recording
324 /// the order of `chunk_bytes` calls so a test can assert the streaming
325 /// writer pulls each chunk exactly once, in ascending slot order. With
326 /// `short_slot` set, that one slot returns one byte fewer than planned
327 /// (size-mismatch). `Arc<Mutex<_>>` (not `Rc<RefCell<_>>`) keeps it
328 /// `Send + Sync`, as the `ChunkProvider` supertrait requires.
329 struct MemProvider {
330 chunks: Vec<Vec<u8>>,
331 calls: Calls,
332 short_slot: Option<usize>,
333 }
334
335 impl ChunkProvider for MemProvider {
336 fn chunk_bytes(&self, index: usize, out: &mut Vec<u8>) -> Result<(), FormatError> {
337 self.calls.lock().unwrap().push(index);
338 assert!(
339 out.is_empty(),
340 "the emitter hands the provider an empty buffer"
341 );
342 out.extend_from_slice(&self.chunks[index]);
343 if self.short_slot == Some(index) {
344 out.pop();
345 }
346 Ok(())
347 }
348 }
349
350 fn f64_chunk(vals: &[f64]) -> Vec<u8> {
351 let mut v = Vec::new();
352 for &x in vals {
353 v.extend_from_slice(&x.to_le_bytes());
354 }
355 v
356 }
357
358 fn meta_of(chunk_bytes: &[Vec<u8>]) -> Vec<ChunkMeta> {
359 chunk_bytes
360 .iter()
361 .map(|c| ChunkMeta {
362 compressed_size: c.len() as u64,
363 filter_mask: 0,
364 })
365 .collect()
366 }
367
368 /// Stage one lazily-streamed, unfiltered chunked f64 dataset named `name` on
369 /// `b`. Unfiltered means the "compressed" bytes are the raw element bytes, so
370 /// the produced file is a plain chunked f64 dataset that reads back.
371 fn stage_lazy(
372 b: &mut FileBuilder,
373 name: &str,
374 chunk_bytes: Vec<Vec<u8>>,
375 dims: &[u64],
376 chunk_dims: &[u64],
377 maxshape: Option<&[u64]>,
378 calls: Calls,
379 short_slot: Option<usize>,
380 ) {
381 let meta = meta_of(&chunk_bytes);
382 let provider = MemProvider {
383 chunks: chunk_bytes,
384 calls,
385 short_slot,
386 };
387 b.create_dataset(name).with_raw_chunks_lazy(
388 crate::type_builders::make_f64_type(),
389 dims,
390 maxshape,
391 chunk_dims,
392 8,
393 None,
394 meta,
395 Box::new(provider),
396 );
397 }
398
399 /// Build a file with one lazily-streamed chunked f64 dataset named `d`.
400 fn build_lazy(
401 chunk_bytes: Vec<Vec<u8>>,
402 dims: &[u64],
403 chunk_dims: &[u64],
404 maxshape: Option<&[u64]>,
405 calls: Calls,
406 short_slot: Option<usize>,
407 ) -> FileBuilder {
408 let mut b = FileBuilder::new();
409 stage_lazy(
410 &mut b,
411 "d",
412 chunk_bytes,
413 dims,
414 chunk_dims,
415 maxshape,
416 calls,
417 short_slot,
418 );
419 b
420 }
421
422 fn read_back_f64(bytes: &[u8], path: &str) -> Vec<f64> {
423 let file = crate::reader::File::from_bytes(bytes.to_vec()).unwrap();
424 let raw = file.dataset(path).unwrap().read_raw().unwrap();
425 raw.chunks_exact(8)
426 .map(|b| f64::from_le_bytes(b.try_into().unwrap()))
427 .collect()
428 }
429
430 #[test]
431 fn streamed_output_matches_buffered_and_streams_one_chunk_at_a_time() {
432 let chunks = vec![
433 f64_chunk(&[1.0, 2.0]),
434 f64_chunk(&[3.0, 4.0]),
435 f64_chunk(&[5.0, 6.0]),
436 ];
437
438 let calls_buf = Arc::new(Mutex::new(Vec::new()));
439 let buffered = build_lazy(chunks.clone(), &[6], &[2], None, calls_buf.clone(), None)
440 .finish()
441 .unwrap();
442
443 let calls_str = Arc::new(Mutex::new(Vec::new()));
444 let mut streamed = Vec::new();
445 build_lazy(chunks.clone(), &[6], &[2], None, calls_str.clone(), None)
446 .finish_to(&mut streamed)
447 .unwrap();
448
449 // The streaming (io::Write) path and the buffered (Vec) path must produce
450 // byte-for-byte the same file.
451 assert_eq!(
452 buffered, streamed,
453 "streamed output must be byte-identical to buffered output"
454 );
455 // Each chunk is pulled exactly once, in ascending slot order — i.e. the
456 // writer streams chunk-by-chunk rather than collecting them all.
457 assert_eq!(*calls_buf.lock().unwrap(), vec![0, 1, 2]);
458 assert_eq!(*calls_str.lock().unwrap(), vec![0, 1, 2]);
459 // And the file reads back to the original values.
460 assert_eq!(
461 read_back_f64(&buffered, "d"),
462 vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
463 );
464 }
465
466 #[test]
467 fn file_builder_keeps_its_auto_traits() {
468 // The lazy chunk provider is boxed into `FileBuilder`; a bare boxed trait
469 // object would strip `Send`/`Sync` (fixed by the `ChunkProvider`
470 // supertrait) and `UnwindSafe`/`RefUnwindSafe` (fixed by wrapping it in
471 // `AssertUnwindSafe`). Removing any of these auto-trait impls is a semver
472 // break that `cargo-semver-checks` enforces in CI, so pin all four here.
473 fn assert_auto_traits<
474 T: Send + Sync + std::panic::UnwindSafe + std::panic::RefUnwindSafe,
475 >() {
476 }
477 assert_auto_traits::<FileBuilder>();
478 }
479
480 #[test]
481 fn streaming_writer_rejects_provider_size_mismatch() {
482 // A provider returning fewer bytes than planned — on slot 0 *or* any later
483 // slot — must be rejected rather than written as a corrupt file.
484 for short_slot in [0usize, 2] {
485 let chunks = vec![
486 f64_chunk(&[1.0, 2.0]),
487 f64_chunk(&[3.0, 4.0]),
488 f64_chunk(&[5.0, 6.0]),
489 ];
490 let calls = Arc::new(Mutex::new(Vec::new()));
491 let err = build_lazy(chunks, &[6], &[2], None, calls, Some(short_slot))
492 .finish()
493 .unwrap_err();
494 match err {
495 Error::Format(FormatError::ChunkedReadError(_)) => {}
496 other => panic!("slot {short_slot}: expected ChunkedReadError, got {other:?}"),
497 }
498 }
499 }
500
501 /// Assert the buffered and streamed outputs are byte-identical for one chunked
502 /// layout, and that the produced file reads back to `expected`.
503 fn assert_variant_streams_identically(
504 chunks: Vec<Vec<u8>>,
505 dims: &[u64],
506 chunk_dims: &[u64],
507 maxshape: Option<&[u64]>,
508 expected: &[f64],
509 ) {
510 let buffered = build_lazy(
511 chunks.clone(),
512 dims,
513 chunk_dims,
514 maxshape,
515 Arc::new(Mutex::new(Vec::new())),
516 None,
517 )
518 .finish()
519 .unwrap();
520 let mut streamed = Vec::new();
521 build_lazy(
522 chunks,
523 dims,
524 chunk_dims,
525 maxshape,
526 Arc::new(Mutex::new(Vec::new())),
527 None,
528 )
529 .finish_to(&mut streamed)
530 .unwrap();
531 assert_eq!(
532 buffered, streamed,
533 "index variant dims={dims:?} chunk={chunk_dims:?} must stream identically"
534 );
535 // The streamed file decodes to the expected values (not merely parses).
536 assert_eq!(
537 read_back_f64(&buffered, "d"),
538 expected,
539 "index variant dims={dims:?} chunk={chunk_dims:?} must read back correctly"
540 );
541 }
542
543 #[test]
544 fn streamed_equals_buffered_across_index_variants() {
545 // single-chunk, fixed-array (>1 chunk), and extensible-array (unlimited
546 // max shape) all lay out from sizes alone, so each must stream identically
547 // and read back to the right values.
548 assert_variant_streams_identically(
549 vec![f64_chunk(&[1.0, 2.0])],
550 &[2],
551 &[2],
552 None,
553 &[1.0, 2.0],
554 );
555 assert_variant_streams_identically(
556 (0..5)
557 .map(|i| f64_chunk(&[i as f64, i as f64 + 0.5]))
558 .collect(),
559 &[10],
560 &[2],
561 None,
562 &[0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5],
563 );
564 assert_variant_streams_identically(
565 vec![f64_chunk(&[1.0, 2.0]), f64_chunk(&[3.0, 4.0])],
566 &[4],
567 &[2],
568 Some(&[u64::MAX]),
569 &[1.0, 2.0, 3.0, 4.0],
570 );
571 }
572
573 /// A `Write` that accepts `limit` bytes total, then fails every later write —
574 /// to exercise the streaming I/O-error path.
575 struct FailAfter {
576 remaining: usize,
577 }
578 impl Write for FailAfter {
579 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
580 if self.remaining == 0 {
581 return Err(std::io::Error::other("write limit reached"));
582 }
583 let n = buf.len().min(self.remaining);
584 self.remaining -= n;
585 Ok(n)
586 }
587 fn flush(&mut self) -> std::io::Result<()> {
588 Ok(())
589 }
590 }
591
592 #[test]
593 fn streaming_io_error_surfaces_as_error_io() {
594 // A dataset large enough to exceed the internal BufWriter so writes occur
595 // mid-stream; the sink fails partway and `finish_to` must surface it as
596 // `Error::Io`, not a format error or a panic.
597 let chunks: Vec<Vec<u8>> = (0..12).map(|_| f64_chunk(&[1.0; 256])).collect(); // 12 * 2 KiB
598 let builder = build_lazy(
599 chunks,
600 &[3072],
601 &[256],
602 None,
603 Arc::new(Mutex::new(Vec::new())),
604 None,
605 );
606 let err = builder
607 .finish_to(FailAfter { remaining: 4096 })
608 .unwrap_err();
609 assert!(
610 matches!(err, Error::Io(_)),
611 "a failing sink must surface as Error::Io, got {err:?}"
612 );
613 }
614
615 #[test]
616 fn streamed_dataset_with_attribute_and_contiguous_sibling() {
617 // One file mixing a streamed (lazy chunked) dataset that also carries an
618 // attribute, a plain contiguous dataset, and a zero-element contiguous
619 // dataset — exercising the assembly loop's InMemory + Streamed dispatch and
620 // attribute handling together. Buffered and streamed must agree and read
621 // back.
622 let build = || {
623 let chunks = vec![f64_chunk(&[1.0, 2.0]), f64_chunk(&[3.0, 4.0])];
624 let meta = meta_of(&chunks);
625 let provider = MemProvider {
626 chunks,
627 calls: Arc::new(Mutex::new(Vec::new())),
628 short_slot: None,
629 };
630 let mut b = FileBuilder::new();
631 // Configure the one streamed dataset and its attribute on the same
632 // builder (a second `create_dataset` would add a *different* dataset).
633 b.create_dataset("chunked")
634 .with_raw_chunks_lazy(
635 crate::type_builders::make_f64_type(),
636 &[4],
637 None,
638 &[2],
639 8,
640 None,
641 meta,
642 Box::new(provider),
643 )
644 .set_attr("units", AttrValue::I64(7));
645 b.create_dataset("contig")
646 .with_f64_data(&[10.0, 11.0, 12.0]);
647 b.create_dataset("empty").with_f64_data(&[]);
648 b
649 };
650 let buffered = build().finish().unwrap();
651 let mut streamed = Vec::new();
652 build().finish_to(&mut streamed).unwrap();
653 assert_eq!(buffered, streamed, "mixed file must stream identically");
654 assert_eq!(
655 read_back_f64(&buffered, "chunked"),
656 vec![1.0, 2.0, 3.0, 4.0]
657 );
658 assert_eq!(read_back_f64(&buffered, "contig"), vec![10.0, 11.0, 12.0]);
659 }
660
661 /// Userblock content is part of the file the writer emits, so both output
662 /// paths carry it — which is the whole point of the setter, since the
663 /// streaming path has no bytes left to patch by the time it returns.
664 #[test]
665 fn userblock_content_leads_the_file_on_both_output_paths() {
666 const HEADER: &[u8] = b"a wrapper format's header";
667 let build = || {
668 let mut b = FileBuilder::new();
669 b.with_userblock(512).with_userblock_content(HEADER);
670 b.create_dataset("x").with_f64_data(&[1.0, 2.0]);
671 b
672 };
673 let buffered = build().finish().unwrap();
674 let mut streamed = Vec::new();
675 build().finish_to(&mut streamed).unwrap();
676
677 assert_eq!(buffered, streamed, "content must stream identically");
678 assert_eq!(&buffered[..HEADER.len()], HEADER);
679 assert!(
680 buffered[HEADER.len()..512].iter().all(|&b| b == 0),
681 "the rest of the region stays zero-filled"
682 );
683 // The superblock still begins exactly where the userblock ends, so the
684 // content displaced nothing.
685 assert_eq!(&buffered[512..520], b"\x89HDF\r\n\x1a\n");
686 assert_eq!(read_back_f64(&buffered, "x"), vec![1.0, 2.0]);
687 }
688
689 /// Without the setter the region is all zeros, as it was before — so the
690 /// setter cannot have changed any existing file's bytes.
691 #[test]
692 fn an_unset_userblock_stays_zero_filled() {
693 let mut b = FileBuilder::new();
694 b.with_userblock(512);
695 b.create_dataset("x").with_f64_data(&[1.0]);
696 let bytes = b.finish().unwrap();
697 assert!(bytes[..512].iter().all(|&b| b == 0));
698 }
699
700 /// Serves a contiguous dataset's bytes block by block, so a test can stage a
701 /// produced region without materializing it. Blocks are `block` bytes except
702 /// the last, matching what the emitter asks for.
703 struct BlockProvider {
704 total: usize,
705 block: usize,
706 calls: Calls,
707 /// Return one byte too few for this block, so a test can reach the
708 /// emitter's own size check. The MAT layer checks lengths before the
709 /// writer ever sees them, so without this knob that check is unreachable.
710 short_block: Option<usize>,
711 }
712
713 impl BlockProvider {
714 /// The byte this provider yields at offset `i`. A ramp rather than a
715 /// constant, so a block emitted at the wrong offset is visible.
716 fn byte(i: usize) -> u8 {
717 (i % 251) as u8
718 }
719
720 fn expected(total: usize) -> Vec<u8> {
721 (0..total).map(Self::byte).collect()
722 }
723 }
724
725 impl ChunkProvider for BlockProvider {
726 fn chunk_bytes(&self, index: usize, out: &mut Vec<u8>) -> Result<(), FormatError> {
727 self.calls.lock().unwrap().push(index);
728 let start = index * self.block;
729 let mut end = (start + self.block).min(self.total);
730 if self.short_block == Some(index) {
731 end -= 1;
732 }
733 out.extend((start..end).map(Self::byte));
734 Ok(())
735 }
736 }
737
738 /// A produced contiguous region must be the dataset a materialized one would
739 /// be — same bytes, same addresses — on both output paths. Sized so the
740 /// blocks do not divide the region evenly, since a short last block is where
741 /// an off-by-one in the emitter's loop would show up.
742 #[test]
743 fn a_produced_contiguous_dataset_matches_a_materialized_one() {
744 const TOTAL: usize = 8 * 1000 + 8 * 3; // 1003 f64
745 const BLOCK: usize = 8 * 256;
746 let expected = BlockProvider::expected(TOTAL);
747
748 let materialize = || {
749 let mut b = FileBuilder::new();
750 b.create_dataset("d")
751 .with_raw_data(
752 crate::type_builders::make_f64_type(),
753 expected.clone(),
754 1003,
755 )
756 .with_shape(&[1003]);
757 b
758 };
759 let produce = |calls: &Calls| {
760 let mut b = FileBuilder::new();
761 b.create_dataset("d").with_produced_data(
762 crate::type_builders::make_f64_type(),
763 &[1003],
764 TOTAL as u64,
765 BLOCK as u64,
766 Box::new(BlockProvider {
767 total: TOTAL,
768 block: BLOCK,
769 calls: Arc::clone(calls),
770 short_block: None,
771 }),
772 );
773 b
774 };
775
776 let calls = Calls::default();
777 let produced = produce(&calls).finish().unwrap();
778 assert_eq!(
779 materialize().finish().unwrap(),
780 produced,
781 "a produced region must be byte-for-byte a materialized one"
782 );
783 // Every block once, ascending, with a short tail at the end.
784 let n = TOTAL.div_ceil(BLOCK);
785 assert_eq!(*calls.lock().unwrap(), (0..n).collect::<Vec<_>>());
786 const { assert!(TOTAL % BLOCK != 0, "the fixture must leave a short tail") };
787
788 let calls = Calls::default();
789 let mut streamed = Vec::new();
790 produce(&calls).finish_to(&mut streamed).unwrap();
791 assert_eq!(produced, streamed, "and identical on the streaming path");
792 assert_eq!(read_back_f64(&produced, "d").len(), 1003);
793 }
794
795 /// A paged file classifies each dataset as small or large and reserves
796 /// free-space sections from its data length *before* the region is built. A
797 /// produced region has no bytes to measure at that point, only a declared
798 /// size, so this is the path where trusting the wrong one misplaces the file.
799 #[test]
800 fn a_produced_dataset_is_placed_correctly_in_a_paged_file() {
801 // Larger than the 4 KiB page, so it lands in the large run whose
802 // page-aligned fragments the free-space managers describe.
803 const TOTAL: usize = 8 * 4096;
804 const BLOCK: usize = 8 * 512;
805 let expected = BlockProvider::expected(TOTAL);
806
807 let build = |produced: bool| {
808 let mut b = FileBuilder::new();
809 b.with_file_space_strategy(FileSpaceStrategy::Page, true, 1);
810 b.with_file_space_page_size(4096);
811 let ds = b.create_dataset("d");
812 if produced {
813 ds.with_produced_data(
814 crate::type_builders::make_f64_type(),
815 &[4096],
816 TOTAL as u64,
817 BLOCK as u64,
818 Box::new(BlockProvider {
819 total: TOTAL,
820 block: BLOCK,
821 calls: Calls::default(),
822 short_block: None,
823 }),
824 );
825 } else {
826 ds.with_raw_data(
827 crate::type_builders::make_f64_type(),
828 expected.clone(),
829 4096,
830 )
831 .with_shape(&[4096]);
832 }
833 b
834 };
835
836 let materialized = build(false).finish().unwrap();
837 let produced = build(true).finish().unwrap();
838 assert_eq!(
839 materialized, produced,
840 "a paged file must place a produced region exactly where it places a materialized one"
841 );
842 assert_eq!(read_back_f64(&produced, "d").len(), 4096);
843 }
844
845 /// The emitter checks each produced block's length itself, rather than
846 /// trusting whatever staged the region.
847 ///
848 /// The MAT layer's adapter checks first and reports a typed error, so that
849 /// path never reaches this guard — which is exactly why it needs its own
850 /// test: it is the only thing protecting a producer staged any other way,
851 /// and a short block would slide every address after this dataset.
852 #[test]
853 fn a_produced_block_of_the_wrong_size_is_refused_by_the_emitter() {
854 const TOTAL: usize = 8 * 1000;
855 const BLOCK: usize = 8 * 256;
856 for short in [0usize, 2] {
857 let mut b = FileBuilder::new();
858 b.create_dataset("d").with_produced_data(
859 crate::type_builders::make_f64_type(),
860 &[1000],
861 TOTAL as u64,
862 BLOCK as u64,
863 Box::new(BlockProvider {
864 total: TOTAL,
865 block: BLOCK,
866 calls: Calls::default(),
867 short_block: Some(short),
868 }),
869 );
870 match b.finish() {
871 Err(Error::Format(FormatError::SerializationError(msg))) => {
872 assert!(
873 msg.contains("planned size"),
874 "expected the block-size refusal, got {msg:?}"
875 );
876 }
877 other => panic!("expected a refusal for a short block {short}, got {other:?}"),
878 }
879 }
880 }
881
882 /// A userblock size the format does not define is refused rather than
883 /// written.
884 ///
885 /// The size is the superblock's base address, and readers scan the doubling
886 /// sequence 0, 512, 1024, … for the signature. An unaligned size therefore
887 /// produced a file that this crate itself could not reopen — silently, since
888 /// nothing checked. Sizing the region to a wrapper format's header, which is
889 /// exactly what `with_userblock_content` invites, is how a caller reaches it.
890 #[test]
891 fn a_userblock_size_the_format_does_not_define_is_refused() {
892 for size in [1u64, 511, 600, 1000, 1536] {
893 let mut b = FileBuilder::new();
894 b.with_userblock(size);
895 b.create_dataset("x").with_f64_data(&[1.0]);
896 match b.finish() {
897 Err(Error::Format(FormatError::InvalidUserblockSize(reported))) => {
898 assert_eq!(reported, size);
899 }
900 other => panic!("expected {size} to be refused, got {other:?}"),
901 }
902 }
903
904 // The sizes the format does define still work, and the file reopens —
905 // which is the property the refusal above exists to protect.
906 for size in [0u64, 512, 1024, 2048, 4096] {
907 let mut b = FileBuilder::new();
908 b.with_userblock(size);
909 b.create_dataset("x").with_f64_data(&[1.0, 2.0]);
910 let bytes = b.finish().expect("a valid userblock size");
911 assert_eq!(
912 &bytes[size as usize..size as usize + 4],
913 b"\x89HDF",
914 "the superblock begins exactly where the userblock ends"
915 );
916 assert_eq!(
917 read_back_f64(&bytes, "x"),
918 vec![1.0, 2.0],
919 "a file with a {size}-byte userblock must reopen"
920 );
921 }
922 }
923
924 /// Content past the end of the region would push the superblock down and
925 /// produce a file nothing can open, so it is refused instead.
926 #[test]
927 fn userblock_content_longer_than_its_region_is_refused() {
928 let mut b = FileBuilder::new();
929 b.with_userblock(512).with_userblock_content(&[7u8; 513]);
930 b.create_dataset("x").with_f64_data(&[1.0]);
931 match b.finish() {
932 Err(Error::Format(FormatError::UserblockContentTooLarge { content, userblock })) => {
933 assert_eq!((content, userblock), (513, 512));
934 }
935 other => panic!("expected UserblockContentTooLarge, got {other:?}"),
936 }
937 }
938}