use cobre_core::{EntityId, HydroStorage, InitialConditions};
use serde::Deserialize;
use std::collections::HashSet;
use std::path::Path;
use crate::LoadError;
#[derive(Deserialize)]
struct RawInitialConditions {
#[serde(rename = "$schema")]
_schema: Option<String>,
storage: Vec<RawHydroStorage>,
filling_storage: Vec<RawHydroStorage>,
}
#[derive(Deserialize)]
struct RawHydroStorage {
hydro_id: i32,
value_hm3: f64,
}
pub fn parse_initial_conditions(path: &Path) -> Result<InitialConditions, LoadError> {
let raw_text = std::fs::read_to_string(path).map_err(|e| LoadError::io(path, e))?;
let raw: RawInitialConditions =
serde_json::from_str(&raw_text).map_err(|e| LoadError::parse(path, e.to_string()))?;
validate_raw(&raw, path)?;
Ok(convert(raw))
}
fn validate_raw(raw: &RawInitialConditions, path: &Path) -> Result<(), LoadError> {
validate_non_negative(&raw.storage, "storage", path)?;
validate_non_negative(&raw.filling_storage, "filling_storage", path)?;
validate_no_duplicates(&raw.storage, "storage", path)?;
validate_no_duplicates(&raw.filling_storage, "filling_storage", path)?;
validate_mutual_exclusion(raw, path)?;
Ok(())
}
fn validate_non_negative(
entries: &[RawHydroStorage],
array_name: &str,
path: &Path,
) -> Result<(), LoadError> {
for (i, entry) in entries.iter().enumerate() {
if entry.value_hm3 < 0.0 {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("{array_name}[{i}].value_hm3"),
message: format!("value_hm3 must be >= 0.0, got {}", entry.value_hm3),
});
}
}
Ok(())
}
fn validate_no_duplicates(
entries: &[RawHydroStorage],
array_name: &str,
path: &Path,
) -> Result<(), LoadError> {
let mut seen: HashSet<i32> = HashSet::new();
for (i, entry) in entries.iter().enumerate() {
if !seen.insert(entry.hydro_id) {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("{array_name}[{i}].hydro_id"),
message: format!("duplicate hydro_id {} in {array_name}", entry.hydro_id),
});
}
}
Ok(())
}
fn validate_mutual_exclusion(raw: &RawInitialConditions, path: &Path) -> Result<(), LoadError> {
let storage_ids: HashSet<i32> = raw.storage.iter().map(|e| e.hydro_id).collect();
for (i, entry) in raw.filling_storage.iter().enumerate() {
if storage_ids.contains(&entry.hydro_id) {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("filling_storage[{i}].hydro_id"),
message: format!(
"hydro_id {} appears in both storage and filling_storage; \
a hydro must appear in exactly one of the two arrays",
entry.hydro_id
),
});
}
}
Ok(())
}
fn convert(raw: RawInitialConditions) -> InitialConditions {
let mut storage: Vec<HydroStorage> = raw
.storage
.into_iter()
.map(|e| HydroStorage {
hydro_id: EntityId(e.hydro_id),
value_hm3: e.value_hm3,
})
.collect();
storage.sort_by_key(|e| e.hydro_id.0);
let mut filling_storage: Vec<HydroStorage> = raw
.filling_storage
.into_iter()
.map(|e| HydroStorage {
hydro_id: EntityId(e.hydro_id),
value_hm3: e.value_hm3,
})
.collect();
filling_storage.sort_by_key(|e| e.hydro_id.0);
InitialConditions {
storage,
filling_storage,
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic, clippy::too_many_lines)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn write_json(content: &str) -> NamedTempFile {
let mut f = NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f
}
const VALID_JSON: &str = r#"{
"$schema": "https://cobre.dev/schemas/v2/initial_conditions.schema.json",
"storage": [
{ "hydro_id": 0, "value_hm3": 15000.0 },
{ "hydro_id": 1, "value_hm3": 8500.0 }
],
"filling_storage": [
{ "hydro_id": 10, "value_hm3": 200.0 }
]
}"#;
#[test]
fn test_parse_valid_initial_conditions() {
let f = write_json(VALID_JSON);
let ic = parse_initial_conditions(f.path()).unwrap();
assert_eq!(ic.storage.len(), 2);
assert_eq!(ic.filling_storage.len(), 1);
assert_eq!(ic.storage[0].hydro_id, EntityId(0));
assert!(
(ic.storage[0].value_hm3 - 15_000.0).abs() < f64::EPSILON,
"expected 15000.0, got {}",
ic.storage[0].value_hm3
);
assert_eq!(ic.storage[1].hydro_id, EntityId(1));
assert!(
(ic.storage[1].value_hm3 - 8_500.0).abs() < f64::EPSILON,
"expected 8500.0, got {}",
ic.storage[1].value_hm3
);
assert_eq!(ic.filling_storage[0].hydro_id, EntityId(10));
assert!(
(ic.filling_storage[0].value_hm3 - 200.0).abs() < f64::EPSILON,
"expected 200.0, got {}",
ic.filling_storage[0].value_hm3
);
}
#[test]
fn test_parse_empty_arrays() {
let json = r#"{ "storage": [], "filling_storage": [] }"#;
let f = write_json(json);
let ic = parse_initial_conditions(f.path()).unwrap();
assert!(ic.storage.is_empty());
assert!(ic.filling_storage.is_empty());
}
#[test]
fn test_negative_storage_value() {
let json = r#"{
"storage": [
{ "hydro_id": 0, "value_hm3": -1.0 }
],
"filling_storage": []
}"#;
let f = write_json(json);
let err = parse_initial_conditions(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("value_hm3"),
"field should contain 'value_hm3', got: {field}"
);
assert!(
message.contains("value_hm3"),
"message should mention value_hm3, got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_negative_filling_storage_value() {
let json = r#"{
"storage": [],
"filling_storage": [
{ "hydro_id": 10, "value_hm3": -100.0 }
]
}"#;
let f = write_json(json);
let err = parse_initial_conditions(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("value_hm3"),
"field should contain 'value_hm3', got: {field}"
);
assert!(
message.contains("value_hm3"),
"message should mention value_hm3, got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_duplicate_hydro_id_in_storage() {
let json = r#"{
"storage": [
{ "hydro_id": 5, "value_hm3": 1000.0 },
{ "hydro_id": 5, "value_hm3": 2000.0 }
],
"filling_storage": []
}"#;
let f = write_json(json);
let err = parse_initial_conditions(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("storage"),
"field should mention 'storage', got: {field}"
);
assert!(
message.contains("duplicate"),
"message should mention 'duplicate', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_duplicate_hydro_id_in_filling_storage() {
let json = r#"{
"storage": [],
"filling_storage": [
{ "hydro_id": 10, "value_hm3": 100.0 },
{ "hydro_id": 10, "value_hm3": 200.0 }
]
}"#;
let f = write_json(json);
let err = parse_initial_conditions(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("filling_storage"),
"field should mention 'filling_storage', got: {field}"
);
assert!(
message.contains("duplicate"),
"message should mention 'duplicate', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_hydro_id_in_both_lists() {
let json = r#"{
"storage": [
{ "hydro_id": 5, "value_hm3": 1000.0 }
],
"filling_storage": [
{ "hydro_id": 5, "value_hm3": 100.0 }
]
}"#;
let f = write_json(json);
let err = parse_initial_conditions(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("filling_storage"),
"field should mention 'filling_storage', got: {field}"
);
assert!(
message.contains("storage") && message.contains("filling_storage"),
"message should mention both arrays for mutual exclusion, got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_file_not_found() {
let path = Path::new("/nonexistent/initial_conditions.json");
let err = parse_initial_conditions(path).unwrap_err();
match &err {
LoadError::IoError { path: p, .. } => {
assert_eq!(p, path);
}
other => panic!("expected IoError, got: {other:?}"),
}
}
#[test]
fn test_zero_storage_value_is_valid() {
let json = r#"{
"storage": [
{ "hydro_id": 0, "value_hm3": 0.0 }
],
"filling_storage": []
}"#;
let f = write_json(json);
let result = parse_initial_conditions(f.path());
assert!(
result.is_ok(),
"0.0 is non-negative and must be accepted, got: {result:?}"
);
}
#[test]
fn test_filling_storage_below_dead_volume_is_valid() {
let json = r#"{
"storage": [],
"filling_storage": [
{ "hydro_id": 10, "value_hm3": 1.0 }
]
}"#;
let f = write_json(json);
let result = parse_initial_conditions(f.path());
assert!(
result.is_ok(),
"filling storage values below dead volume are valid at this layer, got: {result:?}"
);
}
#[test]
fn test_declaration_order_invariance() {
let json_ordered = r#"{
"storage": [
{ "hydro_id": 0, "value_hm3": 1000.0 },
{ "hydro_id": 1, "value_hm3": 2000.0 }
],
"filling_storage": []
}"#;
let json_reversed = r#"{
"storage": [
{ "hydro_id": 1, "value_hm3": 2000.0 },
{ "hydro_id": 0, "value_hm3": 1000.0 }
],
"filling_storage": []
}"#;
let f1 = write_json(json_ordered);
let f2 = write_json(json_reversed);
let ic1 = parse_initial_conditions(f1.path()).unwrap();
let ic2 = parse_initial_conditions(f2.path()).unwrap();
assert_eq!(
ic1, ic2,
"results must be identical regardless of input ordering"
);
assert_eq!(ic1.storage[0].hydro_id, EntityId(0));
assert_eq!(ic1.storage[1].hydro_id, EntityId(1));
}
#[test]
fn test_invalid_json_syntax() {
let f = write_json(r#"{"storage": [not valid json}}"#);
let err = parse_initial_conditions(f.path()).unwrap_err();
assert!(
matches!(err, LoadError::ParseError { .. }),
"expected ParseError for invalid JSON, got: {err:?}"
);
}
#[test]
fn test_missing_required_field() {
let json = r#"{ "filling_storage": [] }"#;
let f = write_json(json);
let err = parse_initial_conditions(f.path()).unwrap_err();
assert!(
matches!(err, LoadError::ParseError { .. }),
"expected ParseError for missing storage field, got: {err:?}"
);
}
}