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}