Skip to main content

netcdf_reader/
lib.rs

1//! Pure-Rust NetCDF file reader.
2//!
3//! Supports:
4//! - **CDF-1** (classic): `CDF\x01` magic
5//! - **CDF-2** (64-bit offset): `CDF\x02` magic
6//! - **CDF-5** (64-bit data): `CDF\x05` magic
7//! - **NetCDF-4** (HDF5-backed): `\x89HDF\r\n\x1a\n` magic (requires `netcdf4` feature)
8//!
9//! # Example
10//!
11//! ```no_run
12//! use netcdf_reader::NcFile;
13//!
14//! let file = NcFile::open("example.nc").unwrap();
15//! println!("format: {:?}", file.format());
16//! for var in file.variables() {
17//!     println!("  variable: {} shape={:?}", var.name(), var.shape());
18//! }
19//! ```
20
21pub mod classic;
22pub mod error;
23pub mod masked;
24pub mod types;
25pub mod unpack;
26
27#[cfg(feature = "netcdf4")]
28pub mod nc4;
29
30#[cfg(feature = "cf")]
31pub mod cf;
32
33pub use error::{Error, Result};
34pub use types::*;
35
36use std::fs::File;
37use std::path::Path;
38
39use memmap2::Mmap;
40use ndarray::ArrayD;
41#[cfg(feature = "rayon")]
42use rayon::ThreadPool;
43
44/// Trait alias for types readable from both classic and NetCDF-4 files.
45///
46/// This unifies `classic::data::NcReadType` (for CDF-1/2/5) and
47/// `hdf5_reader::H5Type` (for NetCDF-4/HDF5) so that `NcFile::read_variable`
48/// works across all formats with a single type parameter.
49#[cfg(feature = "netcdf4")]
50pub trait NcReadable: classic::data::NcReadType + hdf5_reader::H5Type {}
51#[cfg(feature = "netcdf4")]
52impl<T: classic::data::NcReadType + hdf5_reader::H5Type> NcReadable for T {}
53
54#[cfg(not(feature = "netcdf4"))]
55pub trait NcReadable: classic::data::NcReadType {}
56#[cfg(not(feature = "netcdf4"))]
57impl<T: classic::data::NcReadType> NcReadable for T {}
58
59/// NetCDF file format.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum NcFormat {
62    /// CDF-1 classic format.
63    Classic,
64    /// CDF-2 64-bit offset format.
65    Offset64,
66    /// CDF-5 64-bit data format.
67    Cdf5,
68    /// NetCDF-4 (HDF5-backed).
69    Nc4,
70    /// NetCDF-4 classic model (HDF5-backed, restricted data model).
71    Nc4Classic,
72}
73
74/// An opened NetCDF file.
75pub struct NcFile {
76    format: NcFormat,
77    inner: NcFileInner,
78}
79
80enum NcFileInner {
81    Classic(classic::ClassicFile),
82    #[cfg(feature = "netcdf4")]
83    Nc4(nc4::Nc4File),
84}
85
86/// HDF5 magic bytes: `\x89HDF\r\n\x1a\n`
87const HDF5_MAGIC: [u8; 8] = [0x89, b'H', b'D', b'F', 0x0D, 0x0A, 0x1A, 0x0A];
88
89/// Detect the NetCDF format from the first bytes of a file.
90fn detect_format(data: &[u8]) -> Result<NcFormat> {
91    if data.len() < 4 {
92        return Err(Error::InvalidMagic);
93    }
94
95    // Check for CDF magic: "CDF" followed by version byte.
96    if data[0] == b'C' && data[1] == b'D' && data[2] == b'F' {
97        return match data[3] {
98            1 => Ok(NcFormat::Classic),
99            2 => Ok(NcFormat::Offset64),
100            5 => Ok(NcFormat::Cdf5),
101            v => Err(Error::UnsupportedVersion(v)),
102        };
103    }
104
105    // Check for HDF5 magic (8 bytes).
106    if data.len() >= 8 && data[..8] == HDF5_MAGIC {
107        return Ok(NcFormat::Nc4);
108    }
109
110    Err(Error::InvalidMagic)
111}
112
113impl NcFile {
114    /// Open a NetCDF file from a path.
115    ///
116    /// The format is auto-detected from the file's magic bytes.
117    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
118        Self::open_with_options(path, NcOpenOptions::default())
119    }
120
121    /// Open a NetCDF file from in-memory bytes.
122    ///
123    /// The format is auto-detected from the magic bytes.
124    pub fn from_bytes(data: &[u8]) -> Result<Self> {
125        Self::from_bytes_with_options(data, NcOpenOptions::default())
126    }
127
128    /// Open a NetCDF file from in-memory bytes with custom options.
129    ///
130    /// NC4 options are applied when the payload is HDF5-backed.
131    pub fn from_bytes_with_options(data: &[u8], options: NcOpenOptions) -> Result<Self> {
132        let format = detect_format(data)?;
133
134        match format {
135            NcFormat::Classic | NcFormat::Offset64 | NcFormat::Cdf5 => {
136                let classic = classic::ClassicFile::from_bytes(data, format)?;
137                Ok(NcFile {
138                    format,
139                    inner: NcFileInner::Classic(classic),
140                })
141            }
142            NcFormat::Nc4 | NcFormat::Nc4Classic => {
143                #[cfg(feature = "netcdf4")]
144                {
145                    let nc4 = nc4::Nc4File::from_bytes_with_options(data, options)?;
146                    let actual_format = if nc4.is_classic_model() {
147                        NcFormat::Nc4Classic
148                    } else {
149                        NcFormat::Nc4
150                    };
151                    Ok(NcFile {
152                        format: actual_format,
153                        inner: NcFileInner::Nc4(nc4),
154                    })
155                }
156                #[cfg(not(feature = "netcdf4"))]
157                {
158                    let _ = options;
159                    Err(Error::Nc4NotEnabled)
160                }
161            }
162        }
163    }
164
165    /// The detected file format.
166    pub fn format(&self) -> NcFormat {
167        self.format
168    }
169
170    /// The root group of the file.
171    ///
172    /// Classic files have a single implicit root group containing all
173    /// dimensions, variables, and global attributes. NetCDF-4 files
174    /// can have nested sub-groups.
175    pub fn root_group(&self) -> &NcGroup {
176        match &self.inner {
177            NcFileInner::Classic(c) => c.root_group(),
178            #[cfg(feature = "netcdf4")]
179            NcFileInner::Nc4(n) => n.root_group(),
180        }
181    }
182
183    /// Convenience: dimensions in the root group.
184    pub fn dimensions(&self) -> &[NcDimension] {
185        &self.root_group().dimensions
186    }
187
188    /// Convenience: variables in the root group.
189    pub fn variables(&self) -> &[NcVariable] {
190        &self.root_group().variables
191    }
192
193    /// Convenience: global attributes (attributes of the root group).
194    pub fn global_attributes(&self) -> &[NcAttribute] {
195        &self.root_group().attributes
196    }
197
198    /// Find a group by path relative to the root group.
199    pub fn group(&self, path: &str) -> Result<&NcGroup> {
200        self.root_group()
201            .group(path)
202            .ok_or_else(|| Error::GroupNotFound(path.to_string()))
203    }
204
205    /// Find a variable by name or path relative to the root group.
206    pub fn variable(&self, name: &str) -> Result<&NcVariable> {
207        self.root_group()
208            .variable(name)
209            .ok_or_else(|| Error::VariableNotFound(name.to_string()))
210    }
211
212    /// Find a dimension by name or path relative to the root group.
213    pub fn dimension(&self, name: &str) -> Result<&NcDimension> {
214        self.root_group()
215            .dimension(name)
216            .ok_or_else(|| Error::DimensionNotFound(name.to_string()))
217    }
218
219    /// Find a group attribute by name or path relative to the root group.
220    pub fn global_attribute(&self, name: &str) -> Result<&NcAttribute> {
221        self.root_group()
222            .attribute(name)
223            .ok_or_else(|| Error::AttributeNotFound(name.to_string()))
224    }
225
226    /// Read a variable's data as a typed array.
227    ///
228    /// Works for both classic (CDF-1/2/5) and NetCDF-4 files. NetCDF-4 nested
229    /// variables can be addressed with paths like `group/subgroup/var`. The type
230    /// parameter `T` must implement `NcReadable`, which is satisfied by:
231    /// `i8, u8, i16, u16, i32, u32, i64, u64, f32, f64`.
232    pub fn read_variable<T: NcReadable>(&self, name: &str) -> Result<ArrayD<T>> {
233        match &self.inner {
234            NcFileInner::Classic(c) => c.read_variable::<T>(name),
235            #[cfg(feature = "netcdf4")]
236            NcFileInner::Nc4(n) => Ok(n.read_variable::<T>(name)?),
237        }
238    }
239
240    /// Read a variable using internal chunk-level parallelism when available.
241    ///
242    /// Classic formats fall back to `read_variable`.
243    #[cfg(feature = "rayon")]
244    pub fn read_variable_parallel<T: NcReadable>(&self, name: &str) -> Result<ArrayD<T>> {
245        match &self.inner {
246            NcFileInner::Classic(c) => c.read_variable::<T>(name),
247            #[cfg(feature = "netcdf4")]
248            NcFileInner::Nc4(n) => Ok(n.read_variable_parallel::<T>(name)?),
249        }
250    }
251
252    /// Read a variable using the provided Rayon thread pool when available.
253    ///
254    /// Classic formats fall back to `read_variable`.
255    #[cfg(feature = "rayon")]
256    pub fn read_variable_in_pool<T: NcReadable>(
257        &self,
258        name: &str,
259        pool: &ThreadPool,
260    ) -> Result<ArrayD<T>> {
261        match &self.inner {
262            NcFileInner::Classic(c) => c.read_variable::<T>(name),
263            #[cfg(feature = "netcdf4")]
264            NcFileInner::Nc4(n) => Ok(n.read_variable_in_pool::<T>(name, pool)?),
265        }
266    }
267
268    /// Access the underlying classic file (for reading data).
269    ///
270    /// Returns `None` if this is a NetCDF-4 file.
271    pub fn as_classic(&self) -> Option<&classic::ClassicFile> {
272        match &self.inner {
273            NcFileInner::Classic(c) => Some(c),
274            #[cfg(feature = "netcdf4")]
275            NcFileInner::Nc4(_) => None,
276        }
277    }
278
279    /// Read a variable with automatic type promotion to f64.
280    ///
281    /// Reads in the native storage type (i8, i16, i32, f32, f64, u8, etc.)
282    /// and promotes all values to f64. This avoids the `TypeMismatch` error
283    /// that `read_variable::<f64>` produces for non-f64 variables.
284    pub fn read_variable_as_f64(&self, name: &str) -> Result<ArrayD<f64>> {
285        match &self.inner {
286            NcFileInner::Classic(c) => c.read_variable_as_f64(name),
287            #[cfg(feature = "netcdf4")]
288            NcFileInner::Nc4(n) => n.read_variable_as_f64(name),
289        }
290    }
291
292    /// Read a string variable as a single string.
293    ///
294    /// Use [`NcFile::read_variable_as_strings`] when the variable contains
295    /// multiple string elements.
296    pub fn read_variable_as_string(&self, name: &str) -> Result<String> {
297        match &self.inner {
298            NcFileInner::Classic(c) => c.read_variable_as_string(name),
299            #[cfg(feature = "netcdf4")]
300            NcFileInner::Nc4(n) => n.read_variable_as_string(name),
301        }
302    }
303
304    /// Read a string or char variable as a flat vector of strings.
305    ///
306    /// Classic char arrays interpret the last dimension as the string length
307    /// and flatten the leading dimensions.
308    pub fn read_variable_as_strings(&self, name: &str) -> Result<Vec<String>> {
309        match &self.inner {
310            NcFileInner::Classic(c) => c.read_variable_as_strings(name),
311            #[cfg(feature = "netcdf4")]
312            NcFileInner::Nc4(n) => n.read_variable_as_strings(name),
313        }
314    }
315
316    /// Read a variable and apply `scale_factor`/`add_offset` unpacking.
317    ///
318    /// Returns `actual = stored * scale_factor + add_offset`.
319    /// If neither attribute is present, returns the raw data as f64.
320    /// Uses type-promoting read so it works with any numeric storage type.
321    pub fn read_variable_unpacked(&self, name: &str) -> Result<ArrayD<f64>> {
322        let var = self.variable(name)?;
323        let params = unpack::UnpackParams::from_variable(var);
324        let mut data = self.read_variable_as_f64(name)?;
325        if let Some(p) = params {
326            p.apply(&mut data);
327        }
328        Ok(data)
329    }
330
331    /// Read a variable, replace `_FillValue`/`missing_value` with NaN,
332    /// and mask values outside `valid_min`/`valid_max`/`valid_range`.
333    /// Uses type-promoting read so it works with any numeric storage type.
334    pub fn read_variable_masked(&self, name: &str) -> Result<ArrayD<f64>> {
335        let var = self.variable(name)?;
336        let params = masked::MaskParams::from_variable(var);
337        let mut data = self.read_variable_as_f64(name)?;
338        if let Some(p) = params {
339            p.apply(&mut data);
340        }
341        Ok(data)
342    }
343
344    /// Read a variable with both masking and unpacking (CF spec order).
345    ///
346    /// Order: read → mask fill/missing → unpack (scale+offset).
347    /// Uses type-promoting read so it works with any numeric storage type.
348    pub fn read_variable_unpacked_masked(&self, name: &str) -> Result<ArrayD<f64>> {
349        let var = self.variable(name)?;
350        let mask_params = masked::MaskParams::from_variable(var);
351        let unpack_params = unpack::UnpackParams::from_variable(var);
352        let mut data = self.read_variable_as_f64(name)?;
353        if let Some(p) = mask_params {
354            p.apply(&mut data);
355        }
356        if let Some(p) = unpack_params {
357            p.apply(&mut data);
358        }
359        Ok(data)
360    }
361
362    // ----- Slice API -----
363
364    /// Read a slice (hyperslab) of a variable as a typed array.
365    pub fn read_variable_slice<T: NcReadable>(
366        &self,
367        name: &str,
368        selection: &NcSliceInfo,
369    ) -> Result<ArrayD<T>> {
370        match &self.inner {
371            NcFileInner::Classic(c) => c.read_variable_slice::<T>(name, selection),
372            #[cfg(feature = "netcdf4")]
373            NcFileInner::Nc4(n) => Ok(n.read_variable_slice::<T>(name, selection)?),
374        }
375    }
376
377    /// Read a slice (hyperslab) using chunk-level parallelism when available.
378    ///
379    /// For NetCDF-4 chunked datasets, overlapping chunks are decompressed in
380    /// parallel via Rayon. Classic formats fall back to `read_variable_slice`.
381    #[cfg(feature = "rayon")]
382    pub fn read_variable_slice_parallel<T: NcReadable>(
383        &self,
384        name: &str,
385        selection: &NcSliceInfo,
386    ) -> Result<ArrayD<T>> {
387        match &self.inner {
388            NcFileInner::Classic(c) => c.read_variable_slice::<T>(name, selection),
389            #[cfg(feature = "netcdf4")]
390            NcFileInner::Nc4(n) => Ok(n.read_variable_slice_parallel::<T>(name, selection)?),
391        }
392    }
393
394    /// Read a slice of a variable with automatic type promotion to f64.
395    pub fn read_variable_slice_as_f64(
396        &self,
397        name: &str,
398        selection: &NcSliceInfo,
399    ) -> Result<ArrayD<f64>> {
400        match &self.inner {
401            NcFileInner::Classic(c) => c.read_variable_slice_as_f64(name, selection),
402            #[cfg(feature = "netcdf4")]
403            NcFileInner::Nc4(n) => n.read_variable_slice_as_f64(name, selection),
404        }
405    }
406
407    /// Read a slice with `scale_factor`/`add_offset` unpacking.
408    pub fn read_variable_slice_unpacked(
409        &self,
410        name: &str,
411        selection: &NcSliceInfo,
412    ) -> Result<ArrayD<f64>> {
413        let var = self.variable(name)?;
414        let params = unpack::UnpackParams::from_variable(var);
415        let mut data = self.read_variable_slice_as_f64(name, selection)?;
416        if let Some(p) = params {
417            p.apply(&mut data);
418        }
419        Ok(data)
420    }
421
422    /// Read a slice with fill/missing value masking.
423    pub fn read_variable_slice_masked(
424        &self,
425        name: &str,
426        selection: &NcSliceInfo,
427    ) -> Result<ArrayD<f64>> {
428        let var = self.variable(name)?;
429        let params = masked::MaskParams::from_variable(var);
430        let mut data = self.read_variable_slice_as_f64(name, selection)?;
431        if let Some(p) = params {
432            p.apply(&mut data);
433        }
434        Ok(data)
435    }
436
437    /// Read a slice with both masking and unpacking (CF spec order).
438    pub fn read_variable_slice_unpacked_masked(
439        &self,
440        name: &str,
441        selection: &NcSliceInfo,
442    ) -> Result<ArrayD<f64>> {
443        let var = self.variable(name)?;
444        let mask_params = masked::MaskParams::from_variable(var);
445        let unpack_params = unpack::UnpackParams::from_variable(var);
446        let mut data = self.read_variable_slice_as_f64(name, selection)?;
447        if let Some(p) = mask_params {
448            p.apply(&mut data);
449        }
450        if let Some(p) = unpack_params {
451            p.apply(&mut data);
452        }
453        Ok(data)
454    }
455
456    // ----- Lazy Slice Iterator -----
457
458    /// Create an iterator that yields one slice per index along a given dimension.
459    ///
460    /// Each call to `next()` reads one slice using the slice API. This is
461    /// useful for iterating time steps, levels, etc. without loading the
462    /// entire dataset into memory.
463    pub fn iter_slices<T: NcReadable>(
464        &self,
465        name: &str,
466        dim: usize,
467    ) -> Result<NcSliceIterator<'_, T>> {
468        let var = self.variable(name)?;
469        let ndim = var.ndim();
470        if dim >= ndim {
471            return Err(Error::InvalidData(format!(
472                "dimension index {} out of range for {}-dimensional variable '{}'",
473                dim, ndim, name
474            )));
475        }
476        let dim_size = var.dimensions[dim].size;
477        Ok(NcSliceIterator {
478            file: self,
479            name: name.to_string(),
480            dim,
481            dim_size,
482            current: 0,
483            ndim,
484            _marker: std::marker::PhantomData,
485        })
486    }
487}
488
489/// Configuration options for opening a NetCDF file.
490pub struct NcOpenOptions {
491    /// Maximum bytes for the chunk cache (NC4 only). Default: 64 MiB.
492    pub chunk_cache_bytes: usize,
493    /// Maximum number of chunk cache slots (NC4 only). Default: 521.
494    pub chunk_cache_slots: usize,
495    /// Custom filter registry (NC4 only).
496    #[cfg(feature = "netcdf4")]
497    pub filter_registry: Option<hdf5_reader::FilterRegistry>,
498}
499
500impl Default for NcOpenOptions {
501    fn default() -> Self {
502        NcOpenOptions {
503            chunk_cache_bytes: 64 * 1024 * 1024,
504            chunk_cache_slots: 521,
505            #[cfg(feature = "netcdf4")]
506            filter_registry: None,
507        }
508    }
509}
510
511impl NcFile {
512    /// Open a NetCDF file with custom options.
513    pub fn open_with_options(path: impl AsRef<Path>, options: NcOpenOptions) -> Result<Self> {
514        let path = path.as_ref();
515        let file = File::open(path)?;
516        // SAFETY: read-only mapping; caller must not modify the file concurrently.
517        let mmap = unsafe { Mmap::map(&file)? };
518        let format = detect_format(&mmap)?;
519
520        match format {
521            NcFormat::Classic | NcFormat::Offset64 | NcFormat::Cdf5 => {
522                let classic = classic::ClassicFile::from_mmap(mmap, format)?;
523                Ok(NcFile {
524                    format,
525                    inner: NcFileInner::Classic(classic),
526                })
527            }
528            NcFormat::Nc4 | NcFormat::Nc4Classic => {
529                #[cfg(feature = "netcdf4")]
530                {
531                    let hdf5 = hdf5_reader::Hdf5File::from_mmap_with_options(
532                        mmap,
533                        hdf5_reader::OpenOptions {
534                            chunk_cache_bytes: options.chunk_cache_bytes,
535                            chunk_cache_slots: options.chunk_cache_slots,
536                            filter_registry: options.filter_registry,
537                        },
538                    )?;
539                    let root_group = nc4::groups::build_root_group(&hdf5)?;
540                    let nc4 = nc4::Nc4File::from_hdf5(hdf5, root_group);
541                    let actual_format = if nc4.is_classic_model() {
542                        NcFormat::Nc4Classic
543                    } else {
544                        NcFormat::Nc4
545                    };
546                    Ok(NcFile {
547                        format: actual_format,
548                        inner: NcFileInner::Nc4(nc4),
549                    })
550                }
551                #[cfg(not(feature = "netcdf4"))]
552                {
553                    let _ = options;
554                    Err(Error::Nc4NotEnabled)
555                }
556            }
557        }
558    }
559}
560
561/// Lazy iterator over slices of a variable along a given dimension.
562pub struct NcSliceIterator<'f, T: NcReadable> {
563    file: &'f NcFile,
564    name: String,
565    dim: usize,
566    dim_size: u64,
567    current: u64,
568    ndim: usize,
569    _marker: std::marker::PhantomData<T>,
570}
571
572impl<'f, T: NcReadable> Iterator for NcSliceIterator<'f, T> {
573    type Item = Result<ArrayD<T>>;
574
575    fn next(&mut self) -> Option<Self::Item> {
576        if self.current >= self.dim_size {
577            return None;
578        }
579        let mut selections = Vec::with_capacity(self.ndim);
580        for d in 0..self.ndim {
581            if d == self.dim {
582                selections.push(NcSliceInfoElem::Index(self.current));
583            } else {
584                selections.push(NcSliceInfoElem::Slice {
585                    start: 0,
586                    end: u64::MAX,
587                    step: 1,
588                });
589            }
590        }
591        let selection = NcSliceInfo { selections };
592        self.current += 1;
593        Some(self.file.read_variable_slice::<T>(&self.name, &selection))
594    }
595
596    fn size_hint(&self) -> (usize, Option<usize>) {
597        let remaining = (self.dim_size - self.current) as usize;
598        (remaining, Some(remaining))
599    }
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605
606    #[test]
607    fn test_detect_cdf1() {
608        let data = b"CDF\x01rest_of_file";
609        assert_eq!(detect_format(data).unwrap(), NcFormat::Classic);
610    }
611
612    #[test]
613    fn test_detect_cdf2() {
614        let data = b"CDF\x02rest_of_file";
615        assert_eq!(detect_format(data).unwrap(), NcFormat::Offset64);
616    }
617
618    #[test]
619    fn test_detect_cdf5() {
620        let data = b"CDF\x05rest_of_file";
621        assert_eq!(detect_format(data).unwrap(), NcFormat::Cdf5);
622    }
623
624    #[test]
625    fn test_detect_hdf5() {
626        let mut data = vec![0x89, b'H', b'D', b'F', 0x0D, 0x0A, 0x1A, 0x0A];
627        data.extend_from_slice(b"rest_of_file");
628        assert_eq!(detect_format(&data).unwrap(), NcFormat::Nc4);
629    }
630
631    #[test]
632    fn test_detect_invalid_magic() {
633        let data = b"XXXX";
634        assert!(matches!(
635            detect_format(data).unwrap_err(),
636            Error::InvalidMagic
637        ));
638    }
639
640    #[test]
641    fn test_detect_unsupported_version() {
642        let data = b"CDF\x03";
643        assert!(matches!(
644            detect_format(data).unwrap_err(),
645            Error::UnsupportedVersion(3)
646        ));
647    }
648
649    #[test]
650    fn test_detect_too_short() {
651        let data = b"CD";
652        assert!(matches!(
653            detect_format(data).unwrap_err(),
654            Error::InvalidMagic
655        ));
656    }
657
658    #[test]
659    fn test_from_bytes_minimal_cdf1() {
660        // Minimal valid CDF-1 file: magic + numrecs + absent dim/att/var lists.
661        let mut data = Vec::new();
662        data.extend_from_slice(b"CDF\x01");
663        data.extend_from_slice(&0u32.to_be_bytes()); // numrecs = 0
664                                                     // dim_list: ABSENT
665        data.extend_from_slice(&0u32.to_be_bytes()); // tag = 0
666        data.extend_from_slice(&0u32.to_be_bytes()); // count = 0
667                                                     // att_list: ABSENT
668        data.extend_from_slice(&0u32.to_be_bytes());
669        data.extend_from_slice(&0u32.to_be_bytes());
670        // var_list: ABSENT
671        data.extend_from_slice(&0u32.to_be_bytes());
672        data.extend_from_slice(&0u32.to_be_bytes());
673
674        let file = NcFile::from_bytes(&data).unwrap();
675        assert_eq!(file.format(), NcFormat::Classic);
676        assert!(file.dimensions().is_empty());
677        assert!(file.variables().is_empty());
678        assert!(file.global_attributes().is_empty());
679    }
680
681    #[test]
682    fn test_from_bytes_cdf1_with_data() {
683        // Build a CDF-1 file with one dimension, one global attribute, and one variable.
684        let mut data = Vec::new();
685        data.extend_from_slice(b"CDF\x01");
686        data.extend_from_slice(&0u32.to_be_bytes()); // numrecs = 0
687
688        // dim_list: 1 dimension "x" with size 3
689        data.extend_from_slice(&0x0000_000Au32.to_be_bytes()); // NC_DIMENSION tag
690        data.extend_from_slice(&1u32.to_be_bytes()); // nelems = 1
691                                                     // name "x": length=1, "x", 3 bytes padding
692        data.extend_from_slice(&1u32.to_be_bytes());
693        data.push(b'x');
694        data.extend_from_slice(&[0, 0, 0]); // padding to 4
695                                            // dim size
696        data.extend_from_slice(&3u32.to_be_bytes());
697
698        // att_list: 1 attribute "title" = "test"
699        data.extend_from_slice(&0x0000_000Cu32.to_be_bytes()); // NC_ATTRIBUTE tag
700        data.extend_from_slice(&1u32.to_be_bytes()); // nelems = 1
701                                                     // name "title"
702        data.extend_from_slice(&5u32.to_be_bytes());
703        data.extend_from_slice(b"title");
704        data.extend_from_slice(&[0, 0, 0]); // padding
705                                            // nc_type = NC_CHAR = 2
706        data.extend_from_slice(&2u32.to_be_bytes());
707        // nvalues = 4
708        data.extend_from_slice(&4u32.to_be_bytes());
709        data.extend_from_slice(b"test"); // exactly 4 bytes, no padding needed
710
711        // var_list: 1 variable "vals" with dim x, type float
712        data.extend_from_slice(&0x0000_000Bu32.to_be_bytes()); // NC_VARIABLE tag
713        data.extend_from_slice(&1u32.to_be_bytes()); // nelems = 1
714                                                     // name "vals"
715        data.extend_from_slice(&4u32.to_be_bytes());
716        data.extend_from_slice(b"vals");
717        // ndims = 1
718        data.extend_from_slice(&1u32.to_be_bytes());
719        // dimid = 0
720        data.extend_from_slice(&0u32.to_be_bytes());
721        // att_list: absent
722        data.extend_from_slice(&0u32.to_be_bytes());
723        data.extend_from_slice(&0u32.to_be_bytes());
724        // nc_type = NC_FLOAT = 5
725        data.extend_from_slice(&5u32.to_be_bytes());
726        // vsize = 12 (3 floats * 4 bytes)
727        data.extend_from_slice(&12u32.to_be_bytes());
728        // begin (offset): we'll put data right after this header
729        let data_offset = data.len() as u32 + 4; // +4 for this field itself
730        data.extend_from_slice(&data_offset.to_be_bytes());
731
732        // Now append the variable data: 3 floats
733        data.extend_from_slice(&1.5f32.to_be_bytes());
734        data.extend_from_slice(&2.5f32.to_be_bytes());
735        data.extend_from_slice(&3.5f32.to_be_bytes());
736
737        let file = NcFile::from_bytes(&data).unwrap();
738        assert_eq!(file.format(), NcFormat::Classic);
739        assert_eq!(file.dimensions().len(), 1);
740        assert_eq!(file.dimensions()[0].name, "x");
741        assert_eq!(file.dimensions()[0].size, 3);
742
743        assert_eq!(file.global_attributes().len(), 1);
744        assert_eq!(file.global_attributes()[0].name, "title");
745        assert_eq!(
746            file.global_attributes()[0].value.as_string().unwrap(),
747            "test"
748        );
749
750        assert_eq!(file.variables().len(), 1);
751        let var = file.variable("vals").unwrap();
752        assert_eq!(var.dtype(), &NcType::Float);
753        assert_eq!(var.shape(), vec![3]);
754
755        // Read the actual data through the classic file.
756        let classic = file.as_classic().unwrap();
757        let arr: ndarray::ArrayD<f32> = classic.read_variable("vals").unwrap();
758        assert_eq!(arr.shape(), &[3]);
759        assert_eq!(arr[[0]], 1.5f32);
760        assert_eq!(arr[[1]], 2.5f32);
761        assert_eq!(arr[[2]], 3.5f32);
762    }
763
764    #[test]
765    fn test_variable_not_found() {
766        let mut data = Vec::new();
767        data.extend_from_slice(b"CDF\x01");
768        data.extend_from_slice(&0u32.to_be_bytes());
769        // All absent.
770        data.extend_from_slice(&0u32.to_be_bytes());
771        data.extend_from_slice(&0u32.to_be_bytes());
772        data.extend_from_slice(&0u32.to_be_bytes());
773        data.extend_from_slice(&0u32.to_be_bytes());
774        data.extend_from_slice(&0u32.to_be_bytes());
775        data.extend_from_slice(&0u32.to_be_bytes());
776
777        let file = NcFile::from_bytes(&data).unwrap();
778        assert!(matches!(
779            file.variable("nonexistent").unwrap_err(),
780            Error::VariableNotFound(_)
781        ));
782    }
783
784    #[test]
785    fn test_group_not_found() {
786        let mut data = Vec::new();
787        data.extend_from_slice(b"CDF\x01");
788        data.extend_from_slice(&0u32.to_be_bytes());
789        data.extend_from_slice(&0u32.to_be_bytes());
790        data.extend_from_slice(&0u32.to_be_bytes());
791        data.extend_from_slice(&0u32.to_be_bytes());
792        data.extend_from_slice(&0u32.to_be_bytes());
793        data.extend_from_slice(&0u32.to_be_bytes());
794        data.extend_from_slice(&0u32.to_be_bytes());
795
796        let file = NcFile::from_bytes(&data).unwrap();
797        assert!(matches!(
798            file.group("nonexistent").unwrap_err(),
799            Error::GroupNotFound(_)
800        ));
801    }
802}