1use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::PathBuf;
9
10#[derive(Debug, Clone, Serialize, Deserialize, Default)]
19pub struct MomusConfig {
20 #[serde(default)]
22 pub global: GlobalConfig,
23 #[serde(default)]
25 pub run: RunConfig,
26 #[serde(default)]
28 pub bench: BenchConfig,
29 #[serde(default)]
31 pub fuzz: FuzzConfig,
32 #[serde(default)]
34 pub chaos: ChaosConfig,
35 #[serde(default)]
37 pub contract: ContractConfig,
38 #[serde(default)]
40 pub guard: GuardConfig,
41 #[serde(default)]
43 pub diff: DiffConfig,
44 #[serde(default)]
46 pub plan: PlanConfig,
47}
48
49impl MomusConfig {
50 pub fn load(path: &str) -> anyhow::Result<Self> {
54 let content = std::fs::read_to_string(path)?;
55 let config: MomusConfig = toml::from_str(&content)?;
56 Ok(config)
57 }
58
59 pub fn load_optional(path: &str) -> Self {
62 match std::fs::read_to_string(path) {
63 Ok(content) => match toml::from_str(&content) {
64 Ok(config) => config,
65 Err(e) => {
66 tracing::warn!(
67 "Config file '{}' has invalid TOML: {}. Using defaults.",
68 path,
69 e
70 );
71 Self::default()
72 }
73 },
74 Err(_) => Self::default(),
75 }
76 }
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct GlobalConfig {
83 #[serde(default)]
85 pub base_url: Option<String>,
86 #[serde(default)]
88 pub headers: HashMap<String, String>,
89 #[serde(default = "default_timeout")]
91 pub timeout_secs: u64,
92}
93
94impl Default for GlobalConfig {
95 fn default() -> Self {
96 Self {
97 base_url: None,
98 headers: HashMap::new(),
99 timeout_secs: default_timeout(),
100 }
101 }
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct RunConfig {
114 #[serde(default)]
116 pub base_url: Option<String>,
117
118 #[serde(default = "default_output")]
120 pub output: PathBuf,
121
122 #[serde(default)]
124 pub headers: HashMap<String, String>,
125
126 #[serde(default = "default_timeout")]
128 pub timeout_secs: u64,
129}
130
131fn default_output() -> PathBuf {
132 PathBuf::from("./output")
133}
134
135fn default_timeout() -> u64 {
136 30
137}
138
139impl Default for RunConfig {
140 fn default() -> Self {
141 Self {
142 base_url: None,
143 output: default_output(),
144 headers: HashMap::new(),
145 timeout_secs: default_timeout(),
146 }
147 }
148}
149
150impl RunConfig {
151 pub fn merge(&mut self, base_url: Option<String>, output: Option<PathBuf>) {
155 if let Some(url) = base_url {
156 self.base_url = Some(url);
157 }
158 if let Some(out) = output {
159 self.output = out;
160 }
161 }
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
170#[serde(tag = "type")]
171pub enum BenchMode {
172 Steady {
174 concurrency: usize,
176 duration_secs: u64,
178 },
179 MaxThroughput {
181 min_concurrency: usize,
183 max_concurrency: usize,
185 step: usize,
187 step_duration_secs: u64,
189 max_error_rate: f64,
191 max_p99_ms: u64,
193 },
194 Soak {
196 concurrency: usize,
198 duration_secs: u64,
200 },
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct BenchConfig {
206 pub mode: BenchMode,
208 #[serde(default)]
210 pub warmup_requests: usize,
211 #[serde(default = "default_timeout")]
213 pub timeout_secs: u64,
214 #[serde(default)]
216 pub base_url: Option<String>,
217 #[serde(default = "default_output")]
219 pub output: PathBuf,
220}
221
222impl Default for BenchConfig {
223 fn default() -> Self {
224 Self {
225 mode: BenchMode::Steady {
226 concurrency: 10,
227 duration_secs: 30,
228 },
229 warmup_requests: 0,
230 timeout_secs: default_timeout(),
231 base_url: None,
232 output: default_output(),
233 }
234 }
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct FuzzConfig {
244 #[serde(default = "default_iterations")]
246 pub iterations: usize,
247 #[serde(default)]
249 pub mutators: Vec<String>,
250 #[serde(default)]
252 pub base_url: Option<String>,
253 #[serde(default = "default_timeout")]
255 pub timeout_secs: u64,
256 #[serde(default = "default_output")]
258 pub output: PathBuf,
259}
260
261fn default_iterations() -> usize {
262 1000
263}
264
265impl Default for FuzzConfig {
266 fn default() -> Self {
267 Self {
268 iterations: default_iterations(),
269 mutators: vec![],
270 base_url: None,
271 timeout_secs: default_timeout(),
272 output: default_output(),
273 }
274 }
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize)]
283pub enum ChaosExperiment {
284 NetworkLatency {
287 endpoint: String,
289 delay_ms: u64,
291 duration_secs: u64,
293 },
294
295 ConnectionReset {
297 endpoint: String,
299 reset_pct: u8,
301 duration_secs: u64,
303 },
304
305 PacketLoss {
307 endpoint: String,
309 drop_pct: u8,
311 duration_secs: u64,
313 },
314
315 ServiceError {
318 endpoint: String,
320 status: u16,
322 duration_secs: u64,
324 },
325
326 ServiceDown {
328 endpoint: String,
330 duration_secs: u64,
332 },
333
334 CpuPressure {
337 cores: usize,
339 duration_secs: u64,
341 },
342
343 MemoryPressure {
345 mb: usize,
347 duration_secs: u64,
349 },
350
351 ClockSkew {
354 offset_secs: i64,
356 duration_secs: u64,
358 },
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct ChaosConfig {
364 #[serde(default)]
366 pub experiments: Vec<ChaosExperiment>,
367 #[serde(default)]
369 pub base_url: Option<String>,
370 #[serde(default = "default_interval")]
372 pub interval_secs: u64,
373 #[serde(default = "default_timeout")]
375 pub timeout_secs: u64,
376 #[serde(default = "default_output")]
378 pub output: PathBuf,
379}
380
381fn default_interval() -> u64 {
382 5
383}
384
385impl Default for ChaosConfig {
386 fn default() -> Self {
387 Self {
388 experiments: vec![],
389 base_url: None,
390 interval_secs: default_interval(),
391 timeout_secs: default_timeout(),
392 output: default_output(),
393 }
394 }
395}
396
397#[derive(Debug, Clone, Serialize, Deserialize)]
403pub struct ContractConfig {
404 #[serde(default)]
406 pub spec_path: String,
407 #[serde(default)]
409 pub base_url: Option<String>,
410 #[serde(default)]
412 pub strict: bool,
413 #[serde(default = "default_timeout")]
415 pub timeout_secs: u64,
416 #[serde(default = "default_output")]
418 pub output: PathBuf,
419}
420
421impl Default for ContractConfig {
422 fn default() -> Self {
423 Self {
424 spec_path: String::new(),
425 base_url: None,
426 strict: false,
427 timeout_secs: default_timeout(),
428 output: default_output(),
429 }
430 }
431}
432
433#[derive(Debug, Clone, Serialize, Deserialize)]
439pub struct GuardConfig {
440 #[serde(default)]
442 pub base_url: Option<String>,
443 #[serde(default = "default_true")]
445 pub check_headers: bool,
446 #[serde(default = "default_true")]
448 pub check_cors: bool,
449 #[serde(default = "default_true")]
451 pub check_leaks: bool,
452 #[serde(default = "default_true")]
454 pub check_exposed: bool,
455 #[serde(default = "default_timeout")]
457 pub timeout_secs: u64,
458 #[serde(default = "default_output")]
460 pub output: PathBuf,
461}
462
463fn default_true() -> bool {
464 true
465}
466
467impl Default for GuardConfig {
468 fn default() -> Self {
469 Self {
470 base_url: None,
471 check_headers: true,
472 check_cors: true,
473 check_leaks: true,
474 check_exposed: true,
475 timeout_secs: default_timeout(),
476 output: default_output(),
477 }
478 }
479}
480
481#[derive(Debug, Clone, Serialize, Deserialize)]
487pub struct DiffConfig {
488 #[serde(default)]
490 pub baseline_url: String,
491 #[serde(default)]
493 pub target_url: String,
494 #[serde(default = "default_true")]
496 pub diff_headers: bool,
497 #[serde(default = "default_true")]
499 pub diff_bodies: bool,
500 #[serde(default = "default_true")]
502 pub diff_status: bool,
503 #[serde(default = "default_timeout")]
505 pub timeout_secs: u64,
506 #[serde(default = "default_output")]
508 pub output: PathBuf,
509}
510
511impl Default for DiffConfig {
512 fn default() -> Self {
513 Self {
514 baseline_url: String::new(),
515 target_url: String::new(),
516 diff_headers: true,
517 diff_bodies: true,
518 diff_status: true,
519 timeout_secs: default_timeout(),
520 output: default_output(),
521 }
522 }
523}
524
525#[derive(Debug, Clone, Serialize, Deserialize)]
531pub struct PlanConfig {
532 #[serde(default = "default_output")]
534 pub output: PathBuf,
535}
536
537impl Default for PlanConfig {
538 fn default() -> Self {
539 Self {
540 output: default_output(),
541 }
542 }
543}
544
545#[cfg(test)]
546mod tests {
547 use super::*;
548
549 #[test]
550 fn parse_run_config_toml() {
551 let toml = r#"
552base_url = "http://localhost:8080"
553output = "./test-output"
554timeout_secs = 60
555
556[headers]
557Authorization = "Bearer test-token"
558"#;
559 let config: RunConfig = toml::from_str(toml).unwrap();
560 assert_eq!(config.base_url, Some("http://localhost:8080".to_string()));
561 assert_eq!(config.output, PathBuf::from("./test-output"));
562 assert_eq!(config.timeout_secs, 60);
563 assert_eq!(
564 config.headers.get("Authorization").unwrap(),
565 "Bearer test-token"
566 );
567 }
568
569 #[test]
570 fn parse_run_config_defaults() {
571 let toml = r#"
572base_url = "http://localhost:8080"
573"#;
574 let config: RunConfig = toml::from_str(toml).unwrap();
575 assert_eq!(config.base_url, Some("http://localhost:8080".to_string()));
576 assert_eq!(config.output, PathBuf::from("./output"));
577 assert_eq!(config.timeout_secs, 30);
578 assert!(config.headers.is_empty());
579 }
580
581 #[test]
582 fn merge_overrides() {
583 let mut config = RunConfig {
584 base_url: Some("http://original".to_string()),
585 output: PathBuf::from("./original"),
586 headers: HashMap::new(),
587 timeout_secs: 30,
588 };
589 config.merge(
590 Some("http://override".to_string()),
591 Some(PathBuf::from("./override")),
592 );
593 assert_eq!(config.base_url, Some("http://override".to_string()));
594 assert_eq!(config.output, PathBuf::from("./override"));
595 }
596
597 #[test]
598 fn merge_partial() {
599 let mut config = RunConfig {
600 base_url: Some("http://original".to_string()),
601 output: PathBuf::from("./original"),
602 headers: HashMap::new(),
603 timeout_secs: 30,
604 };
605 config.merge(None, Some(PathBuf::from("./override")));
606 assert_eq!(config.base_url, Some("http://original".to_string()));
607 assert_eq!(config.output, PathBuf::from("./override"));
608 }
609
610 #[test]
613 fn parse_momus_config_empty() {
614 let toml = "";
615 let config: MomusConfig = toml::from_str(toml).unwrap();
616 assert!(config.global.base_url.is_none());
617 assert!(config.run.base_url.is_none());
618 assert!(config.bench.base_url.is_none());
619 assert!(config.fuzz.base_url.is_none());
620 assert!(config.chaos.base_url.is_none());
621 assert!(config.contract.base_url.is_none());
622 assert!(config.guard.base_url.is_none());
623 assert!(config.diff.baseline_url.is_empty());
624 }
625
626 #[test]
627 fn parse_momus_config_full() {
628 let toml = r#"
629[global]
630base_url = "http://global:8080"
631timeout_secs = 60
632
633[run]
634output = "./run-output"
635
636[bench]
637warmup_requests = 100
638mode = { type = "Steady", concurrency = 20, duration_secs = 60 }
639
640[fuzz]
641iterations = 5000
642
643[chaos]
644interval_secs = 10
645
646[contract]
647spec_path = "./api.yaml"
648strict = true
649
650[guard]
651check_headers = false
652
653[diff]
654baseline_url = "https://prod.example.com"
655target_url = "https://staging.example.com"
656"#;
657 let config: MomusConfig = toml::from_str(toml).unwrap();
658 assert_eq!(
659 config.global.base_url,
660 Some("http://global:8080".to_string())
661 );
662 assert_eq!(config.global.timeout_secs, 60);
663 assert_eq!(config.run.output, PathBuf::from("./run-output"));
664 assert_eq!(config.bench.warmup_requests, 100);
665 assert_eq!(config.fuzz.iterations, 5000);
666 assert_eq!(config.chaos.interval_secs, 10);
667 assert_eq!(config.contract.spec_path, "./api.yaml");
668 assert!(config.contract.strict);
669 assert!(!config.guard.check_headers);
670 assert_eq!(config.diff.baseline_url, "https://prod.example.com");
671 assert_eq!(config.diff.target_url, "https://staging.example.com");
672 }
673
674 #[test]
675 fn parse_momus_config_global_fallback() {
676 let toml = r#"
678[global]
679base_url = "http://global:8080"
680"#;
681 let config: MomusConfig = toml::from_str(toml).unwrap();
682 assert_eq!(
683 config.global.base_url,
684 Some("http://global:8080".to_string())
685 );
686 assert_eq!(config.run.output, PathBuf::from("./output"));
687 assert_eq!(config.bench.warmup_requests, 0);
688 assert_eq!(config.fuzz.iterations, 1000);
689 assert_eq!(config.chaos.interval_secs, 5);
690 assert!(!config.contract.strict);
691 assert!(config.guard.check_headers);
692 assert!(config.diff.baseline_url.is_empty());
693 }
694}