Skip to main content

momus_core/
config.rs

1/// Generic configuration for running test plans.
2///
3/// This module provides a common configuration struct that can be loaded
4/// from a TOML file and merged with CLI overrides. Each engine crate
5/// (bench, fuzz, chaos, etc.) extends this with its own specific config.
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::PathBuf;
9
10// ---------------------------------------------------------------------------
11// Top-level config — a single TOML file with sections per crate
12// ---------------------------------------------------------------------------
13
14/// Top-level configuration loaded from `config.toml`.
15///
16/// Each crate's config lives in its own TOML section. CLI flags override
17/// the corresponding fields after loading.
18#[derive(Debug, Clone, Serialize, Deserialize, Default)]
19pub struct MomusConfig {
20    /// Global defaults shared by all commands.
21    #[serde(default)]
22    pub global: GlobalConfig,
23    /// `momus run` / `momus validate` settings.
24    #[serde(default)]
25    pub run: RunConfig,
26    /// `momus bench` settings.
27    #[serde(default)]
28    pub bench: BenchConfig,
29    /// `momus fuzz` settings.
30    #[serde(default)]
31    pub fuzz: FuzzConfig,
32    /// `momus chaos` settings.
33    #[serde(default)]
34    pub chaos: ChaosConfig,
35    /// `momus contract` settings.
36    #[serde(default)]
37    pub contract: ContractConfig,
38    /// `momus guard` settings.
39    #[serde(default)]
40    pub guard: GuardConfig,
41    /// `momus diff` settings.
42    #[serde(default)]
43    pub diff: DiffConfig,
44    /// `momus plan` settings.
45    #[serde(default)]
46    pub plan: PlanConfig,
47}
48
49impl MomusConfig {
50    /// Load a `MomusConfig` from a TOML file path.
51    ///
52    /// Returns the default config (all sections empty) if the file doesn't exist.
53    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    /// Load a `MomusConfig` from a TOML file, returning default if file not found.
60    /// Logs a warning if the file exists but cannot be parsed.
61    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/// Global defaults inherited by all commands unless overridden by a
80/// command-specific section or CLI flag.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct GlobalConfig {
83    /// Base URL for all requests (overrides the plan's base_url).
84    #[serde(default)]
85    pub base_url: Option<String>,
86    /// Default headers sent with every request.
87    #[serde(default)]
88    pub headers: HashMap<String, String>,
89    /// Request timeout in seconds.
90    #[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// ---------------------------------------------------------------------------
105// Run / Validate config
106// ---------------------------------------------------------------------------
107
108/// Common configuration for `momus run` and `momus validate`.
109///
110/// This is the base config that can be loaded from a TOML file.
111/// CLI flags override specific fields after loading.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct RunConfig {
114    /// Base URL for all requests (overrides the plan's base_url).
115    #[serde(default)]
116    pub base_url: Option<String>,
117
118    /// Output directory for results.
119    #[serde(default = "default_output")]
120    pub output: PathBuf,
121
122    /// Default headers sent with every request.
123    #[serde(default)]
124    pub headers: HashMap<String, String>,
125
126    /// Request timeout in seconds.
127    #[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    /// Merge CLI overrides into this config.
152    ///
153    /// CLI values take precedence over file values.
154    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// ---------------------------------------------------------------------------
165// Bench config
166// ---------------------------------------------------------------------------
167
168/// Benchmark execution mode.
169#[derive(Debug, Clone, Serialize, Deserialize)]
170#[serde(tag = "type")]
171pub enum BenchMode {
172    /// Fixed concurrency for a fixed duration.
173    Steady {
174        /// Number of concurrent workers.
175        concurrency: usize,
176        /// Duration in seconds (0 = one-shot, run each step once).
177        duration_secs: u64,
178    },
179    /// Ramp concurrency upward until error rate or latency threshold is breached.
180    MaxThroughput {
181        /// Starting concurrency.
182        min_concurrency: usize,
183        /// Maximum concurrency to try.
184        max_concurrency: usize,
185        /// Concurrency increment per step.
186        step: usize,
187        /// Duration per step in seconds.
188        step_duration_secs: u64,
189        /// Error rate threshold (0.0–1.0) that triggers stop.
190        max_error_rate: f64,
191        /// Latency P99 threshold in ms that triggers stop.
192        max_p99_ms: u64,
193    },
194    /// Sustained load at fixed concurrency for hours.
195    Soak {
196        /// Number of concurrent workers.
197        concurrency: usize,
198        /// Duration in seconds.
199        duration_secs: u64,
200    },
201}
202
203/// Configuration for a benchmark run.
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct BenchConfig {
206    /// Execution mode.
207    pub mode: BenchMode,
208    /// Number of warmup requests before recording (0 = no warmup).
209    #[serde(default)]
210    pub warmup_requests: usize,
211    /// Request timeout in seconds.
212    #[serde(default = "default_timeout")]
213    pub timeout_secs: u64,
214    /// Base URL override (overrides the plan's base_url).
215    #[serde(default)]
216    pub base_url: Option<String>,
217    /// Output directory for results.
218    #[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// ---------------------------------------------------------------------------
238// Fuzz config
239// ---------------------------------------------------------------------------
240
241/// Configuration for a fuzz run.
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct FuzzConfig {
244    /// Number of mutations to generate per input.
245    #[serde(default = "default_iterations")]
246    pub iterations: usize,
247    /// Which mutators to apply (empty = all).
248    #[serde(default)]
249    pub mutators: Vec<String>,
250    /// Base URL override.
251    #[serde(default)]
252    pub base_url: Option<String>,
253    /// Request timeout in seconds.
254    #[serde(default = "default_timeout")]
255    pub timeout_secs: u64,
256    /// Output directory for results.
257    #[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// ---------------------------------------------------------------------------
278// Chaos config
279// ---------------------------------------------------------------------------
280
281/// A single chaos experiment to run.
282#[derive(Debug, Clone, Serialize, Deserialize)]
283pub enum ChaosExperiment {
284    // -- Network faults ------------------------------------------------------
285    /// Inject artificial latency into requests to a specific endpoint.
286    NetworkLatency {
287        /// Endpoint path pattern (e.g. "/api/slow").
288        endpoint: String,
289        /// Additional delay in milliseconds.
290        delay_ms: u64,
291        /// How long the fault is active in seconds.
292        duration_secs: u64,
293    },
294
295    /// Simulate connection resets for a percentage of requests.
296    ConnectionReset {
297        /// Endpoint path pattern.
298        endpoint: String,
299        /// Percentage of requests to reset (0–100).
300        reset_pct: u8,
301        /// How long the fault is active in seconds.
302        duration_secs: u64,
303    },
304
305    /// Drop a percentage of requests (simulate packet loss).
306    PacketLoss {
307        /// Endpoint path pattern.
308        endpoint: String,
309        /// Percentage of requests to drop (0–100).
310        drop_pct: u8,
311        /// How long the fault is active in seconds.
312        duration_secs: u64,
313    },
314
315    // -- Service faults ------------------------------------------------------
316    /// Return a specific HTTP status code for a matching endpoint.
317    ServiceError {
318        /// Endpoint path pattern.
319        endpoint: String,
320        /// HTTP status code to return.
321        status: u16,
322        /// How long the fault is active in seconds.
323        duration_secs: u64,
324    },
325
326    /// Simulate a downstream service being unreachable.
327    ServiceDown {
328        /// Endpoint path pattern.
329        endpoint: String,
330        /// How long the fault is active in seconds.
331        duration_secs: u64,
332    },
333
334    // -- Resource faults -----------------------------------------------------
335    /// Simulate CPU pressure (busy loop on N cores).
336    CpuPressure {
337        /// Number of cores to saturate.
338        cores: usize,
339        /// Duration in seconds.
340        duration_secs: u64,
341    },
342
343    /// Simulate memory pressure (allocate N MB).
344    MemoryPressure {
345        /// Megabytes to allocate.
346        mb: usize,
347        /// Duration in seconds.
348        duration_secs: u64,
349    },
350
351    // -- State faults --------------------------------------------------------
352    /// Simulate clock skew (N seconds ahead/behind).
353    ClockSkew {
354        /// Offset in seconds (positive = ahead, negative = behind).
355        offset_secs: i64,
356        /// Duration in seconds.
357        duration_secs: u64,
358    },
359}
360
361/// Configuration for a chaos run.
362#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct ChaosConfig {
364    /// List of experiments to run (sequentially).
365    #[serde(default)]
366    pub experiments: Vec<ChaosExperiment>,
367    /// Base URL override.
368    #[serde(default)]
369    pub base_url: Option<String>,
370    /// How long to wait between experiments (seconds).
371    #[serde(default = "default_interval")]
372    pub interval_secs: u64,
373    /// Request timeout in seconds.
374    #[serde(default = "default_timeout")]
375    pub timeout_secs: u64,
376    /// Output directory for results.
377    #[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// ---------------------------------------------------------------------------
398// Contract config
399// ---------------------------------------------------------------------------
400
401/// Configuration for a contract validation run.
402#[derive(Debug, Clone, Serialize, Deserialize)]
403pub struct ContractConfig {
404    /// Path to the API spec file (OpenAPI YAML/JSON or GraphQL SDL).
405    #[serde(default)]
406    pub spec_path: String,
407    /// Base URL override.
408    #[serde(default)]
409    pub base_url: Option<String>,
410    /// Whether to fail on undocumented endpoints.
411    #[serde(default)]
412    pub strict: bool,
413    /// Request timeout in seconds.
414    #[serde(default = "default_timeout")]
415    pub timeout_secs: u64,
416    /// Output directory for results.
417    #[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// ---------------------------------------------------------------------------
434// Guard config
435// ---------------------------------------------------------------------------
436
437/// Configuration for a security scan.
438#[derive(Debug, Clone, Serialize, Deserialize)]
439pub struct GuardConfig {
440    /// Base URL override.
441    #[serde(default)]
442    pub base_url: Option<String>,
443    /// Whether to check for missing security headers.
444    #[serde(default = "default_true")]
445    pub check_headers: bool,
446    /// Whether to check CORS configuration.
447    #[serde(default = "default_true")]
448    pub check_cors: bool,
449    /// Whether to check for information leakage in error responses.
450    #[serde(default = "default_true")]
451    pub check_leaks: bool,
452    /// Whether to check for exposed internal endpoints.
453    #[serde(default = "default_true")]
454    pub check_exposed: bool,
455    /// Request timeout in seconds.
456    #[serde(default = "default_timeout")]
457    pub timeout_secs: u64,
458    /// Output directory for results.
459    #[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// ---------------------------------------------------------------------------
482// Diff config
483// ---------------------------------------------------------------------------
484
485/// Configuration for a diff run.
486#[derive(Debug, Clone, Serialize, Deserialize)]
487pub struct DiffConfig {
488    /// Baseline environment URL (e.g. production).
489    #[serde(default)]
490    pub baseline_url: String,
491    /// Target environment URL (e.g. staging, new deployment).
492    #[serde(default)]
493    pub target_url: String,
494    /// Whether to diff response headers.
495    #[serde(default = "default_true")]
496    pub diff_headers: bool,
497    /// Whether to diff response bodies.
498    #[serde(default = "default_true")]
499    pub diff_bodies: bool,
500    /// Whether to diff status codes.
501    #[serde(default = "default_true")]
502    pub diff_status: bool,
503    /// Request timeout in seconds.
504    #[serde(default = "default_timeout")]
505    pub timeout_secs: u64,
506    /// Output directory for results.
507    #[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// ---------------------------------------------------------------------------
526// Plan config
527// ---------------------------------------------------------------------------
528
529/// Configuration for `momus plan`.
530#[derive(Debug, Clone, Serialize, Deserialize)]
531pub struct PlanConfig {
532    /// Output directory for the plan display (default: ./output).
533    #[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    // -- MomusConfig (multi-section) tests -----------------------------------
611
612    #[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        // When a section is missing, its defaults apply.
677        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}