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