Skip to main content

oxigdal_netcdf/
error.rs

1//! Error types for NetCDF operations.
2//!
3//! This module provides comprehensive error handling for NetCDF file operations,
4//! including I/O errors, format errors, dimension errors, variable errors, and
5//! attribute errors.
6//!
7//! # Error Codes
8//!
9//! Each error variant has an associated error code (e.g., N001, N002) for easier
10//! debugging and documentation. Error codes are stable across versions.
11//!
12//! # Helper Methods
13//!
14//! All error types provide:
15//! - `code()` - Returns the error code
16//! - `suggestion()` - Returns helpful hints for fixing the error
17//! - `context()` - Returns additional context about the error (variable/dimension names, etc.)
18
19use core::fmt;
20
21#[cfg(feature = "std")]
22use std::error::Error as StdError;
23
24use oxigdal_core::error::OxiGdalError;
25
26/// Result type for NetCDF operations.
27pub type Result<T> = core::result::Result<T, NetCdfError>;
28
29/// NetCDF-specific error types.
30#[derive(Debug)]
31pub enum NetCdfError {
32    /// I/O error occurred
33    Io(String),
34
35    /// Invalid NetCDF format
36    InvalidFormat(String),
37
38    /// Version not supported
39    UnsupportedVersion { version: u8, message: String },
40
41    /// Dimension error
42    DimensionError(String),
43
44    /// Dimension not found
45    DimensionNotFound { name: String },
46
47    /// Variable error
48    VariableError(String),
49
50    /// Variable not found
51    VariableNotFound { name: String },
52
53    /// Attribute error
54    AttributeError(String),
55
56    /// Attribute not found
57    AttributeNotFound { name: String },
58
59    /// Data type mismatch
60    DataTypeMismatch { expected: String, found: String },
61
62    /// Invalid shape or dimensions
63    InvalidShape { message: String },
64
65    /// Unlimited dimension error
66    UnlimitedDimensionError(String),
67
68    /// Index out of bounds
69    IndexOutOfBounds {
70        index: usize,
71        length: usize,
72        dimension: String,
73    },
74
75    /// String encoding error
76    StringEncodingError(String),
77
78    /// Feature not enabled
79    FeatureNotEnabled { feature: String, message: String },
80
81    /// NetCDF-4 not available (requires feature flag)
82    NetCdf4NotAvailable,
83
84    /// Compression not supported
85    CompressionNotSupported { compression: String },
86
87    /// Invalid compression parameters
88    InvalidCompressionParams(String),
89
90    /// CF conventions error
91    CfConventionsError(String),
92
93    /// Coordinate variable error
94    CoordinateError(String),
95
96    /// File already exists
97    FileAlreadyExists { path: String },
98
99    /// File not found
100    FileNotFound { path: String },
101
102    /// Permission denied
103    PermissionDenied { path: String },
104
105    /// Invalid file mode
106    InvalidFileMode { mode: String },
107
108    /// NetCDF library error (for netcdf4 feature)
109    #[cfg(feature = "netcdf4")]
110    NetCdfLibError(String),
111
112    /// Generic error
113    Other(String),
114
115    /// Error from oxigdal-core
116    Core(OxiGdalError),
117}
118
119impl fmt::Display for NetCdfError {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        match self {
122            Self::Io(msg) => write!(f, "I/O error: {msg}"),
123            Self::InvalidFormat(msg) => write!(f, "Invalid NetCDF format: {msg}"),
124            Self::UnsupportedVersion { version, message } => {
125                write!(f, "Unsupported NetCDF version {version}: {message}")
126            }
127            Self::DimensionError(msg) => write!(f, "Dimension error: {msg}"),
128            Self::DimensionNotFound { name } => write!(f, "Dimension not found: {name}"),
129            Self::VariableError(msg) => write!(f, "Variable error: {msg}"),
130            Self::VariableNotFound { name } => write!(f, "Variable not found: {name}"),
131            Self::AttributeError(msg) => write!(f, "Attribute error: {msg}"),
132            Self::AttributeNotFound { name } => write!(f, "Attribute not found: {name}"),
133            Self::DataTypeMismatch { expected, found } => {
134                write!(f, "Data type mismatch: expected {expected}, found {found}")
135            }
136            Self::InvalidShape { message } => write!(f, "Invalid shape: {message}"),
137            Self::UnlimitedDimensionError(msg) => {
138                write!(f, "Unlimited dimension error: {msg}")
139            }
140            Self::IndexOutOfBounds {
141                index,
142                length,
143                dimension,
144            } => {
145                write!(
146                    f,
147                    "Index {index} out of bounds for dimension '{dimension}' with length {length}"
148                )
149            }
150            Self::StringEncodingError(msg) => write!(f, "String encoding error: {msg}"),
151            Self::FeatureNotEnabled { feature, message } => {
152                write!(f, "Feature '{feature}' not enabled: {message}")
153            }
154            Self::NetCdf4NotAvailable => {
155                write!(
156                    f,
157                    "NetCDF-4/HDF5 reading is not yet implemented in this Pure Rust build; \
158                     only NetCDF-3 (classic and 64-bit offset) files are currently supported for reading. \
159                     The file was detected as HDF5-based NetCDF-4."
160                )
161            }
162            Self::CompressionNotSupported { compression } => {
163                write!(f, "Compression not supported: {compression}")
164            }
165            Self::InvalidCompressionParams(msg) => {
166                write!(f, "Invalid compression parameters: {msg}")
167            }
168            Self::CfConventionsError(msg) => write!(f, "CF conventions error: {msg}"),
169            Self::CoordinateError(msg) => write!(f, "Coordinate error: {msg}"),
170            Self::FileAlreadyExists { path } => write!(f, "File already exists: {path}"),
171            Self::FileNotFound { path } => write!(f, "File not found: {path}"),
172            Self::PermissionDenied { path } => write!(f, "Permission denied: {path}"),
173            Self::InvalidFileMode { mode } => write!(f, "Invalid file mode: {mode}"),
174            #[cfg(feature = "netcdf4")]
175            Self::NetCdfLibError(msg) => write!(f, "NetCDF library error: {msg}"),
176            Self::Other(msg) => write!(f, "{msg}"),
177            Self::Core(err) => write!(f, "Core error: {err}"),
178        }
179    }
180}
181
182#[cfg(feature = "std")]
183impl StdError for NetCdfError {
184    fn source(&self) -> Option<&(dyn StdError + 'static)> {
185        match self {
186            Self::Core(err) => Some(err),
187            _ => None,
188        }
189    }
190}
191
192impl From<OxiGdalError> for NetCdfError {
193    fn from(err: OxiGdalError) -> Self {
194        Self::Core(err)
195    }
196}
197
198#[cfg(feature = "std")]
199impl From<std::io::Error> for NetCdfError {
200    fn from(err: std::io::Error) -> Self {
201        use std::io::ErrorKind;
202        match err.kind() {
203            ErrorKind::NotFound => Self::Io(format!("File not found: {err}")),
204            ErrorKind::PermissionDenied => Self::Io(format!("Permission denied: {err}")),
205            ErrorKind::AlreadyExists => Self::Io(format!("File already exists: {err}")),
206            _ => Self::Io(err.to_string()),
207        }
208    }
209}
210
211#[cfg(feature = "std")]
212impl From<std::string::FromUtf8Error> for NetCdfError {
213    fn from(err: std::string::FromUtf8Error) -> Self {
214        Self::StringEncodingError(err.to_string())
215    }
216}
217
218impl From<core::str::Utf8Error> for NetCdfError {
219    fn from(err: core::str::Utf8Error) -> Self {
220        Self::StringEncodingError(format!("UTF-8 error: {err}"))
221    }
222}
223
224#[cfg(feature = "netcdf4")]
225impl From<netcdf::error::Error> for NetCdfError {
226    fn from(err: netcdf::error::Error) -> Self {
227        Self::NetCdfLibError(err.to_string())
228    }
229}
230
231impl From<serde_json::Error> for NetCdfError {
232    fn from(err: serde_json::Error) -> Self {
233        Self::Other(format!("JSON error: {err}"))
234    }
235}
236
237#[cfg(feature = "netcdf3")]
238impl From<netcdf3::InvalidDataSet> for NetCdfError {
239    fn from(err: netcdf3::InvalidDataSet) -> Self {
240        Self::Other(format!("Invalid DataSet: {err}"))
241    }
242}
243
244#[cfg(feature = "netcdf3")]
245impl From<netcdf3::WriteError> for NetCdfError {
246    fn from(err: netcdf3::WriteError) -> Self {
247        Self::Io(format!("Write error: {err:?}"))
248    }
249}
250
251#[cfg(feature = "netcdf3")]
252impl From<netcdf3::ReadError> for NetCdfError {
253    fn from(err: netcdf3::ReadError) -> Self {
254        Self::Io(format!("Read error: {err:?}"))
255    }
256}
257
258impl NetCdfError {
259    /// Get the error code for this NetCDF error
260    ///
261    /// Error codes are stable across versions and can be used for documentation
262    /// and error handling.
263    pub fn code(&self) -> &'static str {
264        match self {
265            Self::Io(_) => "N001",
266            Self::InvalidFormat(_) => "N002",
267            Self::UnsupportedVersion { .. } => "N003",
268            Self::DimensionError(_) => "N004",
269            Self::DimensionNotFound { .. } => "N005",
270            Self::VariableError(_) => "N006",
271            Self::VariableNotFound { .. } => "N007",
272            Self::AttributeError(_) => "N008",
273            Self::AttributeNotFound { .. } => "N009",
274            Self::DataTypeMismatch { .. } => "N010",
275            Self::InvalidShape { .. } => "N011",
276            Self::UnlimitedDimensionError(_) => "N012",
277            Self::IndexOutOfBounds { .. } => "N013",
278            Self::StringEncodingError(_) => "N014",
279            Self::FeatureNotEnabled { .. } => "N015",
280            Self::NetCdf4NotAvailable => "N016",
281            Self::CompressionNotSupported { .. } => "N017",
282            Self::InvalidCompressionParams(_) => "N018",
283            Self::CfConventionsError(_) => "N019",
284            Self::CoordinateError(_) => "N020",
285            Self::FileAlreadyExists { .. } => "N021",
286            Self::FileNotFound { .. } => "N022",
287            Self::PermissionDenied { .. } => "N023",
288            Self::InvalidFileMode { .. } => "N024",
289            #[cfg(feature = "netcdf4")]
290            Self::NetCdfLibError(_) => "N025",
291            Self::Other(_) => "N099",
292            Self::Core(_) => "N100",
293        }
294    }
295
296    /// Get a helpful suggestion for fixing this NetCDF error
297    ///
298    /// Returns a human-readable suggestion that can help users resolve the error.
299    pub fn suggestion(&self) -> Option<&'static str> {
300        match self {
301            Self::Io(_) => Some("Check file permissions and network connectivity"),
302            Self::InvalidFormat(_) => {
303                Some("Verify the file is a valid NetCDF file. Try using ncdump")
304            }
305            Self::UnsupportedVersion { .. } => {
306                Some("This NetCDF version is not supported. Try NetCDF-3 Classic format")
307            }
308            Self::DimensionError(_) => Some("Check dimension definitions and sizes"),
309            Self::DimensionNotFound { .. } => Some("Use ncdump -h to list available dimensions"),
310            Self::VariableError(_) => Some("Check variable definitions and data types"),
311            Self::VariableNotFound { .. } => Some("Use ncdump -h to list available variables"),
312            Self::AttributeError(_) => Some("Check attribute name and type"),
313            Self::AttributeNotFound { .. } => Some("Use ncdump -h to list available attributes"),
314            Self::DataTypeMismatch { .. } => {
315                Some("Ensure the data type matches the variable definition")
316            }
317            Self::InvalidShape { .. } => {
318                Some("Verify the data dimensions match the variable shape")
319            }
320            Self::UnlimitedDimensionError(_) => Some("Check unlimited dimension is defined first"),
321            Self::IndexOutOfBounds { .. } => Some("Verify indices are within dimension bounds"),
322            Self::StringEncodingError(_) => Some("Ensure string data is valid UTF-8"),
323            Self::FeatureNotEnabled { .. } => {
324                Some("Enable the required feature flag in Cargo.toml")
325            }
326            Self::NetCdf4NotAvailable => Some(
327                "NetCDF-4/HDF5 reading is not yet implemented; convert the file to NetCDF-3 \
328                 classic (e.g. `nccopy -k classic input.nc output.nc`) to read it with this Pure Rust driver",
329            ),
330            Self::CompressionNotSupported { .. } => {
331                Some("Use a supported compression algorithm or disable compression")
332            }
333            Self::InvalidCompressionParams(_) => Some("Check compression level is between 0-9"),
334            Self::CfConventionsError(_) => {
335                Some("Ensure the file follows CF conventions. See https://cfconventions.org")
336            }
337            Self::CoordinateError(_) => Some("Check coordinate variable definitions and values"),
338            Self::FileAlreadyExists { .. } => {
339                Some("Choose a different filename or delete the existing file")
340            }
341            Self::FileNotFound { .. } => {
342                Some("Verify the file path is correct and the file exists")
343            }
344            Self::PermissionDenied { .. } => {
345                Some("Check file permissions or run with appropriate privileges")
346            }
347            Self::InvalidFileMode { .. } => {
348                Some("Use a valid file mode: 'r' (read), 'w' (write), or 'a' (append)")
349            }
350            #[cfg(feature = "netcdf4")]
351            Self::NetCdfLibError(_) => Some("Check the NetCDF-C library is properly installed"),
352            Self::Other(_) => Some("Check the error message for details"),
353            Self::Core(_) => Some("Check the underlying error message for details"),
354        }
355    }
356
357    /// Get additional context about this NetCDF error
358    ///
359    /// Returns structured context information including variable/dimension names.
360    pub fn context(&self) -> ErrorContext {
361        match self {
362            Self::Io(msg) => ErrorContext::new("io_error").with_detail("message", msg.clone()),
363            Self::InvalidFormat(msg) => {
364                ErrorContext::new("invalid_format").with_detail("message", msg.clone())
365            }
366            Self::UnsupportedVersion { version, message } => {
367                ErrorContext::new("unsupported_version")
368                    .with_detail("version", version.to_string())
369                    .with_detail("message", message.clone())
370            }
371            Self::DimensionError(msg) => {
372                ErrorContext::new("dimension_error").with_detail("message", msg.clone())
373            }
374            Self::DimensionNotFound { name } => {
375                ErrorContext::new("dimension_not_found").with_detail("dimension", name.clone())
376            }
377            Self::VariableError(msg) => {
378                ErrorContext::new("variable_error").with_detail("message", msg.clone())
379            }
380            Self::VariableNotFound { name } => {
381                ErrorContext::new("variable_not_found").with_detail("variable", name.clone())
382            }
383            Self::AttributeError(msg) => {
384                ErrorContext::new("attribute_error").with_detail("message", msg.clone())
385            }
386            Self::AttributeNotFound { name } => {
387                ErrorContext::new("attribute_not_found").with_detail("attribute", name.clone())
388            }
389            Self::DataTypeMismatch { expected, found } => ErrorContext::new("data_type_mismatch")
390                .with_detail("expected", expected.clone())
391                .with_detail("found", found.clone()),
392            Self::InvalidShape { message } => {
393                ErrorContext::new("invalid_shape").with_detail("message", message.clone())
394            }
395            Self::UnlimitedDimensionError(msg) => {
396                ErrorContext::new("unlimited_dimension_error").with_detail("message", msg.clone())
397            }
398            Self::IndexOutOfBounds {
399                index,
400                length,
401                dimension,
402            } => ErrorContext::new("index_out_of_bounds")
403                .with_detail("index", index.to_string())
404                .with_detail("length", length.to_string())
405                .with_detail("dimension", dimension.clone()),
406            Self::StringEncodingError(msg) => {
407                ErrorContext::new("string_encoding_error").with_detail("message", msg.clone())
408            }
409            Self::FeatureNotEnabled { feature, message } => {
410                ErrorContext::new("feature_not_enabled")
411                    .with_detail("feature", feature.clone())
412                    .with_detail("message", message.clone())
413            }
414            Self::NetCdf4NotAvailable => ErrorContext::new("netcdf4_not_available"),
415            Self::CompressionNotSupported { compression } => {
416                ErrorContext::new("compression_not_supported")
417                    .with_detail("compression", compression.clone())
418            }
419            Self::InvalidCompressionParams(msg) => {
420                ErrorContext::new("invalid_compression_params").with_detail("message", msg.clone())
421            }
422            Self::CfConventionsError(msg) => {
423                ErrorContext::new("cf_conventions_error").with_detail("message", msg.clone())
424            }
425            Self::CoordinateError(msg) => {
426                ErrorContext::new("coordinate_error").with_detail("message", msg.clone())
427            }
428            Self::FileAlreadyExists { path } => {
429                ErrorContext::new("file_already_exists").with_detail("path", path.clone())
430            }
431            Self::FileNotFound { path } => {
432                ErrorContext::new("file_not_found").with_detail("path", path.clone())
433            }
434            Self::PermissionDenied { path } => {
435                ErrorContext::new("permission_denied").with_detail("path", path.clone())
436            }
437            Self::InvalidFileMode { mode } => {
438                ErrorContext::new("invalid_file_mode").with_detail("mode", mode.clone())
439            }
440            #[cfg(feature = "netcdf4")]
441            Self::NetCdfLibError(msg) => {
442                ErrorContext::new("netcdf_lib_error").with_detail("message", msg.clone())
443            }
444            Self::Other(msg) => ErrorContext::new("other").with_detail("message", msg.clone()),
445            Self::Core(e) => ErrorContext::new("core_error").with_detail("error", e.to_string()),
446        }
447    }
448}
449
450/// Additional context information for NetCDF errors
451#[derive(Debug, Clone)]
452pub struct ErrorContext {
453    /// Error category for grouping similar errors
454    pub category: &'static str,
455    /// Additional details about the error (variable/dimension names, etc.)
456    pub details: Vec<(String, String)>,
457}
458
459impl ErrorContext {
460    /// Create a new error context
461    pub fn new(category: &'static str) -> Self {
462        Self {
463            category,
464            details: Vec::new(),
465        }
466    }
467
468    /// Add a detail to the context
469    pub fn with_detail(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
470        self.details.push((key.into(), value.into()));
471        self
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    #[test]
480    fn test_error_display() {
481        let err = NetCdfError::DimensionNotFound {
482            name: "time".to_string(),
483        };
484        assert_eq!(err.to_string(), "Dimension not found: time");
485
486        let err = NetCdfError::DataTypeMismatch {
487            expected: "f32".to_string(),
488            found: "f64".to_string(),
489        };
490        assert_eq!(
491            err.to_string(),
492            "Data type mismatch: expected f32, found f64"
493        );
494
495        let err = NetCdfError::IndexOutOfBounds {
496            index: 10,
497            length: 5,
498            dimension: "time".to_string(),
499        };
500        assert_eq!(
501            err.to_string(),
502            "Index 10 out of bounds for dimension 'time' with length 5"
503        );
504    }
505
506    #[test]
507    fn test_netcdf4_not_available() {
508        let err = NetCdfError::NetCdf4NotAvailable;
509        let msg = err.to_string();
510        assert!(msg.contains("NetCDF-4"));
511        assert!(msg.contains("Pure Rust"));
512        assert!(msg.contains("NetCDF-3"));
513        // The message must not point users at a Cargo feature that no longer
514        // exists in Cargo.toml.
515        assert!(!msg.contains("Enable 'netcdf4' feature"));
516
517        // The suggestion must likewise not reference the removed feature flag.
518        let suggestion = err.suggestion().expect("suggestion should be present");
519        assert!(!suggestion.contains("'netcdf4' feature"));
520        assert!(suggestion.contains("NetCDF-3"));
521    }
522
523    #[test]
524    fn test_error_codes() {
525        let err = NetCdfError::VariableNotFound {
526            name: "temperature".to_string(),
527        };
528        assert_eq!(err.code(), "N007");
529
530        let err = NetCdfError::DimensionNotFound {
531            name: "time".to_string(),
532        };
533        assert_eq!(err.code(), "N005");
534
535        let err = NetCdfError::AttributeNotFound {
536            name: "units".to_string(),
537        };
538        assert_eq!(err.code(), "N009");
539    }
540
541    #[test]
542    fn test_error_suggestions() {
543        let err = NetCdfError::VariableNotFound {
544            name: "temperature".to_string(),
545        };
546        assert!(err.suggestion().is_some());
547        assert!(err.suggestion().is_some_and(|s| s.contains("ncdump")));
548
549        let err = NetCdfError::DimensionNotFound {
550            name: "time".to_string(),
551        };
552        assert!(err.suggestion().is_some());
553        assert!(err.suggestion().is_some_and(|s| s.contains("ncdump")));
554    }
555
556    #[test]
557    fn test_error_context() {
558        let err = NetCdfError::VariableNotFound {
559            name: "temperature".to_string(),
560        };
561        let ctx = err.context();
562        assert_eq!(ctx.category, "variable_not_found");
563        assert!(
564            ctx.details
565                .iter()
566                .any(|(k, v)| k == "variable" && v == "temperature")
567        );
568
569        let err = NetCdfError::DimensionNotFound {
570            name: "time".to_string(),
571        };
572        let ctx = err.context();
573        assert_eq!(ctx.category, "dimension_not_found");
574        assert!(
575            ctx.details
576                .iter()
577                .any(|(k, v)| k == "dimension" && v == "time")
578        );
579
580        let err = NetCdfError::IndexOutOfBounds {
581            index: 10,
582            length: 5,
583            dimension: "time".to_string(),
584        };
585        let ctx = err.context();
586        assert_eq!(ctx.category, "index_out_of_bounds");
587        assert!(ctx.details.iter().any(|(k, v)| k == "index" && v == "10"));
588        assert!(
589            ctx.details
590                .iter()
591                .any(|(k, v)| k == "dimension" && v == "time")
592        );
593    }
594}