use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InputRange {
pub name: String,
pub low: f64,
pub high: f64,
pub base: Option<f64>,
}
impl InputRange {
#[must_use]
pub fn new(name: &str, low: f64, high: f64) -> Self {
Self {
name: name.to_string(),
low,
high,
base: None,
}
}
#[must_use]
pub const fn with_base(mut self, base: f64) -> Self {
self.base = Some(base);
self
}
pub fn validate(&self) -> Result<(), String> {
if self.low >= self.high {
return Err(format!(
"Input '{}': low ({}) must be less than high ({})",
self.name, self.low, self.high
));
}
if let Some(base) = self.base {
if base < self.low || base > self.high {
return Err(format!(
"Input '{}': base ({}) must be between low ({}) and high ({})",
self.name, base, self.low, self.high
));
}
}
Ok(())
}
#[must_use]
pub fn base_value(&self) -> f64 {
self.base
.unwrap_or_else(|| f64::midpoint(self.low, self.high))
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TornadoConfig {
pub output: String,
#[serde(default)]
pub inputs: Vec<InputRange>,
#[serde(default = "default_steps")]
pub steps: usize,
}
const fn default_steps() -> usize {
2
}
impl TornadoConfig {
#[must_use]
pub fn new(output: &str) -> Self {
Self {
output: output.to_string(),
inputs: Vec::new(),
steps: 2,
}
}
#[must_use]
pub fn with_input(mut self, input: InputRange) -> Self {
self.inputs.push(input);
self
}
#[must_use]
pub const fn with_steps(mut self, steps: usize) -> Self {
self.steps = steps;
self
}
pub fn validate(&self) -> Result<(), String> {
if self.output.is_empty() {
return Err("Output variable must be specified".to_string());
}
if self.inputs.is_empty() {
return Err("At least one input variable must be specified".to_string());
}
if self.steps < 2 {
return Err("Steps must be at least 2".to_string());
}
for input in &self.inputs {
input.validate()?;
}
Ok(())
}
}
#[cfg(test)]
#[allow(clippy::float_cmp)]
mod config_tests {
use super::*;
#[test]
fn test_config_validation() {
let config = TornadoConfig::new("npv")
.with_input(InputRange::new("revenue_growth", 0.02, 0.08))
.with_input(InputRange::new("discount_rate", 0.08, 0.12));
assert!(config.validate().is_ok());
}
#[test]
fn test_empty_output_rejected() {
let config = TornadoConfig::new("").with_input(InputRange::new("x", 0.0, 1.0));
assert!(config.validate().is_err());
}
#[test]
fn test_no_inputs_rejected() {
let config = TornadoConfig::new("output");
assert!(config.validate().is_err());
}
#[test]
fn test_invalid_range_rejected() {
let config = TornadoConfig::new("output").with_input(InputRange::new("x", 1.0, 0.0));
assert!(config.validate().is_err());
}
#[test]
fn test_base_value() {
let input = InputRange::new("x", 0.0, 10.0);
assert_eq!(input.base_value(), 5.0);
let input_with_base = InputRange::new("x", 0.0, 10.0).with_base(3.0);
assert_eq!(input_with_base.base_value(), 3.0);
}
}