pub mod executor;
use std::str::FromStr;
use std::time::Duration;
use serde::Deserialize;
pub fn parse_duration_str(s: &str) -> Result<Duration, String> {
let s = s.trim();
if s.is_empty() {
return Err("duration string is empty".to_string());
}
let mut remaining = s;
let mut total_secs: u64 = 0;
let mut parsed_any = false;
if let Some(m_pos) = remaining.find('m') {
let minutes_str = &remaining[..m_pos];
if minutes_str.is_empty() {
return Err(format!("invalid duration string: '{s}'"));
}
let minutes: u64 = minutes_str
.parse()
.map_err(|_| format!("invalid minutes in duration: '{s}'"))?;
total_secs += minutes * 60;
remaining = &remaining[m_pos + 1..];
parsed_any = true;
}
if let Some(s_pos) = remaining.find('s') {
let secs_str = &remaining[..s_pos];
if secs_str.is_empty() {
return Err(format!("invalid duration string: '{s}'"));
}
let secs: u64 = secs_str
.parse()
.map_err(|_| format!("invalid seconds in duration: '{s}'"))?;
total_secs += secs;
remaining = &remaining[s_pos + 1..];
parsed_any = true;
}
if !parsed_any || !remaining.is_empty() {
return Err(format!("invalid duration string: '{s}'"));
}
Ok(Duration::from_secs(total_secs))
}
fn deserialize_duration<'de, D>(deserializer: D) -> Result<Duration, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
parse_duration_str(&s).map_err(serde::de::Error::custom)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RampType {
#[default]
Linear,
Step,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Stage {
#[serde(deserialize_with = "deserialize_duration")]
pub duration: Duration,
pub target_vus: u32,
#[serde(default)]
pub ramp: RampType,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LoadCurve {
pub stages: Vec<Stage>,
}
impl LoadCurve {
pub fn total_duration(&self) -> Duration {
self.stages.iter().map(|s| s.duration).sum()
}
pub fn target_vus_at(&self, elapsed: Duration) -> u32 {
if self.stages.is_empty() {
return 0;
}
let mut stage_start = Duration::ZERO;
let mut prev_vus: u32 = 0;
for stage in &self.stages {
let stage_end = stage_start + stage.duration;
if elapsed < stage_end {
let progress = if stage.duration.is_zero() {
1.0
} else {
(elapsed - stage_start).as_secs_f64() / stage.duration.as_secs_f64()
};
return match stage.ramp {
RampType::Step => stage.target_vus,
RampType::Linear => {
let from = prev_vus as f64;
let to = stage.target_vus as f64;
(from + (to - from) * progress).round() as u32
}
};
}
prev_vus = stage.target_vus;
stage_start = stage_end;
}
0
}
}
pub const MAX_VUS: u32 = 10_000;
pub const MAX_STAGES: usize = 1_000;
impl LoadCurve {
pub fn validate(&self) -> Result<(), String> {
if self.stages.is_empty() {
return Err("load curve must have at least one stage".to_string());
}
if self.stages.len() > MAX_STAGES {
return Err(format!(
"load curve has {} stages, maximum is {MAX_STAGES}",
self.stages.len()
));
}
for (i, stage) in self.stages.iter().enumerate() {
if stage.target_vus > MAX_VUS {
return Err(format!(
"stage {i}: target_vus {} exceeds maximum {MAX_VUS}",
stage.target_vus
));
}
}
Ok(())
}
}
impl TryFrom<crate::config::ExecutionConfig> for LoadCurve {
type Error = String;
fn try_from(cfg: crate::config::ExecutionConfig) -> Result<Self, Self::Error> {
let stages = cfg
.stages
.ok_or("execution.stages is required for curve mode")?;
let curve = LoadCurve { stages };
curve.validate()?;
Ok(curve)
}
}
impl FromStr for LoadCurve {
type Err = serde_json::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
serde_json::from_str(s)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_curve(stages: Vec<(u64, u32, RampType)>) -> LoadCurve {
LoadCurve {
stages: stages
.into_iter()
.map(|(secs, vus, ramp)| Stage {
duration: Duration::from_secs(secs),
target_vus: vus,
ramp,
})
.collect(),
}
}
#[test]
fn target_vus_at_linear_interpolation_mid_stage() {
let curve = make_curve(vec![(100, 100, RampType::Linear)]);
let vus = curve.target_vus_at(Duration::from_secs(50));
assert_eq!(vus, 50);
}
#[test]
fn target_vus_at_step_jumps_immediately() {
let curve = make_curve(vec![(60, 80, RampType::Step)]);
let vus = curve.target_vus_at(Duration::from_secs(1));
assert_eq!(vus, 80);
let vus = curve.target_vus_at(Duration::from_secs(30));
assert_eq!(vus, 80);
}
#[test]
fn target_vus_at_zero_elapsed_returns_zero() {
let curve = make_curve(vec![(60, 100, RampType::Linear)]);
let vus = curve.target_vus_at(Duration::ZERO);
assert_eq!(vus, 0);
}
#[test]
fn target_vus_at_after_total_duration_returns_zero() {
let curve = make_curve(vec![(60, 100, RampType::Linear)]);
let vus = curve.target_vus_at(Duration::from_secs(60));
assert_eq!(vus, 0);
let vus = curve.target_vus_at(Duration::from_secs(120));
assert_eq!(vus, 0);
}
#[test]
fn total_duration_sums_all_stages() {
let curve = make_curve(vec![
(30, 10, RampType::Linear),
(60, 50, RampType::Linear),
(90, 0, RampType::Linear),
]);
assert_eq!(curve.total_duration(), Duration::from_secs(180));
}
#[test]
fn json_parsing_valid_curve() {
let json = r#"{
"stages": [
{ "duration": "30s", "target_vus": 10 },
{ "duration": "1m", "target_vus": 50, "ramp": "linear" },
{ "duration": "30s", "target_vus": 0, "ramp": "step" }
]
}"#;
let curve: LoadCurve = json.parse().expect("should parse");
assert_eq!(curve.stages.len(), 3);
assert_eq!(curve.stages[0].duration, Duration::from_secs(30));
assert_eq!(curve.stages[0].target_vus, 10);
assert_eq!(curve.stages[1].duration, Duration::from_secs(60));
assert_eq!(curve.stages[1].target_vus, 50);
assert_eq!(curve.stages[2].ramp, RampType::Step);
}
#[test]
fn json_parsing_missing_ramp_defaults_to_linear() {
let json = r#"{
"stages": [
{ "duration": "10s", "target_vus": 5 }
]
}"#;
let curve: LoadCurve = json.parse().expect("should parse");
assert_eq!(curve.stages[0].ramp, RampType::Linear);
}
#[test]
fn duration_string_parsing() {
assert_eq!(parse_duration_str("30s").unwrap(), Duration::from_secs(30));
assert_eq!(parse_duration_str("2m").unwrap(), Duration::from_secs(120));
assert_eq!(
parse_duration_str("1m30s").unwrap(),
Duration::from_secs(90)
);
}
#[test]
fn duration_string_parsing_invalid() {
assert!(parse_duration_str("").is_err());
assert!(parse_duration_str("abc").is_err());
assert!(parse_duration_str("1h").is_err());
}
#[test]
fn validate_rejects_empty_stages() {
let curve = LoadCurve { stages: vec![] };
assert!(curve.validate().is_err());
}
#[test]
fn validate_rejects_too_many_stages() {
let stages = (0..MAX_STAGES + 1)
.map(|_| Stage {
duration: Duration::from_secs(1),
target_vus: 1,
ramp: RampType::Linear,
})
.collect();
let curve = LoadCurve { stages };
assert!(curve.validate().is_err());
}
#[test]
fn validate_rejects_vus_exceeding_max() {
let curve = make_curve(vec![(10, MAX_VUS + 1, RampType::Linear)]);
assert!(curve.validate().is_err());
}
#[test]
fn validate_accepts_valid_curve() {
let curve = make_curve(vec![(10, 100, RampType::Linear)]);
assert!(curve.validate().is_ok());
}
#[test]
fn try_from_execution_config_valid_stages() {
let cfg = crate::config::ExecutionConfig {
stages: Some(vec![
Stage {
duration: Duration::from_secs(10),
target_vus: 5,
ramp: RampType::Linear,
},
Stage {
duration: Duration::from_secs(20),
target_vus: 10,
ramp: RampType::Step,
},
]),
request_count: None,
concurrency: None,
};
let curve = LoadCurve::try_from(cfg).expect("should succeed");
assert_eq!(curve.stages.len(), 2);
assert_eq!(curve.stages[0].target_vus, 5);
assert_eq!(curve.stages[1].target_vus, 10);
}
#[test]
fn try_from_execution_config_empty_stages_fails_validation() {
let cfg = crate::config::ExecutionConfig {
stages: Some(vec![]),
request_count: None,
concurrency: None,
};
let result = LoadCurve::try_from(cfg);
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(
msg.contains("at least one stage"),
"expected validation error, got: {msg}"
);
}
#[test]
fn try_from_execution_config_missing_stages_field_fails() {
let cfg = crate::config::ExecutionConfig {
stages: None,
request_count: Some(100),
concurrency: Some(10),
};
let result = LoadCurve::try_from(cfg);
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(
msg.contains("execution.stages is required"),
"expected missing stages error, got: {msg}"
);
}
#[test]
fn target_vus_at_multi_stage_linear() {
let curve = make_curve(vec![
(100, 100, RampType::Linear),
(100, 200, RampType::Linear),
]);
let vus = curve.target_vus_at(Duration::from_secs(150));
assert_eq!(vus, 150);
}
}