use serde::Deserialize;
use std::path::Path;
use crate::LoadError;
#[derive(Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub(crate) struct RawExchangeFactorsFile {
#[serde(rename = "$schema")]
_schema: Option<String>,
exchange_factors: Vec<RawExchangeFactorEntry>,
}
#[derive(Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
struct RawExchangeFactorEntry {
line_id: i32,
stage_id: i32,
block_factors: Vec<RawBlockExchangeFactor>,
}
#[derive(Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
struct RawBlockExchangeFactor {
block_id: i32,
direct_factor: f64,
reverse_factor: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct BlockExchangeFactor {
pub block_id: i32,
pub direct_factor: f64,
pub reverse_factor: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ExchangeFactorEntry {
pub line_id: i32,
pub stage_id: i32,
pub block_factors: Vec<BlockExchangeFactor>,
}
pub fn parse_exchange_factors(path: &Path) -> Result<Vec<ExchangeFactorEntry>, LoadError> {
let raw_text = std::fs::read_to_string(path).map_err(|e| LoadError::io(path, e))?;
let raw: RawExchangeFactorsFile =
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: &RawExchangeFactorsFile, path: &Path) -> Result<(), LoadError> {
for (i, entry) in raw.exchange_factors.iter().enumerate() {
for (j, bf) in entry.block_factors.iter().enumerate() {
validate_factor(
bf.direct_factor,
&format!("exchange_factors[{i}].block_factors[{j}].direct_factor"),
path,
)?;
validate_factor(
bf.reverse_factor,
&format!("exchange_factors[{i}].block_factors[{j}].reverse_factor"),
path,
)?;
}
}
Ok(())
}
fn validate_factor(value: f64, field: &str, path: &Path) -> Result<(), LoadError> {
if !value.is_finite() {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: field.to_string(),
message: format!("exchange factor must be finite, got {value}"),
});
}
if value <= 0.0 {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: field.to_string(),
message: format!("exchange factor must be > 0.0, got {value}"),
});
}
Ok(())
}
fn convert(raw: RawExchangeFactorsFile) -> Vec<ExchangeFactorEntry> {
let mut result: Vec<ExchangeFactorEntry> = raw
.exchange_factors
.into_iter()
.map(|entry| ExchangeFactorEntry {
line_id: entry.line_id,
stage_id: entry.stage_id,
block_factors: entry
.block_factors
.into_iter()
.map(|bf| BlockExchangeFactor {
block_id: bf.block_id,
direct_factor: bf.direct_factor,
reverse_factor: bf.reverse_factor,
})
.collect(),
})
.collect();
result.sort_by(|a, b| {
a.line_id
.cmp(&b.line_id)
.then_with(|| a.stage_id.cmp(&b.stage_id))
});
result
}
#[cfg(test)]
#[allow(
clippy::doc_markdown,
clippy::expect_used,
clippy::panic,
clippy::too_many_lines,
clippy::unwrap_used
)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn write_json(content: &str) -> NamedTempFile {
let mut f = NamedTempFile::new().expect("tempfile");
f.write_all(content.as_bytes()).expect("write");
f
}
const VALID_JSON: &str = r#"{
"exchange_factors": [
{
"line_id": 0,
"stage_id": 0,
"block_factors": [
{ "block_id": 0, "direct_factor": 0.9, "reverse_factor": 0.9 },
{ "block_id": 1, "direct_factor": 1.0, "reverse_factor": 0.8 },
{ "block_id": 2, "direct_factor": 0.75, "reverse_factor": 1.0 }
]
}
]
}"#;
#[test]
fn test_parse_valid_single_entry_three_block_factors() {
let f = write_json(VALID_JSON);
let result = parse_exchange_factors(f.path()).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line_id, 0);
assert_eq!(result[0].stage_id, 0);
assert_eq!(result[0].block_factors.len(), 3);
assert!((result[0].block_factors[0].direct_factor - 0.9).abs() < f64::EPSILON);
assert!((result[0].block_factors[0].reverse_factor - 0.9).abs() < f64::EPSILON);
assert!((result[0].block_factors[2].direct_factor - 0.75).abs() < f64::EPSILON);
}
#[test]
fn test_parse_negative_direct_factor_returns_schema_error() {
let json = r#"{
"exchange_factors": [
{
"line_id": 0,
"stage_id": 0,
"block_factors": [
{ "block_id": 0, "direct_factor": -0.5, "reverse_factor": 1.0 }
]
}
]
}"#;
let f = write_json(json);
let err = parse_exchange_factors(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("direct_factor"),
"field should contain 'direct_factor', got: {field}"
);
assert!(
message.contains("> 0.0"),
"message should mention > 0.0, got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_parse_zero_reverse_factor_returns_schema_error() {
let json = r#"{
"exchange_factors": [
{
"line_id": 0,
"stage_id": 0,
"block_factors": [
{ "block_id": 0, "direct_factor": 1.0, "reverse_factor": 0.0 }
]
}
]
}"#;
let f = write_json(json);
let err = parse_exchange_factors(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("reverse_factor"),
"field should contain 'reverse_factor', got: {field}"
);
assert!(
message.contains("> 0.0"),
"message should mention > 0.0, got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_parse_nan_factor_returns_schema_error() {
let err =
validate_factor(f64::NAN, "test_field", std::path::Path::new("test.json")).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(field.contains("test_field"));
assert!(message.contains("finite"));
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_parse_empty_array_returns_empty_vec() {
let json = r#"{ "exchange_factors": [] }"#;
let f = write_json(json);
let result = parse_exchange_factors(f.path()).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_parse_sorted_by_line_id_stage_id() {
let json = r#"{
"exchange_factors": [
{
"line_id": 1,
"stage_id": 3,
"block_factors": [
{ "block_id": 0, "direct_factor": 0.5, "reverse_factor": 0.5 }
]
},
{
"line_id": 0,
"stage_id": 5,
"block_factors": [
{ "block_id": 0, "direct_factor": 0.8, "reverse_factor": 0.8 }
]
},
{
"line_id": 0,
"stage_id": 2,
"block_factors": [
{ "block_id": 0, "direct_factor": 0.9, "reverse_factor": 0.9 }
]
}
]
}"#;
let f = write_json(json);
let result = parse_exchange_factors(f.path()).unwrap();
assert_eq!(result.len(), 3);
assert_eq!((result[0].line_id, result[0].stage_id), (0, 2));
assert_eq!((result[1].line_id, result[1].stage_id), (0, 5));
assert_eq!((result[2].line_id, result[2].stage_id), (1, 3));
}
#[test]
fn test_parse_entry_with_empty_block_factors() {
let json = r#"{
"exchange_factors": [
{
"line_id": 0,
"stage_id": 0,
"block_factors": []
}
]
}"#;
let f = write_json(json);
let result = parse_exchange_factors(f.path()).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].block_factors.is_empty());
}
}