Skip to main content

nir_rs/io/
mod.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! HDF5 `.nir` read/write.
4//!
5//! `.nir` is the official NIR interchange format: an HDF5 container whose
6//! layout is fixed by upstream [neuromorphs/NIR](https://github.com/neuromorphs/NIR).
7//! Files written here load in Python `nir.read`, and files written by Python
8//! `nir.write` load in [`read`]. See [`wire`] for the layout itself.
9//!
10//! # Feature gate
11//!
12//! The implementation lives behind the **`hdf5`** feature, which links the
13//! native libhdf5 library. It is off by default so that the graph model stays
14//! dependency-free for consumers that only build or inspect graphs:
15//!
16//! ```toml
17//! nir-rs = { version = "0.4", features = ["hdf5"] }
18//! ```
19//!
20//! System dependency: `libhdf5-dev` (Debian/Ubuntu), `hdf5` (Homebrew), or add
21//! `hdf5-metno` as a direct dependency with `features = ["static", "zlib"]`
22//! for a hermetic build from vendored source.
23//!
24//! Every item in this module exists in both builds — only the bodies are gated.
25//! Without the feature, [`read`], [`write()`] and [`read_version`] return
26//! [`NirError::Unimplemented`] rather than failing to compile, so downstream
27//! code can be written once and feature-gated at the call site if it wants to.
28//!
29//! # Version string
30//!
31//! Upstream writes the version of the Python `nir` package into `/version` and
32//! never validates it on read. Default [`read`] follows suit:
33//!
34//! - [`read`] stores `/version` in [`NirGraph::version`], and leaves it `None`
35//!   when the dataset is absent. It is never an error.
36//! - [`read_version`] is the strict accessor and *does* error when absent.
37//! - [`write()`] emits [`NirGraph::version`] when set, else
38//!   [`DEFAULT_NIR_VERSION`].
39//!
40//! Production importers can opt into an envelope check via
41//! [`ReadOptions::version_policy`] without changing that default:
42//! [`VersionPolicy::RequirePresent`] rejects a missing `/version`;
43//! [`VersionPolicy::CompatibleMajor`] parses a SemVer-compatible string and
44//! accepts caller-supplied majors (typically `0` for paper fixtures and `1`
45//! for current writers). Policy failures use
46//! [`NirError::IncompatibleVersion`] and run **before** the graph body is
47//! decoded.
48//!
49//! # Non-goals
50//!
51//! Byte-identical output versus h5py (group ordering, chunk layout and filter
52//! parameters may differ), and the separate `NIRGraphData` observables layout
53//! that upstream `read_data` / `write_data` handle.
54
55pub mod wire;
56
57mod version;
58pub use version::VersionPolicy;
59
60#[cfg(feature = "hdf5")]
61mod hdf5_read;
62#[cfg(feature = "hdf5")]
63mod hdf5_write;
64
65use crate::graph::NirGraph;
66use std::path::Path;
67
68// `NirError` is constructed only by the feature-off backend, but the rustdoc
69// links throughout this module reference it in both builds.
70#[cfg_attr(feature = "hdf5", allow(unused_imports))]
71use crate::error::{NirError, Result};
72
73/// The three primitives the public functions delegate to.
74///
75/// Selecting the implementation once, here, is what keeps `#[cfg]` out of the
76/// public functions below — they have one body each regardless of features.
77#[cfg(feature = "hdf5")]
78mod backend {
79    pub(super) use super::hdf5_read::{read, read_version};
80    pub(super) use super::hdf5_write::write;
81}
82
83/// Stand-ins used when the `hdf5` feature is off.
84///
85/// The signatures match the real backend so the public API is identical in
86/// both builds; only the outcome differs.
87#[cfg(not(feature = "hdf5"))]
88mod backend {
89    use super::{NirError, NirGraph, Path, ReadOptions, Result, WriteOptions};
90
91    pub(super) fn read(_path: &Path, _opts: &ReadOptions) -> Result<NirGraph> {
92        Err(NirError::Unimplemented(
93            "io::read (enable feature \"hdf5\")",
94        ))
95    }
96
97    pub(super) fn read_version(_path: &Path, _opts: &ReadOptions) -> Result<String> {
98        Err(NirError::Unimplemented(
99            "io::read_version (enable feature \"hdf5\")",
100        ))
101    }
102
103    pub(super) fn write(_path: &Path, _graph: &NirGraph, _opts: &WriteOptions) -> Result<()> {
104        Err(NirError::Unimplemented(
105            "io::write (enable feature \"hdf5\")",
106        ))
107    }
108}
109
110/// Version string written to `/version` when a graph carries none.
111///
112/// Tracks the upstream `nir` release this crate's wire format is validated
113/// against. Consumers that need a specific value should set
114/// [`NirGraph::version`] or [`WriteOptions::with_version`].
115pub const DEFAULT_NIR_VERSION: &str = "1.0.8";
116
117/// Default gzip level, matching h5py's `compression="gzip"` default.
118const DEFAULT_COMPRESSION: u8 = 4;
119
120/// Allocation and collection policy for decoding an untrusted `.nir` file
121/// with [`read_with`].
122///
123/// Defaults are **permissive**: every field is `None`, so [`read`] stays
124/// unbounded aside from the existing hard cap of 1024 `NIRGraph` groups
125/// (root plus nested). That cap is a stack/alias safety bound, not a
126/// substitute for a caller-chosen collection budget.
127///
128/// `max_bytes` is a **decoded-allocation budget**, not an on-disk file-size
129/// limit and not a bound on the returned graph's exact resident size. Charging
130/// is monotonic and conservative: temporary allocations stay charged after
131/// they are released. The exact rules are:
132///
133/// - numeric datasets: element count times decoded width;
134/// - `u64` datasets: both the temporary `Vec<u64>` and converted `Vec<i64>`;
135/// - `i64` extent lists converted to `Vec<usize>` (e.g. `Input.shape`): both the
136///   source `Vec<i64>` and the destination `Vec<usize>`;
137/// - fixed strings: fixed-capacity HDF5 buffers, resulting [`String`] headers,
138///   and the worst-case copied payload;
139/// - variable-length strings: descriptor buffers, payload bytes reported by
140///   `H5Dvlen_get_buf_size`, resulting [`String`] headers, and copied payload.
141///   Scalar VLEN strings use the containing file size as a payload bound
142///   because `H5Dvlen_get_buf_size` can abort on scalar VLEN;
143/// - scalar metadata: its decoded width;
144/// - missing `v_reset` and `w_in`: the synthesized tensor payload.
145///
146/// `max_nodes`, `max_edges`, and `max_nested_graphs` are **global count
147/// budgets** for the whole file: nested subgraphs add to the same totals
148/// rather than resetting per group. Counts are charged from HDF5 metadata
149/// (`H5Gget_info` link counts, `edges` shape, one charge per `NIRGraph`
150/// group) **before** the corresponding `Vec` / map is materialized. Hard-link
151/// aliases are rejected before they can charge a second time.
152///
153/// All arithmetic is checked; overflow is treated as over budget. Node and
154/// link names, collection bookkeeping, allocator overhead, and libhdf5's own
155/// caches are not charged against `max_bytes`.
156///
157/// # Untrusted inputs
158///
159/// Conservative starting points when the file is not from a trusted
160/// producer — tighten further for your threat model:
161///
162/// ```
163/// use nir_rs::io::ReadOptions;
164///
165/// let opts = ReadOptions::default()
166///     .with_max_bytes(Some(64 * 1024 * 1024))
167///     .with_max_nodes(Some(10_000))
168///     .with_max_edges(Some(50_000))
169///     .with_max_nested_graphs(Some(64));
170/// ```
171#[derive(Debug, Clone, PartialEq, Eq, Default)]
172#[non_exhaustive]
173pub struct ReadOptions {
174    /// Maximum total bytes charged by decoded allocations, or `None` for no
175    /// allocation budget.
176    pub max_bytes: Option<usize>,
177    /// Maximum total nodes across root and nested graphs, or `None` for no
178    /// node-count budget.
179    pub max_nodes: Option<usize>,
180    /// Maximum total edges across root and nested graphs, or `None` for no
181    /// edge-count budget.
182    pub max_edges: Option<usize>,
183    /// Maximum total `NIRGraph` groups decoded from the file (root included),
184    /// or `None` to use only the hard cap of 1024 groups.
185    pub max_nested_graphs: Option<usize>,
186    /// How to treat the root `/version` dataset. Defaults to
187    /// [`VersionPolicy::Permissive`], which is the Python `nir.read` behaviour
188    /// and keeps [`read`] byte-for-byte compatible with earlier crate
189    /// versions.
190    pub version_policy: VersionPolicy,
191}
192
193impl ReadOptions {
194    /// Set the decoded-allocation budget in bytes; `None` makes it unbounded.
195    #[must_use]
196    pub fn with_max_bytes(mut self, max_bytes: Option<usize>) -> Self {
197        self.max_bytes = max_bytes;
198        self
199    }
200
201    /// Set the global node-count budget; `None` makes it unbounded.
202    #[must_use]
203    pub fn with_max_nodes(mut self, max_nodes: Option<usize>) -> Self {
204        self.max_nodes = max_nodes;
205        self
206    }
207
208    /// Set the global edge-count budget; `None` makes it unbounded.
209    #[must_use]
210    pub fn with_max_edges(mut self, max_edges: Option<usize>) -> Self {
211        self.max_edges = max_edges;
212        self
213    }
214
215    /// Set the global nested-graph budget; `None` keeps only the hard cap of
216    /// 1024 groups.
217    #[must_use]
218    pub fn with_max_nested_graphs(mut self, max_nested_graphs: Option<usize>) -> Self {
219        self.max_nested_graphs = max_nested_graphs;
220        self
221    }
222
223    /// Set the `/version` compatibility policy.
224    ///
225    /// ```
226    /// use nir_rs::io::{ReadOptions, VersionPolicy};
227    ///
228    /// // Permissive tooling (default): accept missing or arbitrary versions.
229    /// let tool = ReadOptions::default();
230    /// assert_eq!(tool.version_policy, VersionPolicy::Permissive);
231    ///
232    /// // Fail-closed importer: paper 0.x fixtures and 1.x writers.
233    /// let importer = ReadOptions::default()
234    ///     .with_version_policy(VersionPolicy::compatible_major([0, 1]));
235    /// ```
236    #[must_use]
237    pub fn with_version_policy(mut self, version_policy: VersionPolicy) -> Self {
238        self.version_policy = version_policy;
239        self
240    }
241}
242
243/// Tuning knobs for [`write_with`].
244///
245/// Construct from [`Default`] and adjust:
246///
247/// ```
248/// use nir_rs::io::WriteOptions;
249///
250/// let opts = WriteOptions::default().with_compression(None);
251/// assert_eq!(opts.compression, None);
252/// ```
253#[derive(Debug, Clone, PartialEq, Eq)]
254#[non_exhaustive]
255pub struct WriteOptions {
256    /// Deflate (gzip) level for **numeric** array datasets, must be in `0..=9`.
257    ///
258    /// `None` writes arrays uncompressed. Values above 9 are rejected; use
259    /// [`Self::with_compression`] to clamp automatically.
260    ///
261    /// Three kinds of dataset are never compressed regardless of this setting:
262    ///
263    /// - **Scalars.** HDF5 cannot chunk them, and chunking is a prerequisite
264    ///   for any filter.
265    /// - **Empty arrays** — any tensor with a zero-length axis. There are no
266    ///   bytes to compress, and a filter would still cost a chunked layout.
267    /// - **String arrays** — `edges` and [`MetadataValue::StringList`]. These
268    ///   are variable-length, so the dataset holds only heap descriptors and
269    ///   the characters live on HDF5's global heap. A filter applies to the
270    ///   descriptors, not to the payload, so deflating them would add chunking
271    ///   overhead while compressing almost nothing.
272    ///
273    /// [`MetadataValue::StringList`]: crate::types::MetadataValue::StringList
274    pub compression: Option<u8>,
275    /// Version string to write, overriding [`NirGraph::version`].
276    pub version: Option<String>,
277    /// Run [`NirGraph::validate_structure`] and lossless-representation checks
278    /// before writing. Defaults to `true`.
279    ///
280    /// This does **not** run [`NirGraph::validate_parameters`]. Convolution and
281    /// pooling parameter invariants stay opt-in so wild fixtures can still be
282    /// rewritten without an extra compatibility decision.
283    ///
284    /// Set this to `false` to rewrite a graph whose edges do not all resolve,
285    /// or that contains values the wire format cannot preserve (nested graph
286    /// versions, rank-0 metadata tensors). Such files exist in the wild —
287    /// upstream's own `braille_noDelay_bias_zero_subgraph.nir` has a subgraph
288    /// edge naming a node that is not in that subgraph — and [`read`] loads
289    /// them faithfully, so writing them back has to be possible. The resulting
290    /// file will not load in Python `nir.read` with its default type checking,
291    /// and may lose or change the unrepresentable values on readback.
292    pub validate: bool,
293}
294
295impl Default for WriteOptions {
296    fn default() -> Self {
297        Self {
298            compression: Some(DEFAULT_COMPRESSION),
299            version: None,
300            validate: true,
301        }
302    }
303}
304
305impl WriteOptions {
306    /// Set the deflate level for array datasets; `None` disables compression.
307    #[must_use]
308    pub fn with_compression(mut self, level: Option<u8>) -> Self {
309        self.compression = level.map(|l| l.min(9));
310        self
311    }
312
313    /// Override the version string written to `/version`.
314    #[must_use]
315    pub fn with_version(mut self, version: impl Into<String>) -> Self {
316        self.version = Some(version.into());
317        self
318    }
319
320    /// Enable or disable the pre-write structure check.
321    #[must_use]
322    pub fn with_validation(mut self, validate: bool) -> Self {
323        self.validate = validate;
324        self
325    }
326}
327
328/// Read a NIR graph from a `.nir` (HDF5) path.
329///
330/// Absent optional fields are filled with the upstream Python defaults, so a
331/// graph read here matches what `nir.read` produces in memory: a missing
332/// `v_reset` becomes zeros shaped like `v_threshold`, and a missing `w_in`
333/// becomes ones shaped like `v_leak`.
334///
335/// Node **parameters** keep their on-disk float width — an `f32` weight never
336/// becomes `f64`. **Scalar metadata** is the one exception: [`MetadataValue`]
337/// has no `F32` variant, so a scalar `float32` metadata value decodes as
338/// [`MetadataValue::F64`] and is written back as a 64-bit dataset. The value
339/// survives exactly, since `f32` widens to `f64` losslessly; only the wire
340/// dtype of that one dataset changes. Narrower integers likewise widen into
341/// [`MetadataValue::I64`].
342///
343/// **Node order is not preserved.** [`NirGraph::nodes`] is an order-preserving
344/// map, but this reads names in sorted order so that decoding one file twice
345/// gives the same order both times — HDF5 does not promise a link ordering
346/// worth carrying. `edges` is a `Vec` and *is* order-significant, so it is
347/// preserved exactly.
348///
349/// [`MetadataValue`]: crate::types::MetadataValue
350/// [`MetadataValue::F64`]: crate::types::MetadataValue::F64
351/// [`MetadataValue::I64`]: crate::types::MetadataValue::I64
352///
353/// # Errors
354///
355/// - [`NirError::Io`] if the file cannot be opened or is not valid HDF5
356/// - [`NirError::MissingField`] if `/node` or a required node field is absent
357/// - [`NirError::UnknownNodeType`] for a `type` string outside
358///   [`wire::WIRE_TYPES`]
359/// - [`NirError::InvalidTensor`] for a dataset whose element type has no
360///   [`DType`](crate::DType) representation
361/// - [`NirError::InvalidGraph`] for a file that reaches outside its own
362///   container (external links, external raw storage, virtual datasets)
363/// - [`NirError::Unimplemented`] if the `hdf5` feature is off
364///
365/// # Examples
366///
367/// ```no_run
368/// let graph = nir_rs::io::read("model.nir")?;
369/// for (name, node) in &graph.nodes {
370///     println!("{name}: {}", node.type_name());
371/// }
372/// # Ok::<(), nir_rs::NirError>(())
373/// ```
374pub fn read(path: impl AsRef<Path>) -> Result<NirGraph> {
375    read_with(path, &ReadOptions::default())
376}
377
378/// Read a NIR graph with an explicit decoded-allocation and collection budget.
379///
380/// See [`ReadOptions`] for the exact charging rules. Use this entry point for
381/// untrusted files. Plain [`read`] is intentionally unbounded for trusted
382/// callers and backward compatibility.
383///
384/// # Errors
385///
386/// As [`read`], plus [`NirError::ReadLimitExceeded`] when the next decoded
387/// allocation would cross `opts.max_bytes`,
388/// [`NirError::ReadCountLimitExceeded`] when a node, edge, or nested-graph
389/// count would cross the corresponding limit, and
390/// [`NirError::IncompatibleVersion`] when `opts.version_policy` rejects
391/// `/version`. Version-policy checks run before the graph body is decoded.
392pub fn read_with(path: impl AsRef<Path>, opts: &ReadOptions) -> Result<NirGraph> {
393    backend::read(path.as_ref(), opts)
394}
395
396/// Read only the `/version` string from a `.nir` file.
397///
398/// # Errors
399///
400/// [`NirError::MissingField`] when the file has no `/version` dataset;
401/// otherwise as [`read`].
402pub fn read_version(path: impl AsRef<Path>) -> Result<String> {
403    read_version_with(path, &ReadOptions::default())
404}
405
406/// Read only `/version` with an explicit decoded-allocation budget.
407///
408/// # Errors
409///
410/// As [`read_version`], plus [`NirError::ReadLimitExceeded`] when decoding the
411/// version string would cross `opts.max_bytes`, and
412/// [`NirError::IncompatibleVersion`] when `opts.version_policy` rejects the
413/// value. Parsing and policy errors share the graph-reader path.
414pub fn read_version_with(path: impl AsRef<Path>, opts: &ReadOptions) -> Result<String> {
415    backend::read_version(path.as_ref(), opts)
416}
417
418/// Write a NIR graph to a `.nir` (HDF5) path atomically.
419///
420/// Equivalent to [`write_with`] using [`WriteOptions::default`] (gzip level 4,
421/// matching h5py).
422///
423/// Data is written to a temporary file inside a private staging directory
424/// (mode `0700` on Unix), flushed, closed, and then atomically renamed over the
425/// destination. A failed write leaves an existing destination unchanged.
426///
427/// **Staging base (Unix):** when the destination parent is untrusted —
428/// group/world-writable without the sticky bit, a symlink path component, or
429/// owned by a UID other than the process effective UID or root — staging
430/// attempts to use sticky temp (if owned by the current user or root, writable,
431/// and with verified symlink-free ancestry) or a private per-user runtime/cache
432/// directory (if all ancestors are owned by the current user or root, non-symlink,
433/// and free of non-sticky group/world-writable modes) so other local users cannot
434/// rename the staging directory away and plant a path for the HDF5 reopen.
435/// Foreign-owned parents are treated as untrusted even at mode `0755`, because
436/// the directory owner can always rename entries (including under a sticky bit).
437/// If no safe staging base is found, the write fails rather than falling back to
438/// the untrusted destination parent. The final replace into a multi-user
439/// non-sticky parent still has residual rename races — prefer private destination
440/// directories on shared hosts.
441///
442/// Existing Unix file permissions (mode bits) are preserved, but **ownership
443/// and group are changed** to those of the writing process, and POSIX ACLs are
444/// not preserved. A new Unix destination uses mode `0o666` filtered by the
445/// process umask.
446///
447/// **SELinux context (Unix):** On SELinux-enforcing hosts, same-filesystem renames
448/// preserve the source inode's security context. When staging under a secure base
449/// such as `/tmp` and renaming onto the destination, the written file may keep
450/// the staging label rather than the destination directory's file-creation
451/// context, which can make it inaccessible to a confined consumer. Creating the
452/// final inode under a hostile (shared/non-sticky) destination parent would
453/// reintroduce path-swap races, so this residual is accepted: apply `restorecon`
454/// or `chcon` after a successful write when a specific context is required.
455///
456/// This does not fsync the file or containing directory, so it
457/// is not a power-loss durability guarantee.
458///
459/// The graph is validated with
460/// [`NirGraph::validate_structure`](crate::NirGraph::validate_structure) first:
461/// a graph with dangling edges would produce a file that upstream refuses to
462/// load, so it is rejected here instead. Opt out with
463/// [`WriteOptions::with_validation`]. Convolution and pooling parameter
464/// invariants ([`NirGraph::validate_parameters`](crate::NirGraph::validate_parameters))
465/// are not part of this preflight; call them explicitly for that stricter gate.
466///
467/// # Errors
468///
469/// As [`write_with`].
470///
471/// # Examples
472///
473/// ```no_run
474/// # let graph = nir_rs::NirGraph::new();
475/// nir_rs::io::write("model.nir", &graph)?;
476/// # Ok::<(), nir_rs::NirError>(())
477/// ```
478pub fn write(path: impl AsRef<Path>, graph: &NirGraph) -> Result<()> {
479    write_with(path, graph, &WriteOptions::default())
480}
481
482/// Write a NIR graph to a `.nir` (HDF5) path with explicit options.
483///
484/// Uses the same atomic staging and replacement protocol as [`write()`].
485///
486/// **Symlink handling**: When `path` is a symlink, the atomic rename replaces
487/// the symlink itself rather than updating its target. To update the target
488/// file, pass a resolved path: use [`std::fs::canonicalize`] for a fully
489/// resolved absolute path, or join a relative [`std::fs::read_link`] result
490/// with the symlink's parent before writing (raw `read_link` alone is not
491/// enough when the stored target is relative).
492///
493/// **ACL preservation**: Only basic Unix permission bits (mode) are preserved
494/// from an existing destination. POSIX ACLs and Windows DACLs are **not copied**
495/// to the new inode. If the destination is ACL-protected, the replacement may
496/// change who can access it.
497///
498/// **Multi-user destination directories**: Staging is hardened against parent
499/// directory rename races when the destination parent is shared and non-sticky
500/// (see [`write()`]). If no safe staging base can be found (sticky temp owned by
501/// current user, or private per-user directories with verified ownership ancestry),
502/// the write fails. Cross-device promotion is also rejected when the destination
503/// parent is shared and non-sticky to prevent path-swap vulnerabilities during
504/// local staging. The final `rename` into a multi-user non-sticky parent still
505/// cannot be made fully race-free while HDF5 requires a path reopen; use private
506/// directories when untrusted local users can write the parent.
507///
508/// # Errors
509///
510/// - [`NirError::MissingNode`] / [`NirError::DuplicateEdge`] /
511///   [`NirError::InvalidGraph`] if the graph does not validate, if a node name
512///   or metadata key is not a legal HDF5 link name (see
513///   [`wire::check_link_name`]), if a string payload / edge endpoint contains a
514///   NUL byte, if a `Conv2d.input_shape` is not a length-2 pair, or if it holds
515///   a value the wire format cannot carry back unchanged (a nested graph
516///   version, or a rank-0 metadata tensor)
517/// - [`NirError::Io`] if the file cannot be created or a dataset cannot be
518///   written
519/// - [`NirError::Unimplemented`] if the `hdf5` feature is off
520pub fn write_with(path: impl AsRef<Path>, graph: &NirGraph, opts: &WriteOptions) -> Result<()> {
521    backend::write(path.as_ref(), graph, opts)
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527
528    #[test]
529    fn write_options_default_matches_h5py_gzip() {
530        let opts = WriteOptions::default();
531        assert_eq!(opts.compression, Some(4));
532        assert_eq!(opts.version, None);
533        assert!(opts.validate);
534    }
535
536    #[test]
537    fn write_options_builders() {
538        let opts = WriteOptions::default()
539            .with_compression(Some(200))
540            .with_version("0.2.0")
541            .with_validation(false);
542        assert_eq!(opts.compression, Some(9), "level should clamp to 9");
543        assert_eq!(opts.version.as_deref(), Some("0.2.0"));
544        assert!(!opts.validate);
545
546        let off = WriteOptions::default().with_compression(None);
547        assert_eq!(off.compression, None);
548    }
549
550    #[test]
551    fn read_options_default_is_unbounded() {
552        let opts = ReadOptions::default();
553        assert_eq!(opts.max_bytes, None);
554        assert_eq!(opts.max_nodes, None);
555        assert_eq!(opts.max_edges, None);
556        assert_eq!(opts.max_nested_graphs, None);
557        assert_eq!(opts.version_policy, VersionPolicy::Permissive);
558        let configured = opts
559            .with_max_bytes(Some(4096))
560            .with_max_nodes(Some(8))
561            .with_max_edges(Some(16))
562            .with_max_nested_graphs(Some(4));
563        assert_eq!(configured.max_bytes, Some(4096));
564        assert_eq!(configured.max_nodes, Some(8));
565        assert_eq!(configured.max_edges, Some(16));
566        assert_eq!(configured.max_nested_graphs, Some(4));
567    }
568
569    #[test]
570    fn read_options_version_policy_builder() {
571        let opts = ReadOptions::default()
572            .with_version_policy(VersionPolicy::RequirePresent)
573            .with_max_bytes(Some(1024));
574        assert_eq!(opts.version_policy, VersionPolicy::RequirePresent);
575        assert_eq!(opts.max_bytes, Some(1024));
576    }
577
578    #[cfg(not(feature = "hdf5"))]
579    mod without_feature {
580        use super::*;
581
582        #[test]
583        fn read_is_unimplemented() {
584            let err = read("model.nir").unwrap_err();
585            assert!(matches!(err, NirError::Unimplemented(_)));
586        }
587
588        #[test]
589        fn read_version_is_unimplemented() {
590            let err = read_version("model.nir").unwrap_err();
591            assert!(matches!(err, NirError::Unimplemented(_)));
592        }
593
594        #[test]
595        fn bounded_read_is_unimplemented() {
596            let opts = ReadOptions::default()
597                .with_max_bytes(Some(1024))
598                .with_max_nodes(Some(8))
599                .with_max_edges(Some(16))
600                .with_max_nested_graphs(Some(4));
601            assert!(matches!(
602                read_with("model.nir", &opts).unwrap_err(),
603                NirError::Unimplemented(_)
604            ));
605            assert!(matches!(
606                read_version_with("model.nir", &opts).unwrap_err(),
607                NirError::Unimplemented(_)
608            ));
609        }
610
611        #[test]
612        fn write_is_unimplemented() {
613            let g = NirGraph::new();
614            let err = write("out.nir", &g).unwrap_err();
615            assert!(matches!(err, NirError::Unimplemented(_)));
616        }
617    }
618}