use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MomusConfig {
#[serde(default)]
pub global: GlobalConfig,
#[serde(default)]
pub run: RunConfig,
#[serde(default)]
pub bench: BenchConfig,
#[serde(default)]
pub fuzz: FuzzConfig,
#[serde(default)]
pub chaos: ChaosConfig,
#[serde(default)]
pub contract: ContractConfig,
#[serde(default)]
pub guard: GuardConfig,
#[serde(default)]
pub diff: DiffConfig,
#[serde(default)]
pub plan: PlanConfig,
}
impl MomusConfig {
pub fn load(path: &str) -> anyhow::Result<Self> {
let content = std::fs::read_to_string(path)?;
let config: MomusConfig = toml::from_str(&content)?;
Ok(config)
}
pub fn load_optional(path: &str) -> Self {
match std::fs::read_to_string(path) {
Ok(content) => match toml::from_str(&content) {
Ok(config) => config,
Err(e) => {
tracing::warn!(
"Config file '{}' has invalid TOML: {}. Using defaults.",
path,
e
);
Self::default()
}
},
Err(_) => Self::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GlobalConfig {
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub headers: HashMap<String, String>,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
}
impl Default for GlobalConfig {
fn default() -> Self {
Self {
base_url: None,
headers: HashMap::new(),
timeout_secs: default_timeout(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunConfig {
#[serde(default)]
pub base_url: Option<String>,
#[serde(default = "default_output")]
pub output: PathBuf,
#[serde(default)]
pub headers: HashMap<String, String>,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
}
fn default_output() -> PathBuf {
PathBuf::from("./output")
}
fn default_timeout() -> u64 {
30
}
impl Default for RunConfig {
fn default() -> Self {
Self {
base_url: None,
output: default_output(),
headers: HashMap::new(),
timeout_secs: default_timeout(),
}
}
}
impl RunConfig {
pub fn merge(&mut self, base_url: Option<String>, output: Option<PathBuf>) {
if let Some(url) = base_url {
self.base_url = Some(url);
}
if let Some(out) = output {
self.output = out;
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum BenchMode {
Steady {
concurrency: usize,
duration_secs: u64,
},
MaxThroughput {
min_concurrency: usize,
max_concurrency: usize,
step: usize,
step_duration_secs: u64,
max_error_rate: f64,
max_p99_ms: u64,
},
Soak {
concurrency: usize,
duration_secs: u64,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BenchConfig {
pub mode: BenchMode,
#[serde(default)]
pub warmup_requests: usize,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
#[serde(default)]
pub base_url: Option<String>,
#[serde(default = "default_output")]
pub output: PathBuf,
}
impl Default for BenchConfig {
fn default() -> Self {
Self {
mode: BenchMode::Steady {
concurrency: 10,
duration_secs: 30,
},
warmup_requests: 0,
timeout_secs: default_timeout(),
base_url: None,
output: default_output(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FuzzConfig {
#[serde(default = "default_iterations")]
pub iterations: usize,
#[serde(default)]
pub mutators: Vec<String>,
#[serde(default)]
pub base_url: Option<String>,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
#[serde(default = "default_output")]
pub output: PathBuf,
}
fn default_iterations() -> usize {
1000
}
impl Default for FuzzConfig {
fn default() -> Self {
Self {
iterations: default_iterations(),
mutators: vec![],
base_url: None,
timeout_secs: default_timeout(),
output: default_output(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ChaosExperiment {
NetworkLatency {
endpoint: String,
delay_ms: u64,
duration_secs: u64,
},
ConnectionReset {
endpoint: String,
reset_pct: u8,
duration_secs: u64,
},
PacketLoss {
endpoint: String,
drop_pct: u8,
duration_secs: u64,
},
ServiceError {
endpoint: String,
status: u16,
duration_secs: u64,
},
ServiceDown {
endpoint: String,
duration_secs: u64,
},
CpuPressure {
cores: usize,
duration_secs: u64,
},
MemoryPressure {
mb: usize,
duration_secs: u64,
},
ClockSkew {
offset_secs: i64,
duration_secs: u64,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChaosConfig {
#[serde(default)]
pub experiments: Vec<ChaosExperiment>,
#[serde(default)]
pub base_url: Option<String>,
#[serde(default = "default_interval")]
pub interval_secs: u64,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
#[serde(default = "default_output")]
pub output: PathBuf,
}
fn default_interval() -> u64 {
5
}
impl Default for ChaosConfig {
fn default() -> Self {
Self {
experiments: vec![],
base_url: None,
interval_secs: default_interval(),
timeout_secs: default_timeout(),
output: default_output(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContractConfig {
#[serde(default)]
pub spec_path: String,
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub strict: bool,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
#[serde(default = "default_output")]
pub output: PathBuf,
}
impl Default for ContractConfig {
fn default() -> Self {
Self {
spec_path: String::new(),
base_url: None,
strict: false,
timeout_secs: default_timeout(),
output: default_output(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GuardConfig {
#[serde(default)]
pub base_url: Option<String>,
#[serde(default = "default_true")]
pub check_headers: bool,
#[serde(default = "default_true")]
pub check_cors: bool,
#[serde(default = "default_true")]
pub check_leaks: bool,
#[serde(default = "default_true")]
pub check_exposed: bool,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
#[serde(default = "default_output")]
pub output: PathBuf,
}
fn default_true() -> bool {
true
}
impl Default for GuardConfig {
fn default() -> Self {
Self {
base_url: None,
check_headers: true,
check_cors: true,
check_leaks: true,
check_exposed: true,
timeout_secs: default_timeout(),
output: default_output(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffConfig {
#[serde(default)]
pub baseline_url: String,
#[serde(default)]
pub target_url: String,
#[serde(default = "default_true")]
pub diff_headers: bool,
#[serde(default = "default_true")]
pub diff_bodies: bool,
#[serde(default = "default_true")]
pub diff_status: bool,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
#[serde(default = "default_output")]
pub output: PathBuf,
}
impl Default for DiffConfig {
fn default() -> Self {
Self {
baseline_url: String::new(),
target_url: String::new(),
diff_headers: true,
diff_bodies: true,
diff_status: true,
timeout_secs: default_timeout(),
output: default_output(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanConfig {
#[serde(default = "default_output")]
pub output: PathBuf,
}
impl Default for PlanConfig {
fn default() -> Self {
Self {
output: default_output(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_run_config_toml() {
let toml = r#"
base_url = "http://localhost:8080"
output = "./test-output"
timeout_secs = 60
[headers]
Authorization = "Bearer test-token"
"#;
let config: RunConfig = toml::from_str(toml).unwrap();
assert_eq!(config.base_url, Some("http://localhost:8080".to_string()));
assert_eq!(config.output, PathBuf::from("./test-output"));
assert_eq!(config.timeout_secs, 60);
assert_eq!(
config.headers.get("Authorization").unwrap(),
"Bearer test-token"
);
}
#[test]
fn parse_run_config_defaults() {
let toml = r#"
base_url = "http://localhost:8080"
"#;
let config: RunConfig = toml::from_str(toml).unwrap();
assert_eq!(config.base_url, Some("http://localhost:8080".to_string()));
assert_eq!(config.output, PathBuf::from("./output"));
assert_eq!(config.timeout_secs, 30);
assert!(config.headers.is_empty());
}
#[test]
fn merge_overrides() {
let mut config = RunConfig {
base_url: Some("http://original".to_string()),
output: PathBuf::from("./original"),
headers: HashMap::new(),
timeout_secs: 30,
};
config.merge(
Some("http://override".to_string()),
Some(PathBuf::from("./override")),
);
assert_eq!(config.base_url, Some("http://override".to_string()));
assert_eq!(config.output, PathBuf::from("./override"));
}
#[test]
fn merge_partial() {
let mut config = RunConfig {
base_url: Some("http://original".to_string()),
output: PathBuf::from("./original"),
headers: HashMap::new(),
timeout_secs: 30,
};
config.merge(None, Some(PathBuf::from("./override")));
assert_eq!(config.base_url, Some("http://original".to_string()));
assert_eq!(config.output, PathBuf::from("./override"));
}
#[test]
fn parse_momus_config_empty() {
let toml = "";
let config: MomusConfig = toml::from_str(toml).unwrap();
assert!(config.global.base_url.is_none());
assert!(config.run.base_url.is_none());
assert!(config.bench.base_url.is_none());
assert!(config.fuzz.base_url.is_none());
assert!(config.chaos.base_url.is_none());
assert!(config.contract.base_url.is_none());
assert!(config.guard.base_url.is_none());
assert!(config.diff.baseline_url.is_empty());
}
#[test]
fn parse_momus_config_full() {
let toml = r#"
[global]
base_url = "http://global:8080"
timeout_secs = 60
[run]
output = "./run-output"
[bench]
warmup_requests = 100
mode = { type = "Steady", concurrency = 20, duration_secs = 60 }
[fuzz]
iterations = 5000
[chaos]
interval_secs = 10
[contract]
spec_path = "./api.yaml"
strict = true
[guard]
check_headers = false
[diff]
baseline_url = "https://prod.example.com"
target_url = "https://staging.example.com"
"#;
let config: MomusConfig = toml::from_str(toml).unwrap();
assert_eq!(
config.global.base_url,
Some("http://global:8080".to_string())
);
assert_eq!(config.global.timeout_secs, 60);
assert_eq!(config.run.output, PathBuf::from("./run-output"));
assert_eq!(config.bench.warmup_requests, 100);
assert_eq!(config.fuzz.iterations, 5000);
assert_eq!(config.chaos.interval_secs, 10);
assert_eq!(config.contract.spec_path, "./api.yaml");
assert!(config.contract.strict);
assert!(!config.guard.check_headers);
assert_eq!(config.diff.baseline_url, "https://prod.example.com");
assert_eq!(config.diff.target_url, "https://staging.example.com");
}
#[test]
fn parse_momus_config_global_fallback() {
let toml = r#"
[global]
base_url = "http://global:8080"
"#;
let config: MomusConfig = toml::from_str(toml).unwrap();
assert_eq!(
config.global.base_url,
Some("http://global:8080".to_string())
);
assert_eq!(config.run.output, PathBuf::from("./output"));
assert_eq!(config.bench.warmup_requests, 0);
assert_eq!(config.fuzz.iterations, 1000);
assert_eq!(config.chaos.interval_secs, 5);
assert!(!config.contract.strict);
assert!(config.guard.check_headers);
assert!(config.diff.baseline_url.is_empty());
}
}