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}