Skip to main content

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}