1use std::io::Error;
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, thiserror::Error)]
27pub enum OutputError {
28 #[error("I/O error accessing {path}: {source}")]
31 IoError {
32 path: PathBuf,
34 source: Error,
36 },
37
38 #[error("serialization error for entity {entity}: {message}")]
40 SerializationError {
41 entity: String,
43 message: String,
45 },
46
47 #[error("schema error in {file}, column {column}: {message}")]
49 SchemaError {
50 file: String,
52 column: String,
54 message: String,
56 },
57
58 #[error("manifest error for {manifest_type}: {message}")]
60 ManifestError {
61 manifest_type: String,
63 message: String,
65 },
66}
67
68impl OutputError {
69 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 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}