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