cobre_io/error.rs
1//! Error types for the `cobre-io` loading pipeline.
2//!
3//! [`LoadError`] is the primary error type returned by [`crate::load_case`] and every
4//! internal parsing function. Each variant carries enough context for the caller to
5//! produce a diagnostic message without re-reading the input files.
6
7use std::io::Error;
8use std::path::{Path, PathBuf};
9
10/// Errors that can occur during case loading.
11///
12/// Variants are ordered by the pipeline phase in which they typically occur:
13/// I/O read → parse → schema validation → cross-reference validation → semantic
14/// constraint validation → warm-start policy compatibility.
15///
16/// # Examples
17///
18/// ```
19/// use cobre_io::LoadError;
20/// use std::path::PathBuf;
21///
22/// let err = LoadError::SchemaError {
23/// path: PathBuf::from("system/hydros.json"),
24/// field: "bus_id".to_string(),
25/// message: "required field is missing".to_string(),
26/// };
27/// assert!(err.to_string().contains("bus_id"));
28/// ```
29#[derive(Debug, thiserror::Error)]
30pub enum LoadError {
31 /// Filesystem read failure (file not found, permission denied, I/O error).
32 #[error("I/O error reading {path}: {source}")]
33 IoError {
34 /// Path to the file that could not be read.
35 path: PathBuf,
36 /// Underlying I/O error.
37 source: Error,
38 },
39
40 /// JSON or Parquet parsing failure (malformed content, encoding error).
41 #[error("parse error in {path}: {message}")]
42 ParseError {
43 /// Path to the file that failed to parse.
44 path: PathBuf,
45 /// Human-readable description of the parse failure.
46 message: String,
47 },
48
49 /// Schema validation failure (missing required field, wrong type, value out of range).
50 #[error("schema error in {path}, field {field}: {message}")]
51 SchemaError {
52 /// Path to the file containing the invalid entry.
53 path: PathBuf,
54 /// Dot-separated field path within the JSON object (e.g., `"hydros[3].bus_id"`).
55 field: String,
56 /// Human-readable description of the schema violation.
57 message: String,
58 },
59
60 /// Cross-reference validation failure (dangling entity ID, broken foreign key).
61 #[error(
62 "cross-reference error: {source_entity} in {source_file} references \
63 non-existent {target_entity} in {target_collection}"
64 )]
65 CrossReferenceError {
66 /// Path to the file that contains the dangling reference.
67 source_file: PathBuf,
68 /// String identifier of the entity that holds the broken reference
69 /// (e.g., `"Hydro 'H1'"`).
70 source_entity: String,
71 /// Name of the collection that was expected to contain `target_entity`
72 /// (e.g., `"bus registry"`).
73 target_collection: String,
74 /// String identifier of the entity that could not be found
75 /// (e.g., `"BUS_99"`).
76 target_entity: String,
77 },
78
79 /// Semantic constraint violation (acyclic cascade, complete coverage, consistency).
80 #[error("constraint violation: {description}")]
81 ConstraintError {
82 /// Human-readable description of the violated constraint.
83 description: String,
84 },
85
86 /// Warm-start policy is structurally incompatible with the current system.
87 ///
88 /// See SS7.1 in `input-loading-pipeline.md` for the four compatibility checks.
89 #[error(
90 "policy incompatible: {check} mismatch — policy has {policy_value}, \
91 system has {system_value}"
92 )]
93 PolicyIncompatible {
94 /// Name of the failing compatibility check (e.g., `"hydro count"`).
95 check: String,
96 /// Value recorded in the policy file.
97 policy_value: String,
98 /// Value present in the current system.
99 system_value: String,
100 },
101}
102
103impl LoadError {
104 /// Construct an [`LoadError::IoError`] wrapping an [`std::io::Error`] with path context.
105 ///
106 /// Do **not** implement `From<std::io::Error>` instead — that conversion loses
107 /// the path context required for diagnostic messages.
108 ///
109 /// # Examples
110 ///
111 /// ```
112 /// use cobre_io::LoadError;
113 /// use std::io;
114 ///
115 /// let io_err = io::Error::new(io::ErrorKind::NotFound, "no such file");
116 /// let err = LoadError::io("system/hydros.json", io_err);
117 /// assert!(err.to_string().contains("system/hydros.json"));
118 /// ```
119 pub fn io(path: impl AsRef<Path>, source: Error) -> Self {
120 Self::IoError {
121 path: path.as_ref().to_path_buf(),
122 source,
123 }
124 }
125
126 /// Construct a [`LoadError::ParseError`] with path context and a message.
127 ///
128 /// # Examples
129 ///
130 /// ```
131 /// use cobre_io::LoadError;
132 ///
133 /// let err = LoadError::parse("stages.json", "unexpected end of input");
134 /// assert!(err.to_string().contains("stages.json"));
135 /// assert!(err.to_string().contains("unexpected end of input"));
136 /// ```
137 pub fn parse(path: impl AsRef<Path>, message: impl Into<String>) -> Self {
138 Self::ParseError {
139 path: path.as_ref().to_path_buf(),
140 message: message.into(),
141 }
142 }
143}
144
145#[cfg(test)]
146#[allow(clippy::unwrap_used)]
147mod tests {
148 use super::*;
149 use std::io;
150
151 #[test]
152 fn test_load_error_io_display() {
153 let io_err = io::Error::new(io::ErrorKind::NotFound, "no such file or directory");
154 let err = LoadError::io("system/hydros.json", io_err);
155 let display = err.to_string();
156 assert!(
157 display.contains("system/hydros.json"),
158 "display should contain path, got: {display}"
159 );
160 assert!(
161 display.contains("no such file or directory"),
162 "display should contain source message, got: {display}"
163 );
164 }
165
166 #[test]
167 fn test_load_error_parse_display() {
168 let err = LoadError::parse("stages.json", "unexpected end of input");
169 let display = err.to_string();
170 assert!(
171 display.contains("stages.json"),
172 "display should contain path, got: {display}"
173 );
174 assert!(
175 display.contains("unexpected end of input"),
176 "display should contain message, got: {display}"
177 );
178 }
179
180 #[test]
181 fn test_load_error_schema_display() {
182 let err = LoadError::SchemaError {
183 path: PathBuf::from("system/hydros.json"),
184 field: "bus_id".to_string(),
185 message: "required field is missing".to_string(),
186 };
187 let display = err.to_string();
188 assert!(
189 display.contains("system/hydros.json"),
190 "display should contain path, got: {display}"
191 );
192 assert!(
193 display.contains("bus_id"),
194 "display should contain field name, got: {display}"
195 );
196 assert!(
197 display.contains("required field is missing"),
198 "display should contain message, got: {display}"
199 );
200 }
201
202 #[test]
203 fn test_load_error_cross_reference_display() {
204 let err = LoadError::CrossReferenceError {
205 source_file: PathBuf::from("system/hydros.json"),
206 source_entity: "Hydro 'H1'".to_string(),
207 target_collection: "bus registry".to_string(),
208 target_entity: "BUS_99".to_string(),
209 };
210 let display = err.to_string();
211 assert!(
212 display.contains("Hydro 'H1'"),
213 "display should contain source_entity, got: {display}"
214 );
215 assert!(
216 display.contains("system/hydros.json"),
217 "display should contain source_file, got: {display}"
218 );
219 assert!(
220 display.contains("BUS_99"),
221 "display should contain target_entity, got: {display}"
222 );
223 assert!(
224 display.contains("bus registry"),
225 "display should contain target_collection, got: {display}"
226 );
227 }
228
229 #[test]
230 fn test_load_error_is_std_error() {
231 let err = LoadError::ConstraintError {
232 description: "hydro cascade contains a cycle".to_string(),
233 };
234 let dyn_err: &dyn std::error::Error = &err;
235 assert!(dyn_err.source().is_none());
236 assert!(err.to_string().contains("hydro cascade contains a cycle"));
237 }
238
239 #[test]
240 fn test_load_error_io_helper() {
241 let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "permission denied");
242 let err = LoadError::io("config.json", io_err);
243 assert!(matches!(err, LoadError::IoError { .. }));
244 let display = err.to_string();
245 assert!(display.contains("config.json"));
246 assert!(display.contains("permission denied"));
247 }
248}