Skip to main content

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}