Skip to main content

cobre_io/validation/
mod.rs

1//! Validation infrastructure for the cobre-io loading pipeline.
2//!
3//! This module provides the [`ValidationContext`] error/warning collector used by all five
4//! validation layers, along with the [`ErrorKind`] and [`Severity`] enums that categorise
5//! every diagnostic emitted during validation.
6//!
7//! ## Design
8//!
9//! Validation in Cobre collects **all** errors before failing rather than stopping on the
10//! first problem.  This lets users see and fix every issue in a single iteration.
11//!
12//! ```
13//! use cobre_io::validation::{ValidationContext, ErrorKind, Severity};
14//!
15//! let mut ctx = ValidationContext::new();
16//! ctx.add_error(
17//!     ErrorKind::FileNotFound,
18//!     "system/hydros.json",
19//!     None::<&str>,
20//!     "required file is missing",
21//! );
22//! assert!(ctx.has_errors());
23//! assert!(ctx.into_result().is_err());
24//! ```
25
26pub mod dimensional;
27pub mod productivity_resolution;
28pub mod referential;
29pub mod scalar_parameters;
30pub mod schema;
31pub mod semantic;
32pub mod structural;
33
34use std::path::PathBuf;
35
36use crate::LoadError;
37
38// ── Severity ─────────────────────────────────────────────────────────────────
39
40/// Diagnostic severity attached to every [`ValidationEntry`].
41///
42/// `Error` entries prevent execution; `Warning` entries are reported but do not
43/// block the run.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Severity {
46    /// A validation failure that prevents execution.
47    Error,
48    /// A non-fatal observation that is reported but does not block execution.
49    Warning,
50}
51
52// ── ErrorKind ────────────────────────────────────────────────────────────────
53
54/// Categorises the kind of validation problem found.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum ErrorKind {
57    /// Required file is missing from the case directory.
58    FileNotFound,
59    /// File exists but cannot be parsed (invalid JSON syntax, unreadable Parquet header).
60    ParseError,
61    /// File parses successfully but does not conform to its expected schema
62    /// (missing required field, wrong type, value out of valid range).
63    SchemaViolation,
64    /// A cross-entity foreign-key reference points to a non-existent entity.
65    InvalidReference,
66    /// Two entities in the same registry share the same ID.
67    DuplicateId,
68    /// A field value falls outside its valid range or violates a value constraint.
69    InvalidValue,
70    /// A directed graph (e.g., hydro cascade) contains a cycle.
71    CycleDetected,
72    /// A cross-file coverage check fails (e.g., missing inflow params for a hydro).
73    DimensionMismatch,
74    /// A domain-specific business rule is violated.
75    BusinessRuleViolation,
76    /// A warm-start policy is structurally incompatible with the current system.
77    WarmStartIncompatible,
78    /// A resume state is incompatible with the current run configuration.
79    ResumeIncompatible,
80    /// A feature that is used in the input files is not yet implemented.
81    NotImplemented,
82    /// An entity is defined but appears to be inactive (warning only).
83    UnusedEntity,
84    /// A statistical quality concern in the input model (warning only).
85    ModelQuality,
86    /// A valid construct whose semantics are ambiguous or stage-dependent
87    /// (warning only); surfaces the `thermal_generation` / `anticipated_decision`
88    /// stage-drift case.
89    SemanticAmbiguity,
90}
91
92impl ErrorKind {
93    /// Returns the default severity associated with this error kind.
94    #[must_use]
95    pub fn default_severity(self) -> Severity {
96        match self {
97            Self::UnusedEntity | Self::ModelQuality | Self::SemanticAmbiguity => Severity::Warning,
98            _ => Severity::Error,
99        }
100    }
101}
102
103// ── ValidationEntry ──────────────────────────────────────────────────────────
104
105/// A single diagnostic emitted during validation.
106///
107/// Each entry records the source file, the affected entity (if any), the kind of
108/// problem, and a human-readable message.
109#[derive(Debug, Clone)]
110pub struct ValidationEntry {
111    /// Severity of this diagnostic.
112    pub severity: Severity,
113    /// Categorised kind of the problem.
114    pub kind: ErrorKind,
115    /// Path to the file in which the problem was detected.
116    pub file: PathBuf,
117    /// Optional identifier of the entity involved (e.g., `"hydro_042"`).
118    pub entity: Option<String>,
119    /// Human-readable description of the problem.
120    pub message: String,
121}
122
123// ── ValidationContext ─────────────────────────────────────────────────────────
124
125/// Collects all validation diagnostics emitted during the loading pipeline.
126///
127/// Pass a `&mut ValidationContext` to each validation function.  After all layers
128/// have run, call [`into_result`] to convert the collected diagnostics into a
129/// `Result<(), LoadError>`.
130///
131/// [`into_result`]: ValidationContext::into_result
132///
133/// # Examples
134///
135/// ```
136/// use cobre_io::validation::{ValidationContext, ErrorKind};
137///
138/// let mut ctx = ValidationContext::new();
139/// assert!(!ctx.has_errors());
140/// assert!(ctx.into_result().is_ok());
141/// ```
142#[derive(Debug, Default)]
143pub struct ValidationContext {
144    entries: Vec<ValidationEntry>,
145}
146
147impl ValidationContext {
148    /// Creates an empty validation context with no diagnostics.
149    #[must_use]
150    pub fn new() -> Self {
151        Self {
152            entries: Vec::new(),
153        }
154    }
155
156    /// Adds an error diagnostic to the context.
157    pub fn add_error(
158        &mut self,
159        kind: ErrorKind,
160        file: impl Into<PathBuf>,
161        entity: Option<impl Into<String>>,
162        message: impl Into<String>,
163    ) {
164        self.entries.push(ValidationEntry {
165            severity: Severity::Error,
166            kind,
167            file: file.into(),
168            entity: entity.map(Into::into),
169            message: message.into(),
170        });
171    }
172
173    /// Adds a warning diagnostic to the context.
174    pub fn add_warning(
175        &mut self,
176        kind: ErrorKind,
177        file: impl Into<PathBuf>,
178        entity: Option<impl Into<String>>,
179        message: impl Into<String>,
180    ) {
181        self.entries.push(ValidationEntry {
182            severity: Severity::Warning,
183            kind,
184            file: file.into(),
185            entity: entity.map(Into::into),
186            message: message.into(),
187        });
188    }
189
190    /// Returns `true` if any error-severity diagnostics have been collected.
191    #[must_use]
192    pub fn has_errors(&self) -> bool {
193        self.entries.iter().any(|e| e.severity == Severity::Error)
194    }
195
196    /// Returns the number of error-severity diagnostics without allocating.
197    #[must_use]
198    pub fn error_count(&self) -> usize {
199        self.entries
200            .iter()
201            .filter(|e| e.severity == Severity::Error)
202            .count()
203    }
204
205    /// Returns all error-severity [`ValidationEntry`] items.
206    #[must_use]
207    pub fn errors(&self) -> Vec<&ValidationEntry> {
208        self.entries
209            .iter()
210            .filter(|e| e.severity == Severity::Error)
211            .collect()
212    }
213
214    /// Returns a slice of all warning-severity [`ValidationEntry`] items.
215    #[must_use]
216    pub fn warnings(&self) -> Vec<&ValidationEntry> {
217        self.entries
218            .iter()
219            .filter(|e| e.severity == Severity::Warning)
220            .collect()
221    }
222
223    /// Converts the collected diagnostics into a `Result`.
224    ///
225    /// Warnings are not surfaced by this method — inspect [`warnings()`] before
226    /// calling `into_result()`.
227    ///
228    /// [`warnings()`]: ValidationContext::warnings
229    ///
230    /// # Errors
231    ///
232    /// Returns [`LoadError::ConstraintError`] if any error-severity diagnostics
233    /// were collected.  The `description` field contains all error messages
234    /// joined by newlines, formatted as `[ErrorKind] file (entity): message`.
235    ///
236    /// # Examples
237    ///
238    /// ```
239    /// use cobre_io::validation::{ValidationContext, ErrorKind};
240    ///
241    /// let mut ctx = ValidationContext::new();
242    /// ctx.add_warning(ErrorKind::UnusedEntity, "system/thermals.json", Some("T1"), "inactive");
243    /// assert!(ctx.into_result().is_ok());
244    /// ```
245    pub fn into_result(self) -> Result<(), LoadError> {
246        let error_messages: Vec<String> = self
247            .entries
248            .iter()
249            .filter(|e| e.severity == Severity::Error)
250            .map(|e| {
251                let file = e.file.display();
252                if let Some(entity) = &e.entity {
253                    format!("[{:?}] {file} ({entity}): {}", e.kind, e.message)
254                } else {
255                    format!("[{:?}] {file}: {}", e.kind, e.message)
256                }
257            })
258            .collect();
259
260        if error_messages.is_empty() {
261            return Ok(());
262        }
263        Err(LoadError::ConstraintError {
264            description: error_messages.join("\n"),
265        })
266    }
267}
268
269// ── Tests ────────────────────────────────────────────────────────────────────
270
271#[cfg(test)]
272#[allow(clippy::unwrap_used)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn test_context_empty() {
278        let ctx = ValidationContext::new();
279        assert!(!ctx.has_errors(), "new context should have no errors");
280        assert!(
281            ctx.errors().is_empty(),
282            "new context should have empty errors list"
283        );
284        assert!(
285            ctx.warnings().is_empty(),
286            "new context should have empty warnings list"
287        );
288        assert!(
289            ctx.into_result().is_ok(),
290            "empty context should produce Ok result"
291        );
292    }
293
294    #[test]
295    fn test_context_errors_collected() {
296        let mut ctx = ValidationContext::new();
297        ctx.add_error(
298            ErrorKind::FileNotFound,
299            "system/hydros.json",
300            None::<&str>,
301            "file missing",
302        );
303        ctx.add_error(
304            ErrorKind::ParseError,
305            "stages.json",
306            None::<&str>,
307            "malformed JSON",
308        );
309        ctx.add_error(
310            ErrorKind::SchemaViolation,
311            "system/buses.json",
312            Some("bus_42"),
313            "missing field bus_id",
314        );
315        assert!(
316            ctx.has_errors(),
317            "context with 3 errors should report has_errors=true"
318        );
319        assert_eq!(
320            ctx.errors().len(),
321            3,
322            "errors() should return exactly 3 entries"
323        );
324    }
325
326    #[test]
327    fn test_context_warnings_not_errors() {
328        let mut ctx = ValidationContext::new();
329        ctx.add_warning(
330            ErrorKind::UnusedEntity,
331            "system/thermals.json",
332            Some("thermal_old"),
333            "max_generation=0 for all stages",
334        );
335        ctx.add_warning(
336            ErrorKind::ModelQuality,
337            "scenarios/inflow_seasonal_stats.parquet",
338            None::<&str>,
339            "residual bias detected",
340        );
341        assert!(
342            !ctx.has_errors(),
343            "context with only warnings should report has_errors=false"
344        );
345        assert_eq!(
346            ctx.warnings().len(),
347            2,
348            "warnings() should return exactly 2 entries"
349        );
350        assert!(
351            ctx.errors().is_empty(),
352            "errors() should be empty when only warnings exist"
353        );
354    }
355
356    #[test]
357    fn test_context_into_result_with_errors() {
358        let mut ctx = ValidationContext::new();
359        ctx.add_error(
360            ErrorKind::FileNotFound,
361            "system/hydros.json",
362            None::<&str>,
363            "required file is missing",
364        );
365        let result = ctx.into_result();
366        assert!(result.is_err(), "context with errors should produce Err");
367        let err = result.unwrap_err();
368        let display = err.to_string();
369        assert!(
370            display.contains("required file is missing"),
371            "error description should contain the original message, got: {display}"
372        );
373    }
374
375    #[test]
376    fn test_context_into_result_warnings_only_is_ok() {
377        let mut ctx = ValidationContext::new();
378        ctx.add_warning(
379            ErrorKind::UnusedEntity,
380            "system/thermals.json",
381            Some("T1"),
382            "inactive thermal",
383        );
384        assert!(
385            ctx.into_result().is_ok(),
386            "context with only warnings should produce Ok"
387        );
388    }
389
390    #[test]
391    fn test_context_into_result_multiple_errors_joined() {
392        let mut ctx = ValidationContext::new();
393        ctx.add_error(
394            ErrorKind::FileNotFound,
395            "system/hydros.json",
396            None::<&str>,
397            "file alpha missing",
398        );
399        ctx.add_error(
400            ErrorKind::FileNotFound,
401            "system/buses.json",
402            None::<&str>,
403            "file beta missing",
404        );
405        let result = ctx.into_result();
406        assert!(result.is_err());
407        let description = result.unwrap_err().to_string();
408        assert!(
409            description.contains("file alpha missing"),
410            "description should contain first error, got: {description}"
411        );
412        assert!(
413            description.contains("file beta missing"),
414            "description should contain second error, got: {description}"
415        );
416    }
417
418    #[test]
419    fn test_error_kind_default_severity() {
420        assert_eq!(ErrorKind::FileNotFound.default_severity(), Severity::Error);
421        assert_eq!(ErrorKind::ParseError.default_severity(), Severity::Error);
422        assert_eq!(
423            ErrorKind::UnusedEntity.default_severity(),
424            Severity::Warning
425        );
426        assert_eq!(
427            ErrorKind::ModelQuality.default_severity(),
428            Severity::Warning
429        );
430    }
431}