pub mod dimensional;
pub mod productivity_resolution;
pub mod referential;
pub mod scalar_parameters;
pub mod schema;
pub mod semantic;
pub mod structural;
use std::path::PathBuf;
use crate::LoadError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorKind {
FileNotFound,
ParseError,
SchemaViolation,
InvalidReference,
DuplicateId,
InvalidValue,
CycleDetected,
DimensionMismatch,
BusinessRuleViolation,
WarmStartIncompatible,
ResumeIncompatible,
NotImplemented,
UnusedEntity,
ModelQuality,
SemanticAmbiguity,
}
impl ErrorKind {
#[must_use]
pub fn default_severity(self) -> Severity {
match self {
Self::UnusedEntity | Self::ModelQuality | Self::SemanticAmbiguity => Severity::Warning,
_ => Severity::Error,
}
}
}
#[derive(Debug, Clone)]
pub struct ValidationEntry {
pub severity: Severity,
pub kind: ErrorKind,
pub file: PathBuf,
pub entity: Option<String>,
pub message: String,
}
#[derive(Debug, Default)]
pub struct ValidationContext {
entries: Vec<ValidationEntry>,
}
impl ValidationContext {
#[must_use]
pub fn new() -> Self {
Self {
entries: Vec::new(),
}
}
pub fn add_error(
&mut self,
kind: ErrorKind,
file: impl Into<PathBuf>,
entity: Option<impl Into<String>>,
message: impl Into<String>,
) {
self.entries.push(ValidationEntry {
severity: Severity::Error,
kind,
file: file.into(),
entity: entity.map(Into::into),
message: message.into(),
});
}
pub fn add_warning(
&mut self,
kind: ErrorKind,
file: impl Into<PathBuf>,
entity: Option<impl Into<String>>,
message: impl Into<String>,
) {
self.entries.push(ValidationEntry {
severity: Severity::Warning,
kind,
file: file.into(),
entity: entity.map(Into::into),
message: message.into(),
});
}
#[must_use]
pub fn has_errors(&self) -> bool {
self.entries.iter().any(|e| e.severity == Severity::Error)
}
#[must_use]
pub fn error_count(&self) -> usize {
self.entries
.iter()
.filter(|e| e.severity == Severity::Error)
.count()
}
#[must_use]
pub fn errors(&self) -> Vec<&ValidationEntry> {
self.entries
.iter()
.filter(|e| e.severity == Severity::Error)
.collect()
}
#[must_use]
pub fn warnings(&self) -> Vec<&ValidationEntry> {
self.entries
.iter()
.filter(|e| e.severity == Severity::Warning)
.collect()
}
pub fn into_result(self) -> Result<(), LoadError> {
let error_messages: Vec<String> = self
.entries
.iter()
.filter(|e| e.severity == Severity::Error)
.map(|e| {
let file = e.file.display();
if let Some(entity) = &e.entity {
format!("[{:?}] {file} ({entity}): {}", e.kind, e.message)
} else {
format!("[{:?}] {file}: {}", e.kind, e.message)
}
})
.collect();
if error_messages.is_empty() {
return Ok(());
}
Err(LoadError::ConstraintError {
description: error_messages.join("\n"),
})
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_context_empty() {
let ctx = ValidationContext::new();
assert!(!ctx.has_errors(), "new context should have no errors");
assert!(
ctx.errors().is_empty(),
"new context should have empty errors list"
);
assert!(
ctx.warnings().is_empty(),
"new context should have empty warnings list"
);
assert!(
ctx.into_result().is_ok(),
"empty context should produce Ok result"
);
}
#[test]
fn test_context_errors_collected() {
let mut ctx = ValidationContext::new();
ctx.add_error(
ErrorKind::FileNotFound,
"system/hydros.json",
None::<&str>,
"file missing",
);
ctx.add_error(
ErrorKind::ParseError,
"stages.json",
None::<&str>,
"malformed JSON",
);
ctx.add_error(
ErrorKind::SchemaViolation,
"system/buses.json",
Some("bus_42"),
"missing field bus_id",
);
assert!(
ctx.has_errors(),
"context with 3 errors should report has_errors=true"
);
assert_eq!(
ctx.errors().len(),
3,
"errors() should return exactly 3 entries"
);
}
#[test]
fn test_context_warnings_not_errors() {
let mut ctx = ValidationContext::new();
ctx.add_warning(
ErrorKind::UnusedEntity,
"system/thermals.json",
Some("thermal_old"),
"max_generation=0 for all stages",
);
ctx.add_warning(
ErrorKind::ModelQuality,
"scenarios/inflow_seasonal_stats.parquet",
None::<&str>,
"residual bias detected",
);
assert!(
!ctx.has_errors(),
"context with only warnings should report has_errors=false"
);
assert_eq!(
ctx.warnings().len(),
2,
"warnings() should return exactly 2 entries"
);
assert!(
ctx.errors().is_empty(),
"errors() should be empty when only warnings exist"
);
}
#[test]
fn test_context_into_result_with_errors() {
let mut ctx = ValidationContext::new();
ctx.add_error(
ErrorKind::FileNotFound,
"system/hydros.json",
None::<&str>,
"required file is missing",
);
let result = ctx.into_result();
assert!(result.is_err(), "context with errors should produce Err");
let err = result.unwrap_err();
let display = err.to_string();
assert!(
display.contains("required file is missing"),
"error description should contain the original message, got: {display}"
);
}
#[test]
fn test_context_into_result_warnings_only_is_ok() {
let mut ctx = ValidationContext::new();
ctx.add_warning(
ErrorKind::UnusedEntity,
"system/thermals.json",
Some("T1"),
"inactive thermal",
);
assert!(
ctx.into_result().is_ok(),
"context with only warnings should produce Ok"
);
}
#[test]
fn test_context_into_result_multiple_errors_joined() {
let mut ctx = ValidationContext::new();
ctx.add_error(
ErrorKind::FileNotFound,
"system/hydros.json",
None::<&str>,
"file alpha missing",
);
ctx.add_error(
ErrorKind::FileNotFound,
"system/buses.json",
None::<&str>,
"file beta missing",
);
let result = ctx.into_result();
assert!(result.is_err());
let description = result.unwrap_err().to_string();
assert!(
description.contains("file alpha missing"),
"description should contain first error, got: {description}"
);
assert!(
description.contains("file beta missing"),
"description should contain second error, got: {description}"
);
}
#[test]
fn test_error_kind_default_severity() {
assert_eq!(ErrorKind::FileNotFound.default_severity(), Severity::Error);
assert_eq!(ErrorKind::ParseError.default_severity(), Severity::Error);
assert_eq!(
ErrorKind::UnusedEntity.default_severity(),
Severity::Warning
);
assert_eq!(
ErrorKind::ModelQuality.default_severity(),
Severity::Warning
);
}
}