Skip to main content

cobre_io/output/
error.rs

1//! Error types for the `cobre-io` output writing pipeline.
2//!
3//! [`OutputError`] is the primary error type returned by all output writer functions.
4//! Each variant carries enough context for the caller to produce a diagnostic message
5//! without re-reading the output files.
6
7use std::io::Error;
8use std::path::{Path, PathBuf};
9
10/// Errors that can occur during output writing operations.
11///
12/// # Examples
13///
14/// ```
15/// use cobre_io::OutputError;
16/// use std::path::PathBuf;
17///
18/// let err = OutputError::SchemaError {
19///     file: "convergence.parquet".to_string(),
20///     column: "iteration".to_string(),
21///     message: "column type mismatch".to_string(),
22/// };
23/// assert!(err.to_string().contains("convergence.parquet"));
24/// assert!(err.to_string().contains("iteration"));
25/// ```
26#[derive(Debug, thiserror::Error)]
27pub enum OutputError {
28    /// Filesystem I/O failure (read or write) — the variant is shared by the
29    /// checkpoint reader and the output writers, so the wording is direction-neutral.
30    #[error("I/O error accessing {path}: {source}")]
31    IoError {
32        /// Path to the file that could not be accessed.
33        path: PathBuf,
34        /// Underlying I/O error.
35        source: Error,
36    },
37
38    /// Arrow/Parquet encoding failure.
39    #[error("serialization error for entity {entity}: {message}")]
40    SerializationError {
41        /// Name of the entity collection (e.g., `"hydros"`).
42        entity: String,
43        /// Human-readable error description.
44        message: String,
45    },
46
47    /// Parquet schema validation failure.
48    #[error("schema error in {file}, column {column}: {message}")]
49    SchemaError {
50        /// Name of the Parquet file being validated.
51        file: String,
52        /// Name of the column that failed validation.
53        column: String,
54        /// Human-readable error description.
55        message: String,
56    },
57
58    /// Manifest construction failure.
59    #[error("manifest error for {manifest_type}: {message}")]
60    ManifestError {
61        /// Type of manifest being constructed.
62        manifest_type: String,
63        /// Human-readable error description.
64        message: String,
65    },
66}
67
68impl OutputError {
69    /// Construct an [`OutputError::IoError`] from a path and source error.
70    ///
71    /// # Examples
72    ///
73    /// ```
74    /// use cobre_io::OutputError;
75    /// use std::io;
76    ///
77    /// let io_err = io::Error::new(io::ErrorKind::NotFound, "no such file");
78    /// let err = OutputError::io("simulation/costs/data.parquet", io_err);
79    /// assert!(err.to_string().contains("simulation/costs/data.parquet"));
80    /// ```
81    pub fn io(path: impl AsRef<Path>, source: Error) -> Self {
82        Self::IoError {
83            path: path.as_ref().to_path_buf(),
84            source,
85        }
86    }
87
88    /// Construct an [`OutputError::SerializationError`] with entity context and a message.
89    ///
90    /// # Examples
91    ///
92    /// ```
93    /// use cobre_io::OutputError;
94    ///
95    /// let err = OutputError::serialization("hydros", "unsupported field type");
96    /// assert!(err.to_string().contains("hydros"));
97    /// assert!(err.to_string().contains("unsupported field type"));
98    /// ```
99    pub fn serialization(entity: impl Into<String>, message: impl Into<String>) -> Self {
100        Self::SerializationError {
101            entity: entity.into(),
102            message: message.into(),
103        }
104    }
105}
106
107#[cfg(test)]
108#[allow(clippy::unwrap_used)]
109mod tests {
110    use super::*;
111    use std::io;
112
113    fn assert_send_sync_static<E: std::error::Error + Send + Sync + 'static>() {}
114
115    #[test]
116    fn display_io_error_contains_path_and_source() {
117        let io_err = io::Error::new(io::ErrorKind::NotFound, "no such file or directory");
118        let err = OutputError::io("simulation/costs/data.parquet", io_err);
119        let display = err.to_string();
120        assert!(
121            display.contains("simulation/costs/data.parquet"),
122            "display should contain path, got: {display}"
123        );
124        assert!(
125            display.contains("no such file or directory"),
126            "display should contain source message, got: {display}"
127        );
128    }
129
130    #[test]
131    fn display_serialization_error_contains_entity_and_message() {
132        let err = OutputError::serialization("hydros", "unsupported field type");
133        let display = err.to_string();
134        assert!(
135            display.contains("hydros"),
136            "display should contain entity, got: {display}"
137        );
138        assert!(
139            display.contains("unsupported field type"),
140            "display should contain message, got: {display}"
141        );
142    }
143
144    #[test]
145    fn display_schema_error_contains_file_and_column() {
146        let err = OutputError::SchemaError {
147            file: "convergence.parquet".to_string(),
148            column: "iteration".to_string(),
149            message: "column type mismatch".to_string(),
150        };
151        let display = err.to_string();
152        assert!(
153            display.contains("convergence.parquet"),
154            "display should contain file name, got: {display}"
155        );
156        assert!(
157            display.contains("iteration"),
158            "display should contain column name, got: {display}"
159        );
160        assert!(
161            display.contains("column type mismatch"),
162            "display should contain message, got: {display}"
163        );
164    }
165
166    #[test]
167    fn display_manifest_error_contains_type_and_message() {
168        let err = OutputError::ManifestError {
169            manifest_type: "simulation".to_string(),
170            message: "failed to serialize partition list".to_string(),
171        };
172        let display = err.to_string();
173        assert!(
174            display.contains("simulation"),
175            "display should contain manifest_type, got: {display}"
176        );
177        assert!(
178            display.contains("failed to serialize partition list"),
179            "display should contain message, got: {display}"
180        );
181    }
182
183    #[test]
184    fn output_error_is_send_sync_static() {
185        assert_send_sync_static::<OutputError>();
186    }
187
188    #[test]
189    fn output_error_satisfies_std_error_trait() {
190        let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "permission denied");
191        let variants: Vec<OutputError> = vec![
192            OutputError::io("output/data.parquet", io_err),
193            OutputError::serialization("thermals", "batch size mismatch"),
194            OutputError::SchemaError {
195                file: "costs.parquet".to_string(),
196                column: "cost".to_string(),
197                message: "expected Float64".to_string(),
198            },
199            OutputError::ManifestError {
200                manifest_type: "policy".to_string(),
201                message: "missing required field".to_string(),
202            },
203        ];
204        for err in &variants {
205            let _: &dyn std::error::Error = err;
206        }
207    }
208
209    #[test]
210    fn io_helper_constructs_correct_variant() {
211        let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "permission denied");
212        let err = OutputError::io("output/costs.parquet", io_err);
213        assert!(
214            matches!(err, OutputError::IoError { .. }),
215            "io() helper must construct IoError variant"
216        );
217        let display = err.to_string();
218        assert!(display.contains("output/costs.parquet"));
219        assert!(display.contains("permission denied"));
220    }
221
222    #[test]
223    fn serialization_helper_constructs_correct_variant() {
224        let err = OutputError::serialization("lines", "record batch has wrong schema");
225        assert!(
226            matches!(err, OutputError::SerializationError { .. }),
227            "serialization() helper must construct SerializationError variant"
228        );
229        let display = err.to_string();
230        assert!(display.contains("lines"));
231        assert!(display.contains("record batch has wrong schema"));
232    }
233
234    #[test]
235    fn all_variants_debug_non_empty() {
236        let io_err = io::Error::other("disk full");
237        let variants: Vec<OutputError> = vec![
238            OutputError::io("output/data.parquet", io_err),
239            OutputError::serialization("buses", "null value in non-nullable column"),
240            OutputError::SchemaError {
241                file: "flows.parquet".to_string(),
242                column: "flow_mw".to_string(),
243                message: "expected Float64, got Int32".to_string(),
244            },
245            OutputError::ManifestError {
246                manifest_type: "simulation".to_string(),
247                message: "partition list is empty".to_string(),
248            },
249        ];
250        for err in &variants {
251            assert!(
252                !format!("{err:?}").is_empty(),
253                "Debug output must not be empty for variant: {err}"
254            );
255        }
256    }
257}