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_space_info::FileSpaceStrategy;
14use crate::libver::LibVer;
15
16/// Builder for creating a new HDF5 file.
17///
18/// # Example
19///
20/// ```no_run
21/// use hdf5_pure::FileBuilder;
22/// use hdf5_pure::AttrValue;
23///
24/// let mut builder = FileBuilder::new();
25/// builder.create_dataset("data").with_f64_data(&[1.0, 2.0, 3.0]);
26/// builder.set_attr("version", AttrValue::I64(1));
27/// builder.write("output.h5").unwrap();
28/// ```
29pub struct FileBuilder {
30    writer: FormatWriter,
31}
32
33impl FileBuilder {
34    /// Create a new file builder.
35    pub fn new() -> Self {
36        Self {
37            writer: FormatWriter::new(),
38        }
39    }
40
41    /// Create a dataset at the root level. Returns a mutable reference to
42    /// a `DatasetBuilder` for configuring data, shape, and attributes.
43    pub fn create_dataset(&mut self, name: &str) -> &mut FormatDatasetBuilder {
44        self.writer.create_dataset(name)
45    }
46
47    /// Create a group builder. Call `.finish()` on the returned builder
48    /// to complete it, then pass to `add_group()`.
49    pub fn create_group(&mut self, name: &str) -> FormatGroupBuilder {
50        self.writer.create_group(name)
51    }
52
53    /// Add a finished group to the file.
54    pub fn add_group(&mut self, group: FinishedGroup) {
55        self.writer.add_group(group);
56    }
57
58    /// Set the userblock size in bytes. Must be a power of two >= 512 or 0 (no userblock).
59    /// The userblock region is filled with zeros. With the buffered [`finish`](Self::finish),
60    /// write your userblock data into `bytes[0..size]` afterwards; the streaming
61    /// [`finish_to`](Self::finish_to) / [`write`](Self::write) emit the zero-filled region
62    /// directly, so overwrite the file's first `size` bytes after the write instead.
63    pub fn with_userblock(&mut self, size: u64) -> &mut Self {
64        self.writer.with_userblock(size);
65        self
66    }
67
68    /// Constrain the on-disk format version of the file, mirroring HDF5's
69    /// `H5Pset_libver_bounds`. The produced file must fall within `[low, high]`,
70    /// or [`finish`](Self::finish) / [`write`](Self::write) fails with
71    /// [`Error::Format`] wrapping
72    /// [`FormatError::LibverBoundsUnsatisfiable`](crate::FormatError::LibverBoundsUnsatisfiable).
73    ///
74    /// This crate writes exactly one format — the version 3 superblock from
75    /// HDF5 1.10 ([`LibVer::WRITER_OUTPUT`]) — so this is a compatibility
76    /// assertion, not a format selector: a bound that excludes 1.10 (an upper
77    /// bound older than it, or a lower bound newer than it) is rejected.
78    pub fn with_libver_bounds(&mut self, low: LibVer, high: LibVer) -> &mut Self {
79        self.writer.with_libver_bounds(low, high);
80        self
81    }
82
83    /// Set the file-space management strategy, mirroring HDF5's
84    /// `H5Pset_file_space_strategy`. The strategy, persist flag, and free-space
85    /// section `threshold` are recorded in the file's superblock extension, so
86    /// the reference C library and a later reopen observe the choice.
87    ///
88    /// `persist = true` records that freed space should be tracked on disk across
89    /// closes. A brand-new file has nothing to track, so this only records the
90    /// intent; freeing space in a later [`EditSession`](crate::EditSession) then
91    /// writes the on-disk free-space-manager blocks that survive a reopen.
92    pub fn with_file_space_strategy(
93        &mut self,
94        strategy: FileSpaceStrategy,
95        persist: bool,
96        threshold: u64,
97    ) -> &mut Self {
98        self.writer
99            .with_file_space_strategy(strategy, persist, threshold);
100        self
101    }
102
103    /// Set the file-space page size, mirroring HDF5's
104    /// `H5Pset_file_space_page_size`. Recorded in the superblock extension.
105    pub fn with_file_space_page_size(&mut self, page_size: u64) -> &mut Self {
106        self.writer.with_file_space_page_size(page_size);
107        self
108    }
109
110    /// Set an attribute on the root group.
111    pub fn set_attr(&mut self, name: &str, value: AttrValue) {
112        self.writer.set_root_attr(name, value);
113    }
114
115    /// Serialize the file to bytes in memory.
116    pub fn finish(self) -> Result<Vec<u8>, Error> {
117        Ok(self.writer.finish()?)
118    }
119
120    /// Serialize the file directly to a [`Write`] sink, without first buffering
121    /// the whole file in memory.
122    ///
123    /// Produces byte-for-byte the same file as [`finish`](Self::finish), but a
124    /// dataset staged for verbatim chunk *streaming* (repack's out-of-core path)
125    /// has its chunks pulled from the source and written one at a time, so peak
126    /// memory stays bounded by a single chunk plus the file metadata rather than
127    /// the whole dataset. The sink is written front-to-back, so it need not be
128    /// seekable.
129    pub fn finish_to<W: Write>(self, w: W) -> Result<(), Error> {
130        let mut sink = WriteSink::new(std::io::BufWriter::new(w));
131        if let Err(fe) = self.writer.finish_to_sink(&mut sink) {
132            // If the failure came from the sink's I/O, surface the real
133            // `io::Error`; otherwise it is a genuine format error.
134            return match sink.err.take() {
135                Some(io_err) => Err(Error::Io(io_err)),
136                None => Err(Error::Format(fe)),
137            };
138        }
139        sink.into_inner().flush().map_err(Error::Io)
140    }
141
142    /// Serialize and write the file to the given path.
143    ///
144    /// Streams the file to disk (see [`finish_to`](Self::finish_to)), so a repack
145    /// staging streamed chunks does not hold the whole output in memory.
146    pub fn write<P: AsRef<std::path::Path>>(self, path: P) -> Result<(), Error> {
147        let file = std::fs::File::create(path).map_err(Error::Io)?;
148        self.finish_to(file)
149    }
150}
151
152/// Adapts a [`std::io::Write`] to the writer's [`ByteSink`] so a file can be
153/// assembled straight onto the sink. Because `ByteSink` is `no_std` and cannot
154/// carry a `std::io::Error`, an I/O failure is stashed here and the surrounding
155/// [`FileBuilder::finish_to`] turns it back into [`Error::Io`].
156struct WriteSink<W: Write> {
157    inner: W,
158    written: u64,
159    err: Option<std::io::Error>,
160}
161
162impl<W: Write> WriteSink<W> {
163    fn new(inner: W) -> Self {
164        Self {
165            inner,
166            written: 0,
167            err: None,
168        }
169    }
170
171    fn into_inner(self) -> W {
172        self.inner
173    }
174}
175
176impl<W: Write> ByteSink for WriteSink<W> {
177    fn put(&mut self, bytes: &[u8]) -> Result<(), FormatError> {
178        match self.inner.write_all(bytes) {
179            Ok(()) => {
180                self.written += bytes.len() as u64;
181                Ok(())
182            }
183            Err(e) => {
184                self.err = Some(e);
185                // A placeholder format error; `finish_to` replaces it with the
186                // stashed `io::Error` above, so its message is never surfaced.
187                Err(FormatError::SerializationError(
188                    "streaming output write failed".into(),
189                ))
190            }
191        }
192    }
193
194    fn put_zeros(&mut self, n: usize) -> Result<(), FormatError> {
195        // Emit padding in bounded blocks so a large userblock never allocates a
196        // matching buffer.
197        const ZEROS: [u8; 4096] = [0u8; 4096];
198        let mut remaining = n;
199        while remaining > 0 {
200            let take = remaining.min(ZEROS.len());
201            self.put(&ZEROS[..take])?;
202            remaining -= take;
203        }
204        Ok(())
205    }
206
207    fn position(&self) -> u64 {
208        self.written
209    }
210}
211
212impl Default for FileBuilder {
213    fn default() -> Self {
214        Self::new()
215    }
216}
217
218#[cfg(test)]
219mod streaming_tests {
220    use super::*;
221    use crate::chunked_write::{ChunkMeta, ChunkProvider};
222    use std::sync::{Arc, Mutex};
223
224    type Calls = Arc<Mutex<Vec<usize>>>;
225
226    /// A test [`ChunkProvider`] serving fixed in-memory chunk bytes, recording
227    /// the order of `chunk_bytes` calls so a test can assert the streaming
228    /// writer pulls each chunk exactly once, in ascending slot order. With
229    /// `short_slot` set, that one slot returns one byte fewer than planned
230    /// (size-mismatch). `Arc<Mutex<_>>` (not `Rc<RefCell<_>>`) keeps it
231    /// `Send + Sync`, as the `ChunkProvider` supertrait requires.
232    struct MemProvider {
233        chunks: Vec<Vec<u8>>,
234        calls: Calls,
235        short_slot: Option<usize>,
236    }
237
238    impl ChunkProvider for MemProvider {
239        fn chunk_bytes(&self, index: usize) -> Result<Vec<u8>, FormatError> {
240            self.calls.lock().unwrap().push(index);
241            let mut bytes = self.chunks[index].clone();
242            if self.short_slot == Some(index) {
243                bytes.pop();
244            }
245            Ok(bytes)
246        }
247    }
248
249    fn f64_chunk(vals: &[f64]) -> Vec<u8> {
250        let mut v = Vec::new();
251        for &x in vals {
252            v.extend_from_slice(&x.to_le_bytes());
253        }
254        v
255    }
256
257    fn meta_of(chunk_bytes: &[Vec<u8>]) -> Vec<ChunkMeta> {
258        chunk_bytes
259            .iter()
260            .map(|c| ChunkMeta {
261                compressed_size: c.len() as u64,
262                filter_mask: 0,
263            })
264            .collect()
265    }
266
267    /// Stage one lazily-streamed, unfiltered chunked f64 dataset named `name` on
268    /// `b`. Unfiltered means the "compressed" bytes are the raw element bytes, so
269    /// the produced file is a plain chunked f64 dataset that reads back.
270    fn stage_lazy(
271        b: &mut FileBuilder,
272        name: &str,
273        chunk_bytes: Vec<Vec<u8>>,
274        dims: &[u64],
275        chunk_dims: &[u64],
276        maxshape: Option<&[u64]>,
277        calls: Calls,
278        short_slot: Option<usize>,
279    ) {
280        let meta = meta_of(&chunk_bytes);
281        let provider = MemProvider {
282            chunks: chunk_bytes,
283            calls,
284            short_slot,
285        };
286        b.create_dataset(name).with_raw_chunks_lazy(
287            crate::type_builders::make_f64_type(),
288            dims,
289            maxshape,
290            chunk_dims,
291            8,
292            None,
293            meta,
294            Box::new(provider),
295        );
296    }
297
298    /// Build a file with one lazily-streamed chunked f64 dataset named `d`.
299    fn build_lazy(
300        chunk_bytes: Vec<Vec<u8>>,
301        dims: &[u64],
302        chunk_dims: &[u64],
303        maxshape: Option<&[u64]>,
304        calls: Calls,
305        short_slot: Option<usize>,
306    ) -> FileBuilder {
307        let mut b = FileBuilder::new();
308        stage_lazy(
309            &mut b,
310            "d",
311            chunk_bytes,
312            dims,
313            chunk_dims,
314            maxshape,
315            calls,
316            short_slot,
317        );
318        b
319    }
320
321    fn read_back_f64(bytes: &[u8], path: &str) -> Vec<f64> {
322        let file = crate::reader::File::from_bytes(bytes.to_vec()).unwrap();
323        let raw = file.dataset(path).unwrap().read_raw().unwrap();
324        raw.chunks_exact(8)
325            .map(|b| f64::from_le_bytes(b.try_into().unwrap()))
326            .collect()
327    }
328
329    #[test]
330    fn streamed_output_matches_buffered_and_streams_one_chunk_at_a_time() {
331        let chunks = vec![
332            f64_chunk(&[1.0, 2.0]),
333            f64_chunk(&[3.0, 4.0]),
334            f64_chunk(&[5.0, 6.0]),
335        ];
336
337        let calls_buf = Arc::new(Mutex::new(Vec::new()));
338        let buffered = build_lazy(chunks.clone(), &[6], &[2], None, calls_buf.clone(), None)
339            .finish()
340            .unwrap();
341
342        let calls_str = Arc::new(Mutex::new(Vec::new()));
343        let mut streamed = Vec::new();
344        build_lazy(chunks.clone(), &[6], &[2], None, calls_str.clone(), None)
345            .finish_to(&mut streamed)
346            .unwrap();
347
348        // The streaming (io::Write) path and the buffered (Vec) path must produce
349        // byte-for-byte the same file.
350        assert_eq!(
351            buffered, streamed,
352            "streamed output must be byte-identical to buffered output"
353        );
354        // Each chunk is pulled exactly once, in ascending slot order — i.e. the
355        // writer streams chunk-by-chunk rather than collecting them all.
356        assert_eq!(*calls_buf.lock().unwrap(), vec![0, 1, 2]);
357        assert_eq!(*calls_str.lock().unwrap(), vec![0, 1, 2]);
358        // And the file reads back to the original values.
359        assert_eq!(
360            read_back_f64(&buffered, "d"),
361            vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
362        );
363    }
364
365    #[test]
366    fn file_builder_keeps_its_auto_traits() {
367        // The lazy chunk provider is boxed into `FileBuilder`; a bare boxed trait
368        // object would strip `Send`/`Sync` (fixed by the `ChunkProvider`
369        // supertrait) and `UnwindSafe`/`RefUnwindSafe` (fixed by wrapping it in
370        // `AssertUnwindSafe`). Removing any of these auto-trait impls is a semver
371        // break that `cargo-semver-checks` enforces in CI, so pin all four here.
372        fn assert_auto_traits<
373            T: Send + Sync + std::panic::UnwindSafe + std::panic::RefUnwindSafe,
374        >() {
375        }
376        assert_auto_traits::<FileBuilder>();
377    }
378
379    #[test]
380    fn streaming_writer_rejects_provider_size_mismatch() {
381        // A provider returning fewer bytes than planned — on slot 0 *or* any later
382        // slot — must be rejected rather than written as a corrupt file.
383        for short_slot in [0usize, 2] {
384            let chunks = vec![
385                f64_chunk(&[1.0, 2.0]),
386                f64_chunk(&[3.0, 4.0]),
387                f64_chunk(&[5.0, 6.0]),
388            ];
389            let calls = Arc::new(Mutex::new(Vec::new()));
390            let err = build_lazy(chunks, &[6], &[2], None, calls, Some(short_slot))
391                .finish()
392                .unwrap_err();
393            match err {
394                Error::Format(FormatError::ChunkedReadError(_)) => {}
395                other => panic!("slot {short_slot}: expected ChunkedReadError, got {other:?}"),
396            }
397        }
398    }
399
400    /// Assert the buffered and streamed outputs are byte-identical for one chunked
401    /// layout, and that the produced file reads back to `expected`.
402    fn assert_variant_streams_identically(
403        chunks: Vec<Vec<u8>>,
404        dims: &[u64],
405        chunk_dims: &[u64],
406        maxshape: Option<&[u64]>,
407        expected: &[f64],
408    ) {
409        let buffered = build_lazy(
410            chunks.clone(),
411            dims,
412            chunk_dims,
413            maxshape,
414            Arc::new(Mutex::new(Vec::new())),
415            None,
416        )
417        .finish()
418        .unwrap();
419        let mut streamed = Vec::new();
420        build_lazy(
421            chunks,
422            dims,
423            chunk_dims,
424            maxshape,
425            Arc::new(Mutex::new(Vec::new())),
426            None,
427        )
428        .finish_to(&mut streamed)
429        .unwrap();
430        assert_eq!(
431            buffered, streamed,
432            "index variant dims={dims:?} chunk={chunk_dims:?} must stream identically"
433        );
434        // The streamed file decodes to the expected values (not merely parses).
435        assert_eq!(
436            read_back_f64(&buffered, "d"),
437            expected,
438            "index variant dims={dims:?} chunk={chunk_dims:?} must read back correctly"
439        );
440    }
441
442    #[test]
443    fn streamed_equals_buffered_across_index_variants() {
444        // single-chunk, fixed-array (>1 chunk), and extensible-array (unlimited
445        // max shape) all lay out from sizes alone, so each must stream identically
446        // and read back to the right values.
447        assert_variant_streams_identically(
448            vec![f64_chunk(&[1.0, 2.0])],
449            &[2],
450            &[2],
451            None,
452            &[1.0, 2.0],
453        );
454        assert_variant_streams_identically(
455            (0..5)
456                .map(|i| f64_chunk(&[i as f64, i as f64 + 0.5]))
457                .collect(),
458            &[10],
459            &[2],
460            None,
461            &[0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5],
462        );
463        assert_variant_streams_identically(
464            vec![f64_chunk(&[1.0, 2.0]), f64_chunk(&[3.0, 4.0])],
465            &[4],
466            &[2],
467            Some(&[u64::MAX]),
468            &[1.0, 2.0, 3.0, 4.0],
469        );
470    }
471
472    /// A `Write` that accepts `limit` bytes total, then fails every later write —
473    /// to exercise the streaming I/O-error path.
474    struct FailAfter {
475        remaining: usize,
476    }
477    impl Write for FailAfter {
478        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
479            if self.remaining == 0 {
480                return Err(std::io::Error::other("write limit reached"));
481            }
482            let n = buf.len().min(self.remaining);
483            self.remaining -= n;
484            Ok(n)
485        }
486        fn flush(&mut self) -> std::io::Result<()> {
487            Ok(())
488        }
489    }
490
491    #[test]
492    fn streaming_io_error_surfaces_as_error_io() {
493        // A dataset large enough to exceed the internal BufWriter so writes occur
494        // mid-stream; the sink fails partway and `finish_to` must surface it as
495        // `Error::Io`, not a format error or a panic.
496        let chunks: Vec<Vec<u8>> = (0..12).map(|_| f64_chunk(&[1.0; 256])).collect(); // 12 * 2 KiB
497        let builder = build_lazy(
498            chunks,
499            &[3072],
500            &[256],
501            None,
502            Arc::new(Mutex::new(Vec::new())),
503            None,
504        );
505        let err = builder
506            .finish_to(FailAfter { remaining: 4096 })
507            .unwrap_err();
508        assert!(
509            matches!(err, Error::Io(_)),
510            "a failing sink must surface as Error::Io, got {err:?}"
511        );
512    }
513
514    #[test]
515    fn streamed_dataset_with_attribute_and_contiguous_sibling() {
516        // One file mixing a streamed (lazy chunked) dataset that also carries an
517        // attribute, a plain contiguous dataset, and a zero-element contiguous
518        // dataset — exercising the assembly loop's InMemory + Streamed dispatch and
519        // attribute handling together. Buffered and streamed must agree and read
520        // back.
521        let build = || {
522            let chunks = vec![f64_chunk(&[1.0, 2.0]), f64_chunk(&[3.0, 4.0])];
523            let meta = meta_of(&chunks);
524            let provider = MemProvider {
525                chunks,
526                calls: Arc::new(Mutex::new(Vec::new())),
527                short_slot: None,
528            };
529            let mut b = FileBuilder::new();
530            // Configure the one streamed dataset and its attribute on the same
531            // builder (a second `create_dataset` would add a *different* dataset).
532            b.create_dataset("chunked")
533                .with_raw_chunks_lazy(
534                    crate::type_builders::make_f64_type(),
535                    &[4],
536                    None,
537                    &[2],
538                    8,
539                    None,
540                    meta,
541                    Box::new(provider),
542                )
543                .set_attr("units", AttrValue::I64(7));
544            b.create_dataset("contig")
545                .with_f64_data(&[10.0, 11.0, 12.0]);
546            b.create_dataset("empty").with_f64_data(&[]);
547            b
548        };
549        let buffered = build().finish().unwrap();
550        let mut streamed = Vec::new();
551        build().finish_to(&mut streamed).unwrap();
552        assert_eq!(buffered, streamed, "mixed file must stream identically");
553        assert_eq!(
554            read_back_f64(&buffered, "chunked"),
555            vec![1.0, 2.0, 3.0, 4.0]
556        );
557        assert_eq!(read_back_f64(&buffered, "contig"), vec![10.0, 11.0, 12.0]);
558    }
559}