pub mod validate;
use std::collections::HashMap;
use serde::Deserialize;
use crate::encoder::EncoderConfig;
use crate::generator::{GeneratorConfig, LogGeneratorConfig};
use crate::sink::SinkConfig;
#[derive(Debug, Clone, Deserialize)]
pub struct GapConfig {
pub every: String,
pub r#for: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct BurstConfig {
pub every: String,
pub r#for: String,
pub multiplier: f64,
}
fn default_encoder() -> EncoderConfig {
EncoderConfig::PrometheusText
}
fn default_log_encoder() -> EncoderConfig {
EncoderConfig::JsonLines
}
fn default_sink() -> SinkConfig {
SinkConfig::Stdout
}
#[derive(Debug, Clone, Deserialize)]
pub struct ScenarioConfig {
pub name: String,
pub rate: f64,
#[serde(default)]
pub duration: Option<String>,
pub generator: GeneratorConfig,
#[serde(default)]
pub gaps: Option<GapConfig>,
#[serde(default)]
pub bursts: Option<BurstConfig>,
#[serde(default)]
pub labels: Option<HashMap<String, String>>,
#[serde(default = "default_encoder")]
pub encoder: EncoderConfig,
#[serde(default = "default_sink")]
pub sink: SinkConfig,
#[serde(default)]
pub phase_offset: Option<String>,
#[serde(default)]
pub clock_group: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "signal_type")]
pub enum ScenarioEntry {
#[serde(rename = "metrics")]
Metrics(ScenarioConfig),
#[serde(rename = "logs")]
Logs(LogScenarioConfig),
}
impl ScenarioEntry {
pub fn phase_offset(&self) -> Option<&str> {
match self {
ScenarioEntry::Metrics(c) => c.phase_offset.as_deref(),
ScenarioEntry::Logs(c) => c.phase_offset.as_deref(),
}
}
pub fn clock_group(&self) -> Option<&str> {
match self {
ScenarioEntry::Metrics(c) => c.clock_group.as_deref(),
ScenarioEntry::Logs(c) => c.clock_group.as_deref(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct MultiScenarioConfig {
pub scenarios: Vec<ScenarioEntry>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LogScenarioConfig {
pub name: String,
pub rate: f64,
#[serde(default)]
pub duration: Option<String>,
pub generator: LogGeneratorConfig,
#[serde(default)]
pub gaps: Option<GapConfig>,
#[serde(default)]
pub bursts: Option<BurstConfig>,
#[serde(default)]
pub labels: Option<HashMap<String, String>>,
#[serde(default = "default_log_encoder")]
pub encoder: EncoderConfig,
#[serde(default = "default_sink")]
pub sink: SinkConfig,
#[serde(default)]
pub phase_offset: Option<String>,
#[serde(default)]
pub clock_group: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scenario_config_phase_offset_deserializes_from_yaml() {
let yaml = r#"
name: test_metric
rate: 10
generator:
type: constant
value: 1.0
phase_offset: "5s"
"#;
let config: ScenarioConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.phase_offset.as_deref(), Some("5s"));
}
#[test]
fn scenario_config_phase_offset_defaults_to_none() {
let yaml = r#"
name: test_metric
rate: 10
generator:
type: constant
value: 1.0
"#;
let config: ScenarioConfig = serde_yaml::from_str(yaml).unwrap();
assert!(config.phase_offset.is_none());
}
#[test]
fn scenario_config_phase_offset_milliseconds() {
let yaml = r#"
name: ms_test
rate: 10
generator:
type: constant
value: 1.0
phase_offset: "500ms"
"#;
let config: ScenarioConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.phase_offset.as_deref(), Some("500ms"));
}
#[test]
fn scenario_config_phase_offset_minutes() {
let yaml = r#"
name: min_test
rate: 10
generator:
type: constant
value: 1.0
phase_offset: "2m"
"#;
let config: ScenarioConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.phase_offset.as_deref(), Some("2m"));
}
#[test]
fn log_scenario_config_phase_offset_deserializes_from_yaml() {
let yaml = r#"
name: log_test
rate: 10
generator:
type: template
templates:
- message: "test"
field_pools: {}
phase_offset: "3s"
"#;
let config: LogScenarioConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.phase_offset.as_deref(), Some("3s"));
}
#[test]
fn log_scenario_config_phase_offset_defaults_to_none() {
let yaml = r#"
name: log_test
rate: 10
generator:
type: template
templates:
- message: "test"
field_pools: {}
"#;
let config: LogScenarioConfig = serde_yaml::from_str(yaml).unwrap();
assert!(config.phase_offset.is_none());
}
#[test]
fn scenario_config_clock_group_deserializes_from_yaml() {
let yaml = r#"
name: group_test
rate: 10
generator:
type: constant
value: 1.0
clock_group: alert-test
"#;
let config: ScenarioConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.clock_group.as_deref(), Some("alert-test"));
}
#[test]
fn scenario_config_clock_group_defaults_to_none() {
let yaml = r#"
name: no_group
rate: 10
generator:
type: constant
value: 1.0
"#;
let config: ScenarioConfig = serde_yaml::from_str(yaml).unwrap();
assert!(config.clock_group.is_none());
}
#[test]
fn log_scenario_config_clock_group_deserializes_from_yaml() {
let yaml = r#"
name: log_group
rate: 10
generator:
type: template
templates:
- message: "test"
field_pools: {}
clock_group: log-sync
"#;
let config: LogScenarioConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.clock_group.as_deref(), Some("log-sync"));
}
#[test]
fn log_scenario_config_clock_group_defaults_to_none() {
let yaml = r#"
name: log_no_group
rate: 10
generator:
type: template
templates:
- message: "test"
field_pools: {}
"#;
let config: LogScenarioConfig = serde_yaml::from_str(yaml).unwrap();
assert!(config.clock_group.is_none());
}
#[test]
fn log_scenario_config_labels_deserialize_from_yaml() {
let yaml = r#"
name: labeled_logs
rate: 10
generator:
type: template
templates:
- message: "test"
field_pools: {}
labels:
device: wlan0
hostname: router-01
"#;
let config: LogScenarioConfig = serde_yaml::from_str(yaml).unwrap();
let labels = config.labels.as_ref().expect("labels must be Some");
assert_eq!(labels.get("device").map(String::as_str), Some("wlan0"));
assert_eq!(
labels.get("hostname").map(String::as_str),
Some("router-01")
);
assert_eq!(labels.len(), 2);
}
#[test]
fn log_scenario_config_labels_default_to_none() {
let yaml = r#"
name: no_labels_logs
rate: 10
generator:
type: template
templates:
- message: "test"
field_pools: {}
"#;
let config: LogScenarioConfig = serde_yaml::from_str(yaml).unwrap();
assert!(
config.labels.is_none(),
"labels must default to None when not in YAML"
);
}
#[test]
fn log_scenario_config_empty_labels_deserializes_as_some_empty_map() {
let yaml = r#"
name: empty_labels
rate: 10
generator:
type: template
templates:
- message: "test"
field_pools: {}
labels: {}
"#;
let config: LogScenarioConfig = serde_yaml::from_str(yaml).unwrap();
let labels = config
.labels
.as_ref()
.expect("labels must be Some for explicit empty map");
assert!(labels.is_empty(), "labels must be an empty map");
}
#[test]
fn scenario_config_labels_deserialize_from_yaml() {
let yaml = r#"
name: metric_with_labels
rate: 10
generator:
type: constant
value: 1.0
labels:
zone: eu1
env: production
"#;
let config: ScenarioConfig = serde_yaml::from_str(yaml).unwrap();
let labels = config.labels.as_ref().expect("labels must be Some");
assert_eq!(labels.get("zone").map(String::as_str), Some("eu1"));
assert_eq!(labels.get("env").map(String::as_str), Some("production"));
}
#[test]
fn scenario_config_both_phase_offset_and_clock_group() {
let yaml = r#"
name: both_fields
rate: 10
generator:
type: constant
value: 1.0
phase_offset: "30s"
clock_group: compound-alert
"#;
let config: ScenarioConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.phase_offset.as_deref(), Some("30s"));
assert_eq!(config.clock_group.as_deref(), Some("compound-alert"));
}
#[test]
fn scenario_entry_phase_offset_returns_value_for_metrics() {
let entry = ScenarioEntry::Metrics(ScenarioConfig {
name: "accessor_test".to_string(),
rate: 10.0,
duration: None,
generator: GeneratorConfig::Constant { value: 1.0 },
gaps: None,
bursts: None,
labels: None,
encoder: EncoderConfig::PrometheusText,
sink: SinkConfig::Stdout,
phase_offset: Some("5s".to_string()),
clock_group: None,
});
assert_eq!(entry.phase_offset(), Some("5s"));
}
#[test]
fn scenario_entry_phase_offset_returns_none_for_metrics_without_offset() {
let entry = ScenarioEntry::Metrics(ScenarioConfig {
name: "no_offset".to_string(),
rate: 10.0,
duration: None,
generator: GeneratorConfig::Constant { value: 1.0 },
gaps: None,
bursts: None,
labels: None,
encoder: EncoderConfig::PrometheusText,
sink: SinkConfig::Stdout,
phase_offset: None,
clock_group: None,
});
assert_eq!(entry.phase_offset(), None);
}
#[test]
fn scenario_entry_phase_offset_returns_value_for_logs() {
let entry = ScenarioEntry::Logs(LogScenarioConfig {
name: "log_accessor".to_string(),
rate: 10.0,
duration: None,
generator: LogGeneratorConfig::Template {
templates: vec![crate::generator::TemplateConfig {
message: "test".to_string(),
field_pools: HashMap::new(),
}],
severity_weights: None,
seed: Some(0),
},
gaps: None,
bursts: None,
labels: None,
encoder: EncoderConfig::JsonLines,
sink: SinkConfig::Stdout,
phase_offset: Some("10s".to_string()),
clock_group: None,
});
assert_eq!(entry.phase_offset(), Some("10s"));
}
#[test]
fn scenario_entry_clock_group_returns_value_for_metrics() {
let entry = ScenarioEntry::Metrics(ScenarioConfig {
name: "group_accessor".to_string(),
rate: 10.0,
duration: None,
generator: GeneratorConfig::Constant { value: 1.0 },
gaps: None,
bursts: None,
labels: None,
encoder: EncoderConfig::PrometheusText,
sink: SinkConfig::Stdout,
phase_offset: None,
clock_group: Some("my-group".to_string()),
});
assert_eq!(entry.clock_group(), Some("my-group"));
}
#[test]
fn scenario_entry_clock_group_returns_none_when_absent() {
let entry = ScenarioEntry::Metrics(ScenarioConfig {
name: "no_group_acc".to_string(),
rate: 10.0,
duration: None,
generator: GeneratorConfig::Constant { value: 1.0 },
gaps: None,
bursts: None,
labels: None,
encoder: EncoderConfig::PrometheusText,
sink: SinkConfig::Stdout,
phase_offset: None,
clock_group: None,
});
assert_eq!(entry.clock_group(), None);
}
#[test]
fn multi_scenario_config_with_phase_offset_and_clock_group_deserializes() {
let yaml = r#"
scenarios:
- signal_type: metrics
name: cpu_usage
rate: 1
duration: 10s
phase_offset: "0s"
clock_group: alert-test
generator:
type: constant
value: 95.0
encoder:
type: prometheus_text
sink:
type: stdout
- signal_type: metrics
name: memory_usage
rate: 1
duration: 10s
phase_offset: "3s"
clock_group: alert-test
generator:
type: constant
value: 88.0
encoder:
type: prometheus_text
sink:
type: stdout
"#;
let config: MultiScenarioConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.scenarios.len(), 2);
assert_eq!(config.scenarios[0].phase_offset(), Some("0s"));
assert_eq!(config.scenarios[0].clock_group(), Some("alert-test"));
assert_eq!(config.scenarios[1].phase_offset(), Some("3s"));
assert_eq!(config.scenarios[1].clock_group(), Some("alert-test"));
}
#[test]
fn multi_scenario_config_without_phase_offset_backward_compatible() {
let yaml = r#"
scenarios:
- signal_type: metrics
name: cpu_usage
rate: 100
duration: 10s
generator:
type: constant
value: 1.0
encoder:
type: prometheus_text
sink:
type: stdout
"#;
let config: MultiScenarioConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.scenarios.len(), 1);
assert_eq!(config.scenarios[0].phase_offset(), None);
assert_eq!(config.scenarios[0].clock_group(), None);
}
#[test]
fn multi_metric_correlation_example_deserializes() {
let yaml = include_str!("../../../examples/multi-metric-correlation.yaml");
let config: MultiScenarioConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.scenarios.len(), 2, "example must have 2 scenarios");
assert_eq!(config.scenarios[0].phase_offset(), Some("0s"));
assert_eq!(config.scenarios[0].clock_group(), Some("alert-test"));
assert_eq!(config.scenarios[1].phase_offset(), Some("3s"));
assert_eq!(config.scenarios[1].clock_group(), Some("alert-test"));
assert!(matches!(config.scenarios[0], ScenarioEntry::Metrics(_)));
assert!(matches!(config.scenarios[1], ScenarioEntry::Metrics(_)));
}
#[test]
fn multi_scenario_config_logs_entry_with_phase_offset() {
let yaml = r#"
scenarios:
- signal_type: logs
name: delayed_logs
rate: 10
duration: 10s
phase_offset: "2s"
clock_group: log-group
generator:
type: template
templates:
- message: "log event"
field_pools: {}
encoder:
type: json_lines
sink:
type: stdout
"#;
let config: MultiScenarioConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.scenarios.len(), 1);
assert_eq!(config.scenarios[0].phase_offset(), Some("2s"));
assert_eq!(config.scenarios[0].clock_group(), Some("log-group"));
}
#[test]
fn phase_offset_values_are_parseable_as_durations() {
use crate::config::validate::parse_duration;
let yaml = r#"
name: parse_test
rate: 10
generator:
type: constant
value: 1.0
phase_offset: "3s"
"#;
let config: ScenarioConfig = serde_yaml::from_str(yaml).unwrap();
let dur = parse_duration(config.phase_offset.as_deref().unwrap()).unwrap();
assert_eq!(dur, std::time::Duration::from_secs(3));
}
}