1pub mod aliases;
9pub mod validate;
10
11use std::collections::HashMap;
12
13use crate::encoder::EncoderConfig;
14use crate::generator::{CsvColumnSpec, GeneratorConfig, LogGeneratorConfig};
15use crate::sink::SinkConfig;
16use crate::{ConfigError, SondaError};
17
18#[derive(Debug, Clone)]
23#[cfg_attr(feature = "config", derive(serde::Serialize, serde::Deserialize))]
24pub struct GapConfig {
25 pub every: String,
27 pub r#for: String,
29}
30
31#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
36#[cfg_attr(feature = "config", derive(serde::Serialize, serde::Deserialize))]
37#[cfg_attr(feature = "config", serde(rename_all = "snake_case"))]
38pub enum SpikeStrategy {
39 #[default]
43 Counter,
44 Random,
49}
50
51#[derive(Debug, Clone)]
69#[cfg_attr(feature = "config", derive(serde::Serialize, serde::Deserialize))]
70pub struct CardinalitySpikeConfig {
71 pub label: String,
75 pub every: String,
77 pub r#for: String,
79 pub cardinality: u64,
83 #[cfg_attr(feature = "config", serde(default))]
87 pub strategy: SpikeStrategy,
88 #[cfg_attr(feature = "config", serde(default))]
92 pub prefix: Option<String>,
93 #[cfg_attr(feature = "config", serde(default))]
97 pub seed: Option<u64>,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
105#[cfg_attr(feature = "config", derive(serde::Serialize, serde::Deserialize))]
106#[cfg_attr(feature = "config", serde(untagged))]
107pub enum DynamicLabelStrategy {
108 ValuesList {
113 values: Vec<String>,
115 },
116 Counter {
121 #[cfg_attr(feature = "config", serde(default))]
124 prefix: Option<String>,
125 cardinality: u64,
127 },
128}
129
130#[derive(Debug, Clone)]
155#[cfg_attr(feature = "config", derive(serde::Serialize, serde::Deserialize))]
156pub struct DynamicLabelConfig {
157 pub key: String,
161 #[cfg_attr(feature = "config", serde(flatten))]
166 pub strategy: DynamicLabelStrategy,
167}
168
169#[derive(Debug, Clone)]
177#[cfg_attr(feature = "config", derive(serde::Serialize, serde::Deserialize))]
178pub struct BurstConfig {
179 pub every: String,
181 pub r#for: String,
183 pub multiplier: f64,
185}
186
187#[cfg(feature = "config")]
188fn default_encoder() -> EncoderConfig {
189 EncoderConfig::PrometheusText { precision: None }
190}
191
192#[cfg(feature = "config")]
193fn default_log_encoder() -> EncoderConfig {
194 EncoderConfig::JsonLines { precision: None }
195}
196
197#[cfg(feature = "config")]
198fn default_sink() -> SinkConfig {
199 SinkConfig::Stdout
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
208#[cfg_attr(feature = "config", derive(serde::Serialize, serde::Deserialize))]
209#[cfg_attr(feature = "config", serde(rename_all = "lowercase"))]
210pub enum OnSinkError {
211 #[default]
212 Warn,
213 Fail,
214}
215
216#[derive(Debug, Clone)]
227#[cfg_attr(feature = "config", derive(serde::Serialize, serde::Deserialize))]
228pub struct BaseScheduleConfig {
229 pub name: String,
231 pub rate: f64,
233 #[cfg_attr(feature = "config", serde(default))]
235 pub duration: Option<String>,
236 #[cfg_attr(feature = "config", serde(default))]
238 pub gaps: Option<GapConfig>,
239 #[cfg_attr(feature = "config", serde(default))]
243 pub bursts: Option<BurstConfig>,
244 #[cfg_attr(feature = "config", serde(default))]
247 pub cardinality_spikes: Option<Vec<CardinalitySpikeConfig>>,
248 #[cfg_attr(feature = "config", serde(default))]
255 pub dynamic_labels: Option<Vec<DynamicLabelConfig>>,
256 #[cfg_attr(feature = "config", serde(default))]
258 pub labels: Option<HashMap<String, String>>,
259 #[cfg_attr(feature = "config", serde(default = "default_sink"))]
261 pub sink: SinkConfig,
262 #[cfg_attr(feature = "config", serde(default))]
268 pub phase_offset: Option<String>,
269 #[cfg_attr(feature = "config", serde(default))]
275 pub clock_group: Option<String>,
276 #[cfg_attr(feature = "config", serde(skip))]
296 pub clock_group_is_auto: Option<bool>,
297 #[cfg_attr(feature = "config", serde(default))]
300 pub jitter: Option<f64>,
301 #[cfg_attr(feature = "config", serde(default))]
304 pub jitter_seed: Option<u64>,
305 #[cfg_attr(feature = "config", serde(default))]
307 pub on_sink_error: OnSinkError,
308}
309
310#[derive(Debug, Clone)]
343#[cfg_attr(feature = "config", derive(serde::Serialize, serde::Deserialize))]
344pub struct ScenarioConfig {
345 #[cfg_attr(feature = "config", serde(flatten))]
347 pub base: BaseScheduleConfig,
348 pub generator: GeneratorConfig,
350 #[cfg_attr(feature = "config", serde(default = "default_encoder"))]
352 pub encoder: EncoderConfig,
353}
354
355impl std::ops::Deref for ScenarioConfig {
356 type Target = BaseScheduleConfig;
357
358 fn deref(&self) -> &BaseScheduleConfig {
359 &self.base
360 }
361}
362
363impl std::ops::DerefMut for ScenarioConfig {
364 fn deref_mut(&mut self) -> &mut BaseScheduleConfig {
365 &mut self.base
366 }
367}
368
369#[derive(Debug, Clone)]
382#[cfg_attr(feature = "config", derive(serde::Serialize, serde::Deserialize))]
383#[cfg_attr(feature = "config", serde(tag = "type"))]
384#[non_exhaustive]
385pub enum DistributionConfig {
386 #[cfg_attr(feature = "config", serde(rename = "exponential"))]
390 Exponential {
391 rate: f64,
393 },
394 #[cfg_attr(feature = "config", serde(rename = "normal"))]
396 Normal {
397 mean: f64,
399 stddev: f64,
401 },
402 #[cfg_attr(feature = "config", serde(rename = "uniform"))]
404 Uniform {
405 min: f64,
407 max: f64,
409 },
410}
411
412#[derive(Debug, Clone)]
439#[cfg_attr(feature = "config", derive(serde::Serialize, serde::Deserialize))]
440pub struct HistogramScenarioConfig {
441 #[cfg_attr(feature = "config", serde(flatten))]
443 pub base: BaseScheduleConfig,
444 #[cfg_attr(feature = "config", serde(default))]
447 pub buckets: Option<Vec<f64>>,
448 pub distribution: DistributionConfig,
450 #[cfg_attr(feature = "config", serde(default))]
452 pub observations_per_tick: Option<u64>,
453 #[cfg_attr(feature = "config", serde(default))]
455 pub mean_shift_per_sec: Option<f64>,
456 #[cfg_attr(feature = "config", serde(default))]
458 pub seed: Option<u64>,
459 #[cfg_attr(feature = "config", serde(default = "default_encoder"))]
461 pub encoder: EncoderConfig,
462}
463
464impl std::ops::Deref for HistogramScenarioConfig {
465 type Target = BaseScheduleConfig;
466
467 fn deref(&self) -> &BaseScheduleConfig {
468 &self.base
469 }
470}
471
472impl std::ops::DerefMut for HistogramScenarioConfig {
473 fn deref_mut(&mut self) -> &mut BaseScheduleConfig {
474 &mut self.base
475 }
476}
477
478#[derive(Debug, Clone)]
500#[cfg_attr(feature = "config", derive(serde::Serialize, serde::Deserialize))]
501pub struct SummaryScenarioConfig {
502 #[cfg_attr(feature = "config", serde(flatten))]
504 pub base: BaseScheduleConfig,
505 #[cfg_attr(feature = "config", serde(default))]
508 pub quantiles: Option<Vec<f64>>,
509 pub distribution: DistributionConfig,
511 #[cfg_attr(feature = "config", serde(default))]
513 pub observations_per_tick: Option<u64>,
514 #[cfg_attr(feature = "config", serde(default))]
516 pub mean_shift_per_sec: Option<f64>,
517 #[cfg_attr(feature = "config", serde(default))]
519 pub seed: Option<u64>,
520 #[cfg_attr(feature = "config", serde(default = "default_encoder"))]
522 pub encoder: EncoderConfig,
523}
524
525impl std::ops::Deref for SummaryScenarioConfig {
526 type Target = BaseScheduleConfig;
527
528 fn deref(&self) -> &BaseScheduleConfig {
529 &self.base
530 }
531}
532
533impl std::ops::DerefMut for SummaryScenarioConfig {
534 fn deref_mut(&mut self) -> &mut BaseScheduleConfig {
535 &mut self.base
536 }
537}
538
539#[derive(Debug, Clone)]
546#[cfg_attr(feature = "config", derive(serde::Serialize, serde::Deserialize))]
547#[cfg_attr(feature = "config", serde(tag = "signal_type"))]
548#[non_exhaustive]
549pub enum ScenarioEntry {
550 #[cfg_attr(feature = "config", serde(rename = "metrics"))]
552 Metrics(ScenarioConfig),
553 #[cfg_attr(feature = "config", serde(rename = "logs"))]
555 Logs(LogScenarioConfig),
556 #[cfg_attr(feature = "config", serde(rename = "histogram"))]
558 Histogram(HistogramScenarioConfig),
559 #[cfg_attr(feature = "config", serde(rename = "summary"))]
561 Summary(SummaryScenarioConfig),
562}
563
564impl ScenarioEntry {
565 pub fn base(&self) -> &BaseScheduleConfig {
570 match self {
571 ScenarioEntry::Metrics(c) => &c.base,
572 ScenarioEntry::Logs(c) => &c.base,
573 ScenarioEntry::Histogram(c) => &c.base,
574 ScenarioEntry::Summary(c) => &c.base,
575 }
576 }
577
578 pub fn phase_offset(&self) -> Option<&str> {
580 self.base().phase_offset.as_deref()
581 }
582
583 pub fn clock_group(&self) -> Option<&str> {
585 self.base().clock_group.as_deref()
586 }
587
588 pub fn clock_group_is_auto(&self) -> Option<bool> {
595 self.base().clock_group_is_auto
596 }
597
598 #[allow(unreachable_patterns)]
610 pub fn signal_type_name(&self) -> &'static str {
611 match self {
612 ScenarioEntry::Metrics(_) => "metrics",
613 ScenarioEntry::Logs(_) => "logs",
614 ScenarioEntry::Histogram(_) => "histogram",
615 ScenarioEntry::Summary(_) => "summary",
616 _ => "unknown",
619 }
620 }
621}
622
623fn validate_csv_columns(columns: &Option<Vec<CsvColumnSpec>>) -> Result<(), SondaError> {
637 if let Some(ref cols) = columns {
638 if cols.is_empty() {
639 return Err(SondaError::Config(ConfigError::invalid(
640 "csv_replay: 'columns' must not be empty; provide at least one column spec or omit the field",
641 )));
642 }
643
644 let mut seen_indices = std::collections::HashSet::with_capacity(cols.len());
646 for spec in cols {
647 if !seen_indices.insert(spec.index) {
648 return Err(SondaError::Config(ConfigError::invalid(format!(
649 "csv_replay: duplicate column index {}; each column index must be unique",
650 spec.index
651 ))));
652 }
653 }
654
655 let mut seen_names = std::collections::HashSet::with_capacity(cols.len());
657 for spec in cols {
658 if !seen_names.insert(&spec.name) {
659 return Err(SondaError::Config(ConfigError::invalid(format!(
660 "csv_replay: duplicate column name '{}'; each column name must be unique",
661 spec.name
662 ))));
663 }
664 }
665 }
666 Ok(())
667}
668
669fn read_csv_header(path: &str) -> Result<String, SondaError> {
680 use std::io::BufRead;
681
682 let file = std::fs::File::open(path).map_err(|e| {
683 SondaError::Generator(crate::GeneratorError::FileRead {
684 path: path.to_string(),
685 source: e,
686 })
687 })?;
688 let reader = std::io::BufReader::new(file);
689
690 for line_result in reader.lines() {
691 let line = line_result.map_err(|e| {
692 SondaError::Generator(crate::GeneratorError::FileRead {
693 path: path.to_string(),
694 source: e,
695 })
696 })?;
697 let trimmed = line.trim();
698 if trimmed.is_empty() || trimmed.starts_with('#') {
699 continue;
700 }
701 return Ok(line);
702 }
703
704 Err(SondaError::Config(ConfigError::invalid(format!(
705 "csv_replay: file {:?} has no non-comment, non-empty lines",
706 path
707 ))))
708}
709
710fn is_csv_header_line(line: &str) -> bool {
712 crate::generator::csv_header::is_header_line(line)
713}
714
715pub fn expand_scenario(config: ScenarioConfig) -> Result<Vec<ScenarioConfig>, SondaError> {
747 let specs = match &config.generator {
749 GeneratorConfig::CsvReplay { columns, file, .. } => {
750 validate_csv_columns(columns)?;
751
752 if let Some(ref cols) = columns {
753 cols.clone()
754 } else {
755 let header_line = read_csv_header(file)?;
757
758 if !is_csv_header_line(&header_line) {
759 return Err(SondaError::Config(ConfigError::invalid(
760 "csv_replay: CSV file has no header row (first data line is all numeric); \
761 provide explicit 'columns' in the config",
762 )));
763 }
764
765 let parsed = crate::generator::csv_header::parse_header_row(&header_line)?;
766
767 let mut auto_specs = Vec::with_capacity(parsed.len().saturating_sub(1));
769 for (i, ph) in parsed.into_iter().enumerate().skip(1) {
770 let name = ph.metric_name.ok_or_else(|| {
771 SondaError::Config(ConfigError::invalid(format!(
772 "csv_replay: column {} has no metric name \
773 (header has labels only with no __name__)",
774 i
775 )))
776 })?;
777 let labels = if ph.labels.is_empty() {
778 None
779 } else {
780 Some(ph.labels)
781 };
782 auto_specs.push(CsvColumnSpec {
783 index: i,
784 name,
785 labels,
786 });
787 }
788
789 if auto_specs.is_empty() {
790 return Err(SondaError::Config(ConfigError::invalid(
791 "csv_replay: no data columns found after skipping column 0",
792 )));
793 }
794
795 auto_specs
796 }
797 }
798 _ => return Ok(vec![config]),
799 };
800
801 let expanded = specs
802 .into_iter()
803 .map(|spec| {
804 let mut child = config.clone();
805 child.base.name = spec.name;
806
807 if let Some(ref col_labels) = spec.labels {
809 let merged = child.base.labels.get_or_insert_with(HashMap::new);
810 for (k, v) in col_labels {
811 merged.insert(k.clone(), v.clone());
812 }
813 }
814
815 if let GeneratorConfig::CsvReplay {
817 ref mut column,
818 ref mut columns,
819 ..
820 } = child.generator
821 {
822 *column = Some(spec.index);
823 *columns = None;
824 }
825 child
826 })
827 .collect();
828
829 Ok(expanded)
830}
831
832pub fn expand_entry(entry: ScenarioEntry) -> Result<Vec<ScenarioEntry>, SondaError> {
842 match entry {
843 ScenarioEntry::Metrics(config) => {
844 let expanded = expand_scenario(config)?;
845 Ok(expanded.into_iter().map(ScenarioEntry::Metrics).collect())
846 }
847 other => Ok(vec![other]),
849 }
850}
851
852#[derive(Debug, Clone)]
884#[cfg_attr(feature = "config", derive(serde::Serialize, serde::Deserialize))]
885pub struct LogScenarioConfig {
886 #[cfg_attr(feature = "config", serde(flatten))]
888 pub base: BaseScheduleConfig,
889 pub generator: LogGeneratorConfig,
891 #[cfg_attr(feature = "config", serde(default = "default_log_encoder"))]
893 pub encoder: EncoderConfig,
894}
895
896impl std::ops::Deref for LogScenarioConfig {
897 type Target = BaseScheduleConfig;
898
899 fn deref(&self) -> &BaseScheduleConfig {
900 &self.base
901 }
902}
903
904impl std::ops::DerefMut for LogScenarioConfig {
905 fn deref_mut(&mut self) -> &mut BaseScheduleConfig {
906 &mut self.base
907 }
908}
909
910#[cfg(all(test, feature = "config"))]
911mod tests {
912 use std::collections::BTreeMap;
913
914 use super::*;
915
916 #[test]
922 fn scenario_config_phase_offset_deserializes_from_yaml() {
923 let yaml = r#"
924name: test_metric
925rate: 10
926generator:
927 type: constant
928 value: 1.0
929phase_offset: "5s"
930"#;
931 let config: ScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
932 assert_eq!(config.phase_offset.as_deref(), Some("5s"));
933 }
934
935 #[test]
937 fn scenario_config_phase_offset_defaults_to_none() {
938 let yaml = r#"
939name: test_metric
940rate: 10
941generator:
942 type: constant
943 value: 1.0
944"#;
945 let config: ScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
946 assert!(config.phase_offset.is_none());
947 }
948
949 #[test]
951 fn scenario_config_phase_offset_milliseconds() {
952 let yaml = r#"
953name: ms_test
954rate: 10
955generator:
956 type: constant
957 value: 1.0
958phase_offset: "500ms"
959"#;
960 let config: ScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
961 assert_eq!(config.phase_offset.as_deref(), Some("500ms"));
962 }
963
964 #[test]
966 fn scenario_config_phase_offset_minutes() {
967 let yaml = r#"
968name: min_test
969rate: 10
970generator:
971 type: constant
972 value: 1.0
973phase_offset: "2m"
974"#;
975 let config: ScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
976 assert_eq!(config.phase_offset.as_deref(), Some("2m"));
977 }
978
979 #[test]
985 fn log_scenario_config_phase_offset_deserializes_from_yaml() {
986 let yaml = r#"
987name: log_test
988rate: 10
989generator:
990 type: template
991 templates:
992 - message: "test"
993 field_pools: {}
994phase_offset: "3s"
995"#;
996 let config: LogScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
997 assert_eq!(config.phase_offset.as_deref(), Some("3s"));
998 }
999
1000 #[test]
1002 fn log_scenario_config_phase_offset_defaults_to_none() {
1003 let yaml = r#"
1004name: log_test
1005rate: 10
1006generator:
1007 type: template
1008 templates:
1009 - message: "test"
1010 field_pools: {}
1011"#;
1012 let config: LogScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
1013 assert!(config.phase_offset.is_none());
1014 }
1015
1016 #[test]
1022 fn scenario_config_clock_group_deserializes_from_yaml() {
1023 let yaml = r#"
1024name: group_test
1025rate: 10
1026generator:
1027 type: constant
1028 value: 1.0
1029clock_group: alert-test
1030"#;
1031 let config: ScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
1032 assert_eq!(config.clock_group.as_deref(), Some("alert-test"));
1033 }
1034
1035 #[test]
1037 fn scenario_config_clock_group_defaults_to_none() {
1038 let yaml = r#"
1039name: no_group
1040rate: 10
1041generator:
1042 type: constant
1043 value: 1.0
1044"#;
1045 let config: ScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
1046 assert!(config.clock_group.is_none());
1047 }
1048
1049 #[test]
1051 fn log_scenario_config_clock_group_deserializes_from_yaml() {
1052 let yaml = r#"
1053name: log_group
1054rate: 10
1055generator:
1056 type: template
1057 templates:
1058 - message: "test"
1059 field_pools: {}
1060clock_group: log-sync
1061"#;
1062 let config: LogScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
1063 assert_eq!(config.clock_group.as_deref(), Some("log-sync"));
1064 }
1065
1066 #[test]
1068 fn log_scenario_config_clock_group_defaults_to_none() {
1069 let yaml = r#"
1070name: log_no_group
1071rate: 10
1072generator:
1073 type: template
1074 templates:
1075 - message: "test"
1076 field_pools: {}
1077"#;
1078 let config: LogScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
1079 assert!(config.clock_group.is_none());
1080 }
1081
1082 #[test]
1088 fn scenario_config_jitter_deserializes_from_yaml() {
1089 let yaml = r#"
1090name: jitter_test
1091rate: 10
1092generator:
1093 type: constant
1094 value: 1.0
1095jitter: 3.5
1096jitter_seed: 42
1097"#;
1098 let config: ScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
1099 assert_eq!(config.jitter, Some(3.5));
1100 assert_eq!(config.jitter_seed, Some(42));
1101 }
1102
1103 #[test]
1105 fn scenario_config_jitter_defaults_to_none() {
1106 let yaml = r#"
1107name: no_jitter
1108rate: 10
1109generator:
1110 type: constant
1111 value: 1.0
1112"#;
1113 let config: ScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
1114 assert!(config.jitter.is_none());
1115 assert!(config.jitter_seed.is_none());
1116 }
1117
1118 #[test]
1120 fn scenario_config_jitter_without_seed() {
1121 let yaml = r#"
1122name: jitter_no_seed
1123rate: 10
1124generator:
1125 type: sine
1126 amplitude: 20
1127 period_secs: 60
1128 offset: 50
1129jitter: 5.0
1130"#;
1131 let config: ScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
1132 assert_eq!(config.jitter, Some(5.0));
1133 assert!(config.jitter_seed.is_none());
1134 }
1135
1136 #[test]
1138 fn log_scenario_config_jitter_deserializes_from_yaml() {
1139 let yaml = r#"
1140name: log_jitter
1141rate: 10
1142generator:
1143 type: template
1144 templates:
1145 - message: "test"
1146 field_pools: {}
1147jitter: 2.0
1148jitter_seed: 99
1149"#;
1150 let config: LogScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
1151 assert_eq!(config.jitter, Some(2.0));
1152 assert_eq!(config.jitter_seed, Some(99));
1153 }
1154
1155 #[test]
1161 fn log_scenario_config_labels_deserialize_from_yaml() {
1162 let yaml = r#"
1163name: labeled_logs
1164rate: 10
1165generator:
1166 type: template
1167 templates:
1168 - message: "test"
1169 field_pools: {}
1170labels:
1171 device: wlan0
1172 hostname: router-01
1173"#;
1174 let config: LogScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
1175 let labels = config.labels.as_ref().expect("labels must be Some");
1176 assert_eq!(labels.get("device").map(String::as_str), Some("wlan0"));
1177 assert_eq!(
1178 labels.get("hostname").map(String::as_str),
1179 Some("router-01")
1180 );
1181 assert_eq!(labels.len(), 2);
1182 }
1183
1184 #[test]
1186 fn log_scenario_config_labels_default_to_none() {
1187 let yaml = r#"
1188name: no_labels_logs
1189rate: 10
1190generator:
1191 type: template
1192 templates:
1193 - message: "test"
1194 field_pools: {}
1195"#;
1196 let config: LogScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
1197 assert!(
1198 config.labels.is_none(),
1199 "labels must default to None when not in YAML"
1200 );
1201 }
1202
1203 #[test]
1205 fn log_scenario_config_empty_labels_deserializes_as_some_empty_map() {
1206 let yaml = r#"
1207name: empty_labels
1208rate: 10
1209generator:
1210 type: template
1211 templates:
1212 - message: "test"
1213 field_pools: {}
1214labels: {}
1215"#;
1216 let config: LogScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
1217 let labels = config
1218 .labels
1219 .as_ref()
1220 .expect("labels must be Some for explicit empty map");
1221 assert!(labels.is_empty(), "labels must be an empty map");
1222 }
1223
1224 #[test]
1226 fn scenario_config_labels_deserialize_from_yaml() {
1227 let yaml = r#"
1228name: metric_with_labels
1229rate: 10
1230generator:
1231 type: constant
1232 value: 1.0
1233labels:
1234 zone: eu1
1235 env: production
1236"#;
1237 let config: ScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
1238 let labels = config.labels.as_ref().expect("labels must be Some");
1239 assert_eq!(labels.get("zone").map(String::as_str), Some("eu1"));
1240 assert_eq!(labels.get("env").map(String::as_str), Some("production"));
1241 }
1242
1243 #[test]
1249 fn scenario_config_both_phase_offset_and_clock_group() {
1250 let yaml = r#"
1251name: both_fields
1252rate: 10
1253generator:
1254 type: constant
1255 value: 1.0
1256phase_offset: "30s"
1257clock_group: compound-alert
1258"#;
1259 let config: ScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
1260 assert_eq!(config.phase_offset.as_deref(), Some("30s"));
1261 assert_eq!(config.clock_group.as_deref(), Some("compound-alert"));
1262 }
1263
1264 #[test]
1270 fn scenario_entry_phase_offset_returns_value_for_metrics() {
1271 let entry = ScenarioEntry::Metrics(ScenarioConfig {
1272 base: BaseScheduleConfig {
1273 name: "accessor_test".to_string(),
1274 rate: 10.0,
1275 duration: None,
1276 gaps: None,
1277 bursts: None,
1278 cardinality_spikes: None,
1279 dynamic_labels: None,
1280 labels: None,
1281 sink: SinkConfig::Stdout,
1282 phase_offset: Some("5s".to_string()),
1283 clock_group: None,
1284 clock_group_is_auto: None,
1285 jitter: None,
1286 jitter_seed: None,
1287 on_sink_error: crate::OnSinkError::Warn,
1288 },
1289 generator: GeneratorConfig::Constant { value: 1.0 },
1290 encoder: EncoderConfig::PrometheusText { precision: None },
1291 });
1292 assert_eq!(entry.phase_offset(), Some("5s"));
1293 }
1294
1295 #[test]
1297 fn scenario_entry_phase_offset_returns_none_for_metrics_without_offset() {
1298 let entry = ScenarioEntry::Metrics(ScenarioConfig {
1299 base: BaseScheduleConfig {
1300 name: "no_offset".to_string(),
1301 rate: 10.0,
1302 duration: None,
1303 gaps: None,
1304 bursts: None,
1305 cardinality_spikes: None,
1306 dynamic_labels: None,
1307 labels: None,
1308 sink: SinkConfig::Stdout,
1309 phase_offset: None,
1310 clock_group: None,
1311 clock_group_is_auto: None,
1312 jitter: None,
1313 jitter_seed: None,
1314 on_sink_error: crate::OnSinkError::Warn,
1315 },
1316 generator: GeneratorConfig::Constant { value: 1.0 },
1317 encoder: EncoderConfig::PrometheusText { precision: None },
1318 });
1319 assert_eq!(entry.phase_offset(), None);
1320 }
1321
1322 #[test]
1324 fn scenario_entry_phase_offset_returns_value_for_logs() {
1325 let entry = ScenarioEntry::Logs(LogScenarioConfig {
1326 base: BaseScheduleConfig {
1327 name: "log_accessor".to_string(),
1328 rate: 10.0,
1329 duration: None,
1330 gaps: None,
1331 bursts: None,
1332 cardinality_spikes: None,
1333 dynamic_labels: None,
1334 labels: None,
1335 sink: SinkConfig::Stdout,
1336 phase_offset: Some("10s".to_string()),
1337 clock_group: None,
1338 clock_group_is_auto: None,
1339 jitter: None,
1340 jitter_seed: None,
1341 on_sink_error: crate::OnSinkError::Warn,
1342 },
1343 generator: LogGeneratorConfig::Template {
1344 templates: vec![crate::generator::TemplateConfig {
1345 message: "test".to_string(),
1346 field_pools: BTreeMap::new(),
1347 }],
1348 severity_weights: None,
1349 seed: Some(0),
1350 },
1351 encoder: EncoderConfig::JsonLines { precision: None },
1352 });
1353 assert_eq!(entry.phase_offset(), Some("10s"));
1354 }
1355
1356 #[test]
1362 fn scenario_entry_clock_group_returns_value_for_metrics() {
1363 let entry = ScenarioEntry::Metrics(ScenarioConfig {
1364 base: BaseScheduleConfig {
1365 name: "group_accessor".to_string(),
1366 rate: 10.0,
1367 duration: None,
1368 gaps: None,
1369 bursts: None,
1370 cardinality_spikes: None,
1371 dynamic_labels: None,
1372 labels: None,
1373 sink: SinkConfig::Stdout,
1374 phase_offset: None,
1375 clock_group: Some("my-group".to_string()),
1376 clock_group_is_auto: None,
1377 jitter: None,
1378 jitter_seed: None,
1379 on_sink_error: crate::OnSinkError::Warn,
1380 },
1381 generator: GeneratorConfig::Constant { value: 1.0 },
1382 encoder: EncoderConfig::PrometheusText { precision: None },
1383 });
1384 assert_eq!(entry.clock_group(), Some("my-group"));
1385 }
1386
1387 #[test]
1389 fn scenario_entry_clock_group_returns_none_when_absent() {
1390 let entry = ScenarioEntry::Metrics(ScenarioConfig {
1391 base: BaseScheduleConfig {
1392 name: "no_group_acc".to_string(),
1393 rate: 10.0,
1394 duration: None,
1395 gaps: None,
1396 bursts: None,
1397 cardinality_spikes: None,
1398 dynamic_labels: None,
1399 labels: None,
1400 sink: SinkConfig::Stdout,
1401 phase_offset: None,
1402 clock_group: None,
1403 clock_group_is_auto: None,
1404 jitter: None,
1405 jitter_seed: None,
1406 on_sink_error: crate::OnSinkError::Warn,
1407 },
1408 generator: GeneratorConfig::Constant { value: 1.0 },
1409 encoder: EncoderConfig::PrometheusText { precision: None },
1410 });
1411 assert_eq!(entry.clock_group(), None);
1412 }
1413
1414 #[test]
1420 fn scenario_entry_base_returns_shared_config_for_metrics() {
1421 let entry = ScenarioEntry::Metrics(ScenarioConfig {
1422 base: BaseScheduleConfig {
1423 name: "base_test".to_string(),
1424 rate: 42.0,
1425 duration: Some("5s".to_string()),
1426 gaps: None,
1427 bursts: None,
1428 cardinality_spikes: None,
1429 dynamic_labels: None,
1430 labels: None,
1431 sink: SinkConfig::Stdout,
1432 phase_offset: None,
1433 clock_group: None,
1434 clock_group_is_auto: None,
1435 jitter: None,
1436 jitter_seed: None,
1437 on_sink_error: crate::OnSinkError::Warn,
1438 },
1439 generator: GeneratorConfig::Constant { value: 1.0 },
1440 encoder: EncoderConfig::PrometheusText { precision: None },
1441 });
1442 assert_eq!(entry.base().name, "base_test");
1443 assert_eq!(entry.base().rate, 42.0);
1444 }
1445
1446 #[test]
1448 fn scenario_entry_base_returns_shared_config_for_logs() {
1449 let entry = ScenarioEntry::Logs(LogScenarioConfig {
1450 base: BaseScheduleConfig {
1451 name: "log_base".to_string(),
1452 rate: 99.0,
1453 duration: None,
1454 gaps: None,
1455 bursts: None,
1456 cardinality_spikes: None,
1457 dynamic_labels: None,
1458 labels: None,
1459 sink: SinkConfig::Stdout,
1460 phase_offset: None,
1461 clock_group: None,
1462 clock_group_is_auto: None,
1463 jitter: None,
1464 jitter_seed: None,
1465 on_sink_error: crate::OnSinkError::Warn,
1466 },
1467 generator: LogGeneratorConfig::Template {
1468 templates: vec![crate::generator::TemplateConfig {
1469 message: "test".to_string(),
1470 field_pools: BTreeMap::new(),
1471 }],
1472 severity_weights: None,
1473 seed: Some(0),
1474 },
1475 encoder: EncoderConfig::JsonLines { precision: None },
1476 });
1477 assert_eq!(entry.base().name, "log_base");
1478 assert_eq!(entry.base().rate, 99.0);
1479 }
1480
1481 #[test]
1487 fn phase_offset_values_are_parseable_as_durations() {
1488 use crate::config::validate::parse_duration;
1489
1490 let yaml = r#"
1491name: parse_test
1492rate: 10
1493generator:
1494 type: constant
1495 value: 1.0
1496phase_offset: "3s"
1497"#;
1498 let config: ScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
1499 let dur = parse_duration(config.phase_offset.as_deref().unwrap()).unwrap();
1500 assert_eq!(dur, std::time::Duration::from_secs(3));
1501 }
1502
1503 #[test]
1509 fn scenario_config_cardinality_spikes_deserializes_from_yaml() {
1510 let yaml = r#"
1511name: spike_test
1512rate: 10
1513generator:
1514 type: constant
1515 value: 1.0
1516cardinality_spikes:
1517 - label: pod_name
1518 every: 2m
1519 for: 30s
1520 cardinality: 500
1521 strategy: counter
1522 prefix: "pod-"
1523 - label: error_msg
1524 every: 5m
1525 for: 1m
1526 cardinality: 1000
1527 strategy: random
1528 seed: 42
1529"#;
1530 let config: ScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
1531 let spikes = config
1532 .cardinality_spikes
1533 .as_ref()
1534 .expect("cardinality_spikes must be Some");
1535 assert_eq!(spikes.len(), 2);
1536 assert_eq!(spikes[0].label, "pod_name");
1537 assert_eq!(spikes[0].cardinality, 500);
1538 assert_eq!(spikes[0].strategy, SpikeStrategy::Counter);
1539 assert_eq!(spikes[0].prefix.as_deref(), Some("pod-"));
1540 assert_eq!(spikes[1].label, "error_msg");
1541 assert_eq!(spikes[1].strategy, SpikeStrategy::Random);
1542 assert_eq!(spikes[1].seed, Some(42));
1543 }
1544
1545 #[test]
1547 fn scenario_config_cardinality_spikes_defaults_to_none() {
1548 let yaml = r#"
1549name: no_spike
1550rate: 10
1551generator:
1552 type: constant
1553 value: 1.0
1554"#;
1555 let config: ScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
1556 assert!(
1557 config.cardinality_spikes.is_none(),
1558 "cardinality_spikes must be None when absent from YAML"
1559 );
1560 }
1561
1562 #[test]
1564 fn spike_strategy_defaults_to_counter() {
1565 let yaml = r#"
1566name: default_strategy
1567rate: 10
1568generator:
1569 type: constant
1570 value: 1.0
1571cardinality_spikes:
1572 - label: pod_name
1573 every: 1m
1574 for: 10s
1575 cardinality: 10
1576"#;
1577 let config: ScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
1578 let spikes = config.base.cardinality_spikes.unwrap();
1579 assert_eq!(spikes[0].strategy, SpikeStrategy::Counter);
1580 }
1581
1582 #[test]
1584 fn log_scenario_config_cardinality_spikes_deserializes() {
1585 let yaml = r#"
1586name: log_spike
1587rate: 10
1588generator:
1589 type: template
1590 templates:
1591 - message: "test"
1592 field_pools: {}
1593cardinality_spikes:
1594 - label: pod_name
1595 every: 1m
1596 for: 10s
1597 cardinality: 100
1598"#;
1599 let config: LogScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
1600 let spikes = config.base.cardinality_spikes.unwrap();
1601 assert_eq!(spikes.len(), 1);
1602 assert_eq!(spikes[0].label, "pod_name");
1603 }
1604
1605 #[test]
1607 fn backward_compatible_yaml_without_spikes() {
1608 let yaml = r#"
1609name: compat_test
1610rate: 100
1611generator:
1612 type: sine
1613 amplitude: 5.0
1614 period_secs: 30
1615 offset: 10.0
1616labels:
1617 hostname: t0-a1
1618gaps:
1619 every: 2m
1620 for: 20s
1621"#;
1622 let config: ScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
1623 assert!(config.cardinality_spikes.is_none());
1624 assert!(config.gaps.is_some());
1625 assert_eq!(config.name, "compat_test");
1626 }
1627
1628 #[test]
1634 fn base_schedule_config_is_clone_and_debug() {
1635 let base = BaseScheduleConfig {
1636 name: "test".to_string(),
1637 rate: 42.0,
1638 duration: Some("10s".to_string()),
1639 gaps: None,
1640 bursts: None,
1641 cardinality_spikes: None,
1642 dynamic_labels: None,
1643 labels: None,
1644 sink: SinkConfig::Stdout,
1645 phase_offset: None,
1646 clock_group: None,
1647 clock_group_is_auto: None,
1648 jitter: None,
1649 jitter_seed: None,
1650 on_sink_error: crate::OnSinkError::Warn,
1651 };
1652 let cloned = base.clone();
1653 assert_eq!(cloned.name, "test");
1654 assert_eq!(cloned.rate, 42.0);
1655 let dbg = format!("{base:?}");
1656 assert!(
1657 dbg.contains("BaseScheduleConfig"),
1658 "Debug output must contain type name"
1659 );
1660 }
1661
1662 #[test]
1668 fn scenario_config_deref_accesses_base_fields() {
1669 let config = ScenarioConfig {
1670 base: BaseScheduleConfig {
1671 name: "deref_test".to_string(),
1672 rate: 99.0,
1673 duration: Some("5s".to_string()),
1674 gaps: None,
1675 bursts: None,
1676 cardinality_spikes: None,
1677 dynamic_labels: None,
1678 labels: None,
1679 sink: SinkConfig::Stdout,
1680 phase_offset: Some("1s".to_string()),
1681 clock_group: Some("group-a".to_string()),
1682 clock_group_is_auto: None,
1683 jitter: None,
1684 jitter_seed: None,
1685 on_sink_error: crate::OnSinkError::Warn,
1686 },
1687 generator: GeneratorConfig::Constant { value: 1.0 },
1688 encoder: EncoderConfig::PrometheusText { precision: None },
1689 };
1690 assert_eq!(config.name, "deref_test");
1692 assert_eq!(config.rate, 99.0);
1693 assert_eq!(config.duration.as_deref(), Some("5s"));
1694 assert!(config.gaps.is_none());
1695 assert_eq!(config.phase_offset.as_deref(), Some("1s"));
1696 assert_eq!(config.clock_group.as_deref(), Some("group-a"));
1697 }
1698
1699 #[test]
1701 fn log_scenario_config_deref_accesses_base_fields() {
1702 let config = LogScenarioConfig {
1703 base: BaseScheduleConfig {
1704 name: "log_deref".to_string(),
1705 rate: 50.0,
1706 duration: None,
1707 gaps: None,
1708 bursts: None,
1709 cardinality_spikes: None,
1710 dynamic_labels: None,
1711 labels: None,
1712 sink: SinkConfig::Stdout,
1713 phase_offset: None,
1714 clock_group: None,
1715 clock_group_is_auto: None,
1716 jitter: None,
1717 jitter_seed: None,
1718 on_sink_error: crate::OnSinkError::Warn,
1719 },
1720 generator: LogGeneratorConfig::Template {
1721 templates: vec![crate::generator::TemplateConfig {
1722 message: "test".to_string(),
1723 field_pools: BTreeMap::new(),
1724 }],
1725 severity_weights: None,
1726 seed: Some(0),
1727 },
1728 encoder: EncoderConfig::JsonLines { precision: None },
1729 };
1730 assert_eq!(config.name, "log_deref");
1731 assert_eq!(config.rate, 50.0);
1732 assert!(config.duration.is_none());
1733 }
1734
1735 #[test]
1741 fn scenario_config_deref_mut_allows_base_field_mutation() {
1742 let mut config = ScenarioConfig {
1743 base: BaseScheduleConfig {
1744 name: "original".to_string(),
1745 rate: 10.0,
1746 duration: None,
1747 gaps: None,
1748 bursts: None,
1749 cardinality_spikes: None,
1750 dynamic_labels: None,
1751 labels: None,
1752 sink: SinkConfig::Stdout,
1753 phase_offset: None,
1754 clock_group: None,
1755 clock_group_is_auto: None,
1756 jitter: None,
1757 jitter_seed: None,
1758 on_sink_error: crate::OnSinkError::Warn,
1759 },
1760 generator: GeneratorConfig::Constant { value: 1.0 },
1761 encoder: EncoderConfig::PrometheusText { precision: None },
1762 };
1763 config.name = "mutated".to_string();
1764 config.rate = 999.0;
1765 config.duration = Some("30s".to_string());
1766 assert_eq!(config.name, "mutated");
1767 assert_eq!(config.rate, 999.0);
1768 assert_eq!(config.duration.as_deref(), Some("30s"));
1769 }
1770
1771 #[test]
1777 fn scenario_config_flatten_deserializes_all_fields() {
1778 let yaml = r#"
1779name: flatten_test
1780rate: 100
1781duration: 30s
1782generator:
1783 type: sine
1784 amplitude: 5.0
1785 period_secs: 30
1786 offset: 10.0
1787gaps:
1788 every: 2m
1789 for: 20s
1790bursts:
1791 every: 10s
1792 for: 2s
1793 multiplier: 5.0
1794labels:
1795 hostname: t0-a1
1796 zone: eu1
1797encoder:
1798 type: prometheus_text
1799sink:
1800 type: stdout
1801phase_offset: "5s"
1802clock_group: correlation
1803"#;
1804 let config: ScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
1805 assert_eq!(config.name, "flatten_test");
1806 assert_eq!(config.rate, 100.0);
1807 assert_eq!(config.duration.as_deref(), Some("30s"));
1808 assert!(config.gaps.is_some());
1809 assert!(config.bursts.is_some());
1810 let labels = config.labels.as_ref().unwrap();
1811 assert_eq!(labels.get("hostname").map(String::as_str), Some("t0-a1"));
1812 assert!(matches!(
1813 config.encoder,
1814 EncoderConfig::PrometheusText { .. }
1815 ));
1816 assert!(matches!(config.base.sink, SinkConfig::Stdout));
1817 assert_eq!(config.phase_offset.as_deref(), Some("5s"));
1818 assert_eq!(config.clock_group.as_deref(), Some("correlation"));
1819 }
1820
1821 #[test]
1823 fn log_scenario_config_flatten_deserializes_all_fields() {
1824 let yaml = r#"
1825name: log_flatten
1826rate: 20
1827duration: 60s
1828generator:
1829 type: template
1830 templates:
1831 - message: "hello"
1832 field_pools: {}
1833labels:
1834 env: prod
1835encoder:
1836 type: syslog
1837 hostname: myhost
1838 app_name: myapp
1839sink:
1840 type: stdout
1841phase_offset: "2s"
1842clock_group: log-group
1843"#;
1844 let config: LogScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
1845 assert_eq!(config.name, "log_flatten");
1846 assert_eq!(config.rate, 20.0);
1847 assert_eq!(config.duration.as_deref(), Some("60s"));
1848 let labels = config.labels.as_ref().unwrap();
1849 assert_eq!(labels.get("env").map(String::as_str), Some("prod"));
1850 assert_eq!(config.phase_offset.as_deref(), Some("2s"));
1851 assert_eq!(config.clock_group.as_deref(), Some("log-group"));
1852 }
1853
1854 #[test]
1860 fn scenario_config_encoder_defaults_to_prometheus_text() {
1861 let yaml = r#"
1862name: enc_default
1863rate: 10
1864generator:
1865 type: constant
1866 value: 1.0
1867"#;
1868 let config: ScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
1869 assert!(
1870 matches!(config.encoder, EncoderConfig::PrometheusText { .. }),
1871 "ScenarioConfig encoder default must be prometheus_text, got {:?}",
1872 config.encoder
1873 );
1874 }
1875
1876 #[test]
1878 fn log_scenario_config_encoder_defaults_to_json_lines() {
1879 let yaml = r#"
1880name: log_enc_default
1881rate: 10
1882generator:
1883 type: template
1884 templates:
1885 - message: "test"
1886 field_pools: {}
1887"#;
1888 let config: LogScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
1889 assert!(
1890 matches!(config.encoder, EncoderConfig::JsonLines { .. }),
1891 "LogScenarioConfig encoder default must be json_lines, got {:?}",
1892 config.encoder
1893 );
1894 }
1895
1896 #[test]
1902 fn dynamic_labels_counter_deserializes_from_yaml() {
1903 let yaml = r#"
1904name: test
1905rate: 10
1906generator:
1907 type: constant
1908 value: 1.0
1909dynamic_labels:
1910 - key: hostname
1911 prefix: "host-"
1912 cardinality: 10
1913"#;
1914 let config: ScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
1915 let dls = config
1916 .dynamic_labels
1917 .as_ref()
1918 .expect("dynamic_labels must be present");
1919 assert_eq!(dls.len(), 1);
1920 assert_eq!(dls[0].key, "hostname");
1921 match &dls[0].strategy {
1922 DynamicLabelStrategy::Counter {
1923 prefix,
1924 cardinality,
1925 } => {
1926 assert_eq!(prefix.as_deref(), Some("host-"));
1927 assert_eq!(*cardinality, 10);
1928 }
1929 other => panic!("expected Counter strategy, got {other:?}"),
1930 }
1931 }
1932
1933 #[test]
1935 fn dynamic_labels_values_list_deserializes_from_yaml() {
1936 let yaml = r#"
1937name: test
1938rate: 10
1939generator:
1940 type: constant
1941 value: 1.0
1942dynamic_labels:
1943 - key: region
1944 values: [us-east-1, us-west-2, eu-west-1]
1945"#;
1946 let config: ScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
1947 let dls = config
1948 .dynamic_labels
1949 .as_ref()
1950 .expect("dynamic_labels must be present");
1951 assert_eq!(dls.len(), 1);
1952 assert_eq!(dls[0].key, "region");
1953 match &dls[0].strategy {
1954 DynamicLabelStrategy::ValuesList { values } => {
1955 assert_eq!(values, &["us-east-1", "us-west-2", "eu-west-1"]);
1956 }
1957 other => panic!("expected ValuesList strategy, got {other:?}"),
1958 }
1959 }
1960
1961 #[test]
1963 fn dynamic_labels_defaults_to_none() {
1964 let yaml = r#"
1965name: test
1966rate: 10
1967generator:
1968 type: constant
1969 value: 1.0
1970"#;
1971 let config: ScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
1972 assert!(config.dynamic_labels.is_none());
1973 }
1974
1975 #[test]
1977 fn dynamic_labels_multiple_entries_deserialize() {
1978 let yaml = r#"
1979name: test
1980rate: 10
1981generator:
1982 type: constant
1983 value: 1.0
1984dynamic_labels:
1985 - key: hostname
1986 prefix: "host-"
1987 cardinality: 10
1988 - key: region
1989 values: [us-east, eu-west]
1990"#;
1991 let config: ScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
1992 let dls = config
1993 .dynamic_labels
1994 .as_ref()
1995 .expect("dynamic_labels must be present");
1996 assert_eq!(dls.len(), 2);
1997 assert_eq!(dls[0].key, "hostname");
1998 assert_eq!(dls[1].key, "region");
1999 }
2000
2001 #[test]
2003 fn dynamic_labels_on_log_config_deserializes() {
2004 let yaml = r#"
2005name: test_logs
2006rate: 10
2007generator:
2008 type: template
2009 templates:
2010 - message: "test event"
2011 field_pools: {}
2012dynamic_labels:
2013 - key: pod_name
2014 prefix: "pod-"
2015 cardinality: 5
2016"#;
2017 let config: LogScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
2018 let dls = config
2019 .dynamic_labels
2020 .as_ref()
2021 .expect("dynamic_labels must be present");
2022 assert_eq!(dls.len(), 1);
2023 assert_eq!(dls[0].key, "pod_name");
2024 }
2025
2026 #[test]
2028 fn dynamic_labels_counter_no_prefix_deserializes() {
2029 let yaml = r#"
2030name: test
2031rate: 10
2032generator:
2033 type: constant
2034 value: 1.0
2035dynamic_labels:
2036 - key: zone
2037 cardinality: 3
2038"#;
2039 let config: ScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
2040 let dls = config
2041 .dynamic_labels
2042 .as_ref()
2043 .expect("dynamic_labels must be present");
2044 match &dls[0].strategy {
2045 DynamicLabelStrategy::Counter {
2046 prefix,
2047 cardinality,
2048 } => {
2049 assert!(prefix.is_none(), "prefix should be None when not specified");
2050 assert_eq!(*cardinality, 3);
2051 }
2052 other => panic!("expected Counter strategy, got {other:?}"),
2053 }
2054 }
2055
2056 #[test]
2058 fn dynamic_labels_and_static_labels_coexist() {
2059 let yaml = r#"
2060name: test
2061rate: 10
2062generator:
2063 type: constant
2064 value: 1.0
2065labels:
2066 env: prod
2067dynamic_labels:
2068 - key: hostname
2069 prefix: "host-"
2070 cardinality: 5
2071"#;
2072 let config: ScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
2073 assert!(config.labels.is_some(), "static labels must be present");
2074 assert!(
2075 config.dynamic_labels.is_some(),
2076 "dynamic labels must be present"
2077 );
2078 let static_labels = config.labels.as_ref().unwrap();
2079 assert_eq!(static_labels.get("env"), Some(&"prod".to_string()));
2080 }
2081
2082 #[test]
2088 fn csv_replay_columns_deserializes_from_yaml() {
2089 let yaml = r#"
2090name: multi_col
2091rate: 1
2092generator:
2093 type: csv_replay
2094 file: data.csv
2095 columns:
2096 - index: 1
2097 name: cpu_percent
2098 - index: 2
2099 name: mem_percent
2100"#;
2101 let config: ScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
2102 match &config.generator {
2103 GeneratorConfig::CsvReplay {
2104 columns, column, ..
2105 } => {
2106 assert!(column.is_none(), "column is serde(skip), should be None");
2107 let cols = columns.as_ref().expect("columns should be Some");
2108 assert_eq!(cols.len(), 2);
2109 assert_eq!(cols[0].index, 1);
2110 assert_eq!(cols[0].name, "cpu_percent");
2111 assert_eq!(cols[1].index, 2);
2112 assert_eq!(cols[1].name, "mem_percent");
2113 }
2114 other => panic!("expected CsvReplay variant, got {other:?}"),
2115 }
2116 }
2117
2118 #[test]
2120 fn csv_replay_without_columns_field_has_none() {
2121 let yaml = r#"
2122name: single_col
2123rate: 1
2124generator:
2125 type: csv_replay
2126 file: data.csv
2127"#;
2128 let config: ScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
2129 match &config.generator {
2130 GeneratorConfig::CsvReplay {
2131 columns, column, ..
2132 } => {
2133 assert_eq!(*column, None, "column is serde(skip)");
2134 assert!(
2135 columns.is_none(),
2136 "columns should be None when not specified"
2137 );
2138 }
2139 other => panic!("expected CsvReplay variant, got {other:?}"),
2140 }
2141 }
2142
2143 #[test]
2150 fn scenario_entry_signal_type_name_covers_all_variants() {
2151 let metrics_yaml = r#"
2153signal_type: metrics
2154name: cpu
2155rate: 1
2156generator:
2157 type: constant
2158 value: 1.0
2159"#;
2160 let metrics: ScenarioEntry = serde_yaml_ng::from_str(metrics_yaml).unwrap();
2161 assert_eq!(metrics.signal_type_name(), "metrics");
2162
2163 let logs_yaml = r#"
2165signal_type: logs
2166name: app_logs
2167rate: 1
2168generator:
2169 type: replay
2170 file: /tmp/does-not-need-to-exist.log
2171"#;
2172 let logs: ScenarioEntry = serde_yaml_ng::from_str(logs_yaml).unwrap();
2173 assert_eq!(logs.signal_type_name(), "logs");
2174
2175 let histogram_yaml = r#"
2177signal_type: histogram
2178name: req_latency
2179rate: 1
2180observations_per_tick: 100
2181buckets: [0.1, 0.5, 1.0]
2182distribution:
2183 type: uniform
2184 min: 0.0
2185 max: 1.0
2186"#;
2187 let histogram: ScenarioEntry = serde_yaml_ng::from_str(histogram_yaml).unwrap();
2188 assert_eq!(histogram.signal_type_name(), "histogram");
2189
2190 let summary_yaml = r#"
2192signal_type: summary
2193name: req_latency_summary
2194rate: 1
2195observations_per_tick: 100
2196quantiles: [0.5, 0.9, 0.99]
2197distribution:
2198 type: uniform
2199 min: 0.0
2200 max: 1.0
2201"#;
2202 let summary: ScenarioEntry = serde_yaml_ng::from_str(summary_yaml).unwrap();
2203 assert_eq!(summary.signal_type_name(), "summary");
2204 }
2205}
2206
2207#[cfg(test)]
2208mod expand_tests {
2209 use super::*;
2210 use crate::encoder::EncoderConfig;
2211 use crate::generator::{CsvColumnSpec, GeneratorConfig};
2212 use crate::sink::SinkConfig;
2213
2214 fn csv_replay_config(name: &str, columns: Option<Vec<CsvColumnSpec>>) -> ScenarioConfig {
2216 ScenarioConfig {
2217 base: BaseScheduleConfig {
2218 name: name.to_string(),
2219 rate: 10.0,
2220 duration: Some("30s".to_string()),
2221 gaps: None,
2222 bursts: None,
2223 cardinality_spikes: None,
2224 labels: Some([("host".to_string(), "srv1".to_string())].into()),
2225 sink: SinkConfig::Stdout,
2226 phase_offset: None,
2227 clock_group: None,
2228 clock_group_is_auto: None,
2229 jitter: Some(0.5),
2230 jitter_seed: Some(42),
2231 dynamic_labels: None,
2232 on_sink_error: crate::OnSinkError::Warn,
2233 },
2234 generator: GeneratorConfig::CsvReplay {
2235 file: "data.csv".to_string(),
2236 column: None,
2237 repeat: Some(true),
2238 columns,
2239 },
2240 encoder: EncoderConfig::PrometheusText { precision: None },
2241 }
2242 }
2243
2244 #[test]
2251 fn auto_discover_from_header_when_no_columns() {
2252 use std::io::Write;
2253 let mut tmp = tempfile::NamedTempFile::new().expect("create temp file");
2254 write!(tmp, "Time,cpu_usage\n1000,42.5\n").expect("write csv");
2255 tmp.flush().expect("flush");
2256 let path = tmp.path().to_string_lossy().into_owned();
2257
2258 let mut config = csv_replay_config("single_metric", None);
2259 if let GeneratorConfig::CsvReplay { ref mut file, .. } = config.generator {
2260 *file = path;
2261 }
2262 let result = expand_scenario(config).expect("must succeed");
2263 assert_eq!(result.len(), 1, "should auto-discover 1 data column");
2264 assert_eq!(result[0].name, "cpu_usage");
2265
2266 drop(tmp);
2267 }
2268
2269 #[test]
2272 fn no_columns_no_header_returns_error() {
2273 use std::io::Write;
2274 let mut tmp = tempfile::NamedTempFile::new().expect("create temp file");
2275 write!(tmp, "1000,42.5\n2000,55.3\n").expect("write csv");
2276 tmp.flush().expect("flush");
2277 let path = tmp.path().to_string_lossy().into_owned();
2278
2279 let mut config = csv_replay_config("all_numeric", None);
2280 if let GeneratorConfig::CsvReplay { ref mut file, .. } = config.generator {
2281 *file = path;
2282 }
2283 let err = expand_scenario(config).expect_err("must fail");
2284 let msg = err.to_string();
2285 assert!(
2286 msg.contains("no header row"),
2287 "error must mention no header row, got: {msg}"
2288 );
2289
2290 drop(tmp);
2291 }
2292
2293 #[test]
2295 fn non_csv_replay_passes_through() {
2296 let config = ScenarioConfig {
2297 base: BaseScheduleConfig {
2298 name: "const_metric".to_string(),
2299 rate: 1.0,
2300 duration: None,
2301 gaps: None,
2302 bursts: None,
2303 cardinality_spikes: None,
2304 labels: None,
2305 sink: SinkConfig::Stdout,
2306 phase_offset: None,
2307 clock_group: None,
2308 clock_group_is_auto: None,
2309 jitter: None,
2310 jitter_seed: None,
2311 dynamic_labels: None,
2312 on_sink_error: crate::OnSinkError::Warn,
2313 },
2314 generator: GeneratorConfig::Constant { value: 42.0 },
2315 encoder: EncoderConfig::PrometheusText { precision: None },
2316 };
2317 let result = expand_scenario(config).expect("must succeed");
2318 assert_eq!(result.len(), 1);
2319 assert_eq!(result[0].name, "const_metric");
2320 }
2321
2322 #[test]
2328 fn two_column_expansion() {
2329 let cols = vec![
2330 CsvColumnSpec {
2331 index: 1,
2332 name: "cpu_percent".to_string(),
2333 labels: None,
2334 },
2335 CsvColumnSpec {
2336 index: 2,
2337 name: "mem_percent".to_string(),
2338 labels: None,
2339 },
2340 ];
2341 let config = csv_replay_config("parent", Some(cols));
2342 let result = expand_scenario(config).expect("must succeed");
2343
2344 assert_eq!(result.len(), 2, "should produce two expanded configs");
2345
2346 assert_eq!(result[0].name, "cpu_percent");
2348 match &result[0].generator {
2349 GeneratorConfig::CsvReplay {
2350 column,
2351 columns,
2352 file,
2353 repeat,
2354 } => {
2355 assert_eq!(*column, Some(1));
2356 assert!(columns.is_none(), "columns must be None after expansion");
2357 assert_eq!(file, "data.csv", "file must be inherited");
2358 assert_eq!(*repeat, Some(true), "repeat must be inherited");
2359 }
2360 other => panic!("expected CsvReplay, got {other:?}"),
2361 }
2362
2363 assert_eq!(result[1].name, "mem_percent");
2365 match &result[1].generator {
2366 GeneratorConfig::CsvReplay {
2367 column, columns, ..
2368 } => {
2369 assert_eq!(*column, Some(2));
2370 assert!(columns.is_none());
2371 }
2372 other => panic!("expected CsvReplay, got {other:?}"),
2373 }
2374 }
2375
2376 #[test]
2382 fn three_column_expansion() {
2383 let cols = vec![
2384 CsvColumnSpec {
2385 index: 1,
2386 name: "cpu".to_string(),
2387 labels: None,
2388 },
2389 CsvColumnSpec {
2390 index: 2,
2391 name: "mem".to_string(),
2392 labels: None,
2393 },
2394 CsvColumnSpec {
2395 index: 3,
2396 name: "disk_io".to_string(),
2397 labels: None,
2398 },
2399 ];
2400 let config = csv_replay_config("parent", Some(cols));
2401 let result = expand_scenario(config).expect("must succeed");
2402
2403 assert_eq!(result.len(), 3);
2404 assert_eq!(result[0].name, "cpu");
2405 assert_eq!(result[1].name, "mem");
2406 assert_eq!(result[2].name, "disk_io");
2407
2408 for (i, expected_col) in [(0, 1), (1, 2), (2, 3)] {
2410 match &result[i].generator {
2411 GeneratorConfig::CsvReplay { column, .. } => {
2412 assert_eq!(*column, Some(expected_col), "config[{i}] column");
2413 }
2414 other => panic!("expected CsvReplay, got {other:?}"),
2415 }
2416 }
2417 }
2418
2419 #[test]
2425 fn expanded_configs_inherit_parent_fields() {
2426 let cols = vec![CsvColumnSpec {
2427 index: 1,
2428 name: "metric_a".to_string(),
2429 labels: None,
2430 }];
2431 let config = csv_replay_config("parent", Some(cols));
2432 let result = expand_scenario(config).expect("must succeed");
2433
2434 assert_eq!(result.len(), 1);
2435 let child = &result[0];
2436
2437 assert_eq!(child.rate, 10.0, "rate must be inherited");
2439 assert_eq!(
2440 child.duration.as_deref(),
2441 Some("30s"),
2442 "duration must be inherited"
2443 );
2444
2445 let labels = child.labels.as_ref().expect("labels must be inherited");
2447 assert_eq!(labels.get("host").map(|s| s.as_str()), Some("srv1"));
2448
2449 assert_eq!(child.jitter, Some(0.5));
2451 assert_eq!(child.jitter_seed, Some(42));
2452
2453 assert!(matches!(
2455 child.encoder,
2456 EncoderConfig::PrometheusText { .. }
2457 ));
2458 assert!(matches!(child.sink, SinkConfig::Stdout));
2459 }
2460
2461 #[test]
2463 fn expanded_configs_inherit_non_none_gaps_and_bursts() {
2464 let cols = vec![CsvColumnSpec {
2465 index: 1,
2466 name: "metric_a".to_string(),
2467 labels: None,
2468 }];
2469 let mut config = csv_replay_config("parent", Some(cols));
2470 config.base.gaps = Some(GapConfig {
2471 every: "2m".to_string(),
2472 r#for: "20s".to_string(),
2473 });
2474 config.base.bursts = Some(BurstConfig {
2475 every: "10s".to_string(),
2476 r#for: "2s".to_string(),
2477 multiplier: 3.0,
2478 });
2479 let result = expand_scenario(config).expect("must succeed");
2480 assert_eq!(result.len(), 1);
2481 let child = &result[0];
2482
2483 let gaps = child.gaps.as_ref().expect("gaps must be inherited");
2484 assert_eq!(gaps.every, "2m");
2485 assert_eq!(gaps.r#for, "20s");
2486
2487 let bursts = child.bursts.as_ref().expect("bursts must be inherited");
2488 assert_eq!(bursts.every, "10s");
2489 assert_eq!(bursts.r#for, "2s");
2490 assert_eq!(bursts.multiplier, 3.0);
2491 }
2492
2493 #[test]
2499 fn empty_columns_list_returns_error() {
2500 let config = csv_replay_config("empty", Some(vec![]));
2501 let err = expand_scenario(config).expect_err("must fail");
2502 let msg = err.to_string();
2503 assert!(
2504 msg.contains("must not be empty"),
2505 "error must mention empty list, got: {msg}"
2506 );
2507 }
2508
2509 #[test]
2515 fn duplicate_column_index_returns_error() {
2516 let cols = vec![
2517 CsvColumnSpec {
2518 index: 2,
2519 name: "cpu".to_string(),
2520 labels: None,
2521 },
2522 CsvColumnSpec {
2523 index: 2,
2524 name: "mem".to_string(),
2525 labels: None,
2526 },
2527 ];
2528 let config = csv_replay_config("dupe_idx", Some(cols));
2529 let err = expand_scenario(config).expect_err("must fail");
2530 let msg = err.to_string();
2531 assert!(
2532 msg.contains("duplicate column index 2"),
2533 "error must mention duplicate index, got: {msg}"
2534 );
2535 }
2536
2537 #[test]
2539 fn duplicate_column_index_not_first_returns_error() {
2540 let cols = vec![
2541 CsvColumnSpec {
2542 index: 1,
2543 name: "cpu".to_string(),
2544 labels: None,
2545 },
2546 CsvColumnSpec {
2547 index: 3,
2548 name: "mem".to_string(),
2549 labels: None,
2550 },
2551 CsvColumnSpec {
2552 index: 3,
2553 name: "disk".to_string(),
2554 labels: None,
2555 },
2556 ];
2557 let config = csv_replay_config("dupe_idx_late", Some(cols));
2558 let err = expand_scenario(config).expect_err("must fail");
2559 let msg = err.to_string();
2560 assert!(
2561 msg.contains("duplicate column index 3"),
2562 "error must mention duplicate index, got: {msg}"
2563 );
2564 }
2565
2566 #[test]
2572 fn duplicate_column_name_returns_error() {
2573 let cols = vec![
2574 CsvColumnSpec {
2575 index: 1,
2576 name: "cpu".to_string(),
2577 labels: None,
2578 },
2579 CsvColumnSpec {
2580 index: 2,
2581 name: "cpu".to_string(),
2582 labels: None,
2583 },
2584 ];
2585 let config = csv_replay_config("dupe_name", Some(cols));
2586 let err = expand_scenario(config).expect_err("must fail");
2587 let msg = err.to_string();
2588 assert!(
2589 msg.contains("duplicate column name 'cpu'"),
2590 "error must mention duplicate name, got: {msg}"
2591 );
2592 }
2593
2594 #[test]
2596 fn duplicate_column_name_not_first_returns_error() {
2597 let cols = vec![
2598 CsvColumnSpec {
2599 index: 1,
2600 name: "cpu".to_string(),
2601 labels: None,
2602 },
2603 CsvColumnSpec {
2604 index: 2,
2605 name: "mem".to_string(),
2606 labels: None,
2607 },
2608 CsvColumnSpec {
2609 index: 3,
2610 name: "mem".to_string(),
2611 labels: None,
2612 },
2613 ];
2614 let config = csv_replay_config("dupe_name_late", Some(cols));
2615 let err = expand_scenario(config).expect_err("must fail");
2616 let msg = err.to_string();
2617 assert!(
2618 msg.contains("duplicate column name 'mem'"),
2619 "error must mention duplicate name, got: {msg}"
2620 );
2621 }
2622
2623 #[test]
2629 fn expand_entry_metrics_two_columns() {
2630 let cols = vec![
2631 CsvColumnSpec {
2632 index: 1,
2633 name: "cpu".to_string(),
2634 labels: None,
2635 },
2636 CsvColumnSpec {
2637 index: 2,
2638 name: "mem".to_string(),
2639 labels: None,
2640 },
2641 ];
2642 let config = csv_replay_config("parent", Some(cols));
2643 let entry = ScenarioEntry::Metrics(config);
2644 let result = expand_entry(entry).expect("must succeed");
2645
2646 assert_eq!(result.len(), 2);
2647 assert!(matches!(result[0], ScenarioEntry::Metrics(_)));
2648 assert!(matches!(result[1], ScenarioEntry::Metrics(_)));
2649 }
2650
2651 #[test]
2653 fn expand_entry_logs_passes_through() {
2654 use crate::generator::{LogGeneratorConfig, TemplateConfig};
2655 use std::collections::BTreeMap;
2656
2657 let entry = ScenarioEntry::Logs(LogScenarioConfig {
2658 base: BaseScheduleConfig {
2659 name: "app_logs".to_string(),
2660 rate: 10.0,
2661 duration: None,
2662 gaps: None,
2663 bursts: None,
2664 cardinality_spikes: None,
2665 labels: None,
2666 sink: SinkConfig::Stdout,
2667 phase_offset: None,
2668 clock_group: None,
2669 clock_group_is_auto: None,
2670 jitter: None,
2671 jitter_seed: None,
2672 dynamic_labels: None,
2673 on_sink_error: crate::OnSinkError::Warn,
2674 },
2675 generator: LogGeneratorConfig::Template {
2676 templates: vec![TemplateConfig {
2677 message: "test".to_string(),
2678 field_pools: BTreeMap::new(),
2679 }],
2680 severity_weights: None,
2681 seed: Some(0),
2682 },
2683 encoder: EncoderConfig::JsonLines { precision: None },
2684 });
2685 let result = expand_entry(entry).expect("must succeed");
2686 assert_eq!(result.len(), 1);
2687 assert!(matches!(result[0], ScenarioEntry::Logs(_)));
2688 }
2689
2690 #[test]
2696 fn per_column_labels_merge_into_child() {
2697 let cols = vec![
2698 CsvColumnSpec {
2699 index: 1,
2700 name: "cpu".to_string(),
2701 labels: Some(
2702 [("instance".to_string(), "host1".to_string())]
2703 .into_iter()
2704 .collect(),
2705 ),
2706 },
2707 CsvColumnSpec {
2708 index: 2,
2709 name: "mem".to_string(),
2710 labels: Some(
2711 [("instance".to_string(), "host2".to_string())]
2712 .into_iter()
2713 .collect(),
2714 ),
2715 },
2716 ];
2717 let config = csv_replay_config("parent", Some(cols));
2718 let result = expand_scenario(config).expect("must succeed");
2719
2720 assert_eq!(result.len(), 2);
2721
2722 let labels0 = result[0].labels.as_ref().expect("labels must exist");
2724 assert_eq!(labels0.get("instance").map(|s| s.as_str()), Some("host1"));
2725 assert_eq!(labels0.get("host").map(|s| s.as_str()), Some("srv1"));
2726
2727 let labels1 = result[1].labels.as_ref().expect("labels must exist");
2729 assert_eq!(labels1.get("instance").map(|s| s.as_str()), Some("host2"));
2730 assert_eq!(labels1.get("host").map(|s| s.as_str()), Some("srv1"));
2731 }
2732
2733 #[test]
2735 fn per_column_labels_override_scenario_level_on_conflict() {
2736 let cols = vec![CsvColumnSpec {
2737 index: 1,
2738 name: "cpu".to_string(),
2739 labels: Some(
2740 [("host".to_string(), "override-host".to_string())]
2741 .into_iter()
2742 .collect(),
2743 ),
2744 }];
2745 let config = csv_replay_config("parent", Some(cols));
2746 let result = expand_scenario(config).expect("must succeed");
2747
2748 assert_eq!(result.len(), 1);
2749 let labels = result[0].labels.as_ref().expect("labels must exist");
2750 assert_eq!(
2751 labels.get("host").map(|s| s.as_str()),
2752 Some("override-host"),
2753 "column labels must override scenario-level labels"
2754 );
2755 }
2756
2757 #[test]
2759 fn columns_without_labels_preserve_scenario_labels() {
2760 let cols = vec![CsvColumnSpec {
2761 index: 1,
2762 name: "cpu".to_string(),
2763 labels: None,
2764 }];
2765 let config = csv_replay_config("parent", Some(cols));
2766 let result = expand_scenario(config).expect("must succeed");
2767
2768 assert_eq!(result.len(), 1);
2769 let labels = result[0].labels.as_ref().expect("labels must exist");
2770 assert_eq!(
2771 labels.get("host").map(|s| s.as_str()),
2772 Some("srv1"),
2773 "scenario-level labels must be preserved"
2774 );
2775 }
2776
2777 #[test]
2783 fn auto_discovery_expands_from_csv_header() {
2784 use std::io::Write;
2785
2786 let mut tmp = tempfile::NamedTempFile::new().expect("create temp file");
2788 write!(tmp, "Time,cpu_usage,mem_usage\n1000,42.5,60.0\n").expect("write csv");
2789 tmp.flush().expect("flush");
2790 let path = tmp.path().to_string_lossy().into_owned();
2791
2792 let config = ScenarioConfig {
2793 base: BaseScheduleConfig {
2794 name: "auto_test".to_string(),
2795 rate: 1.0,
2796 duration: Some("60s".to_string()),
2797 gaps: None,
2798 bursts: None,
2799 cardinality_spikes: None,
2800 labels: Some(
2801 [("env".to_string(), "test".to_string())]
2802 .into_iter()
2803 .collect(),
2804 ),
2805 sink: SinkConfig::Stdout,
2806 phase_offset: None,
2807 clock_group: None,
2808 clock_group_is_auto: None,
2809 jitter: None,
2810 jitter_seed: None,
2811 dynamic_labels: None,
2812 on_sink_error: crate::OnSinkError::Warn,
2813 },
2814 generator: GeneratorConfig::CsvReplay {
2815 file: path,
2816 column: None,
2817 repeat: Some(true),
2818 columns: None,
2819 },
2820 encoder: EncoderConfig::PrometheusText { precision: None },
2821 };
2822 let result = expand_scenario(config).expect("must succeed");
2823
2824 assert_eq!(result.len(), 2, "should expand to 2 columns (skip Time)");
2825 assert_eq!(result[0].name, "cpu_usage");
2826 assert_eq!(result[1].name, "mem_usage");
2827
2828 for child in &result {
2830 let labels = child.labels.as_ref().expect("labels must be inherited");
2831 assert_eq!(labels.get("env").map(|s| s.as_str()), Some("test"));
2832 }
2833
2834 match &result[0].generator {
2836 GeneratorConfig::CsvReplay {
2837 column, columns, ..
2838 } => {
2839 assert_eq!(*column, Some(1));
2840 assert!(columns.is_none());
2841 }
2842 other => panic!("expected CsvReplay, got {other:?}"),
2843 }
2844 match &result[1].generator {
2845 GeneratorConfig::CsvReplay { column, .. } => {
2846 assert_eq!(*column, Some(2));
2847 }
2848 other => panic!("expected CsvReplay, got {other:?}"),
2849 }
2850
2851 drop(tmp);
2853 }
2854
2855 #[test]
2857 fn auto_discovery_grafana_style_extracts_labels() {
2858 use std::io::Write;
2859
2860 let mut tmp = tempfile::NamedTempFile::new().expect("create temp file");
2861 let header = r#""Time","{__name__=""up"", instance=""host1"", job=""prom""}","{__name__=""up"", instance=""host2"", job=""node""}""#;
2863 write!(tmp, "{header}\n1704067200000,1,1\n").expect("write csv");
2864 tmp.flush().expect("flush");
2865 let path = tmp.path().to_string_lossy().into_owned();
2866
2867 let config = ScenarioConfig {
2868 base: BaseScheduleConfig {
2869 name: "grafana_auto".to_string(),
2870 rate: 1.0,
2871 duration: None,
2872 gaps: None,
2873 bursts: None,
2874 cardinality_spikes: None,
2875 labels: Some(
2876 [("env".to_string(), "production".to_string())]
2877 .into_iter()
2878 .collect(),
2879 ),
2880 sink: SinkConfig::Stdout,
2881 phase_offset: None,
2882 clock_group: None,
2883 clock_group_is_auto: None,
2884 jitter: None,
2885 jitter_seed: None,
2886 dynamic_labels: None,
2887 on_sink_error: crate::OnSinkError::Warn,
2888 },
2889 generator: GeneratorConfig::CsvReplay {
2890 file: path,
2891 column: None,
2892 repeat: Some(true),
2893 columns: None,
2894 },
2895 encoder: EncoderConfig::PrometheusText { precision: None },
2896 };
2897 let result = expand_scenario(config).expect("must succeed");
2898
2899 assert_eq!(result.len(), 2);
2900
2901 assert_eq!(result[0].name, "up");
2903 assert_eq!(result[1].name, "up");
2904
2905 let labels0 = result[0].labels.as_ref().expect("labels must exist");
2907 assert_eq!(labels0.get("instance").map(|s| s.as_str()), Some("host1"));
2908 assert_eq!(labels0.get("job").map(|s| s.as_str()), Some("prom"));
2909 assert_eq!(labels0.get("env").map(|s| s.as_str()), Some("production"));
2910
2911 let labels1 = result[1].labels.as_ref().expect("labels must exist");
2913 assert_eq!(labels1.get("instance").map(|s| s.as_str()), Some("host2"));
2914 assert_eq!(labels1.get("job").map(|s| s.as_str()), Some("node"));
2915 assert_eq!(labels1.get("env").map(|s| s.as_str()), Some("production"));
2916
2917 drop(tmp);
2918 }
2919
2920 #[test]
2926 fn auto_discovery_single_column_file_returns_error() {
2927 use std::io::Write;
2928
2929 let mut tmp = tempfile::NamedTempFile::new().expect("create temp file");
2930 write!(tmp, "Time\n1000\n").expect("write csv");
2931 tmp.flush().expect("flush");
2932 let path = tmp.path().to_string_lossy().into_owned();
2933
2934 let config = ScenarioConfig {
2935 base: BaseScheduleConfig {
2936 name: "no_data_cols".to_string(),
2937 rate: 1.0,
2938 duration: None,
2939 gaps: None,
2940 bursts: None,
2941 cardinality_spikes: None,
2942 labels: None,
2943 sink: SinkConfig::Stdout,
2944 phase_offset: None,
2945 clock_group: None,
2946 clock_group_is_auto: None,
2947 jitter: None,
2948 jitter_seed: None,
2949 dynamic_labels: None,
2950 on_sink_error: crate::OnSinkError::Warn,
2951 },
2952 generator: GeneratorConfig::CsvReplay {
2953 file: path,
2954 column: None,
2955 repeat: Some(true),
2956 columns: None,
2957 },
2958 encoder: EncoderConfig::PrometheusText { precision: None },
2959 };
2960 let err = expand_scenario(config).expect_err("must fail");
2961 let msg = err.to_string();
2962 assert!(
2963 msg.contains("no data columns"),
2964 "error must mention no data columns, got: {msg}"
2965 );
2966
2967 drop(tmp);
2968 }
2969
2970 #[test]
2974 fn auto_discovery_single_data_column_no_time_yields_no_data_columns() {
2975 use std::io::Write;
2976
2977 let mut tmp = tempfile::NamedTempFile::new().expect("create temp file");
2978 write!(tmp, "metric_name\n42.5\n").expect("write csv");
2979 tmp.flush().expect("flush");
2980 let path = tmp.path().to_string_lossy().into_owned();
2981
2982 let config = ScenarioConfig {
2983 base: BaseScheduleConfig {
2984 name: "single_data_col".to_string(),
2985 rate: 1.0,
2986 duration: None,
2987 gaps: None,
2988 bursts: None,
2989 cardinality_spikes: None,
2990 labels: None,
2991 sink: SinkConfig::Stdout,
2992 phase_offset: None,
2993 clock_group: None,
2994 clock_group_is_auto: None,
2995 jitter: None,
2996 jitter_seed: None,
2997 dynamic_labels: None,
2998 on_sink_error: crate::OnSinkError::Warn,
2999 },
3000 generator: GeneratorConfig::CsvReplay {
3001 file: path,
3002 column: None,
3003 repeat: Some(true),
3004 columns: None,
3005 },
3006 encoder: EncoderConfig::PrometheusText { precision: None },
3007 };
3008 let err = expand_scenario(config).expect_err("must fail");
3009 let msg = err.to_string();
3010 assert!(
3011 msg.contains("no data columns"),
3012 "error must mention no data columns, got: {msg}"
3013 );
3014
3015 drop(tmp);
3016 }
3017
3018 #[test]
3020 fn auto_discovery_missing_file_returns_generator_error() {
3021 let config = ScenarioConfig {
3022 base: BaseScheduleConfig {
3023 name: "missing_file".to_string(),
3024 rate: 1.0,
3025 duration: None,
3026 gaps: None,
3027 bursts: None,
3028 cardinality_spikes: None,
3029 labels: None,
3030 sink: SinkConfig::Stdout,
3031 phase_offset: None,
3032 clock_group: None,
3033 clock_group_is_auto: None,
3034 jitter: None,
3035 jitter_seed: None,
3036 dynamic_labels: None,
3037 on_sink_error: crate::OnSinkError::Warn,
3038 },
3039 generator: GeneratorConfig::CsvReplay {
3040 file: "/nonexistent/path.csv".to_string(),
3041 column: None,
3042 repeat: Some(true),
3043 columns: None,
3044 },
3045 encoder: EncoderConfig::PrometheusText { precision: None },
3046 };
3047 let err = expand_scenario(config).expect_err("must fail");
3048 assert!(
3049 matches!(err, SondaError::Generator(_)),
3050 "missing file should be a Generator error, got: {err:?}"
3051 );
3052 }
3053
3054 #[test]
3056 fn auto_discovery_all_numeric_returns_error() {
3057 use std::io::Write;
3058
3059 let mut tmp = tempfile::NamedTempFile::new().expect("create temp file");
3060 write!(tmp, "1000,42.5,60.0\n2000,55.3,70.1\n").expect("write csv");
3061 tmp.flush().expect("flush");
3062 let path = tmp.path().to_string_lossy().into_owned();
3063
3064 let config = ScenarioConfig {
3065 base: BaseScheduleConfig {
3066 name: "no_header".to_string(),
3067 rate: 1.0,
3068 duration: None,
3069 gaps: None,
3070 bursts: None,
3071 cardinality_spikes: None,
3072 labels: None,
3073 sink: SinkConfig::Stdout,
3074 phase_offset: None,
3075 clock_group: None,
3076 clock_group_is_auto: None,
3077 jitter: None,
3078 jitter_seed: None,
3079 dynamic_labels: None,
3080 on_sink_error: crate::OnSinkError::Warn,
3081 },
3082 generator: GeneratorConfig::CsvReplay {
3083 file: path,
3084 column: None,
3085 repeat: Some(true),
3086 columns: None,
3087 },
3088 encoder: EncoderConfig::PrometheusText { precision: None },
3089 };
3090 let err = expand_scenario(config).expect_err("must fail");
3091 let msg = err.to_string();
3092 assert!(
3093 msg.contains("no header row"),
3094 "error must mention no header row, got: {msg}"
3095 );
3096
3097 drop(tmp);
3098 }
3099
3100 #[cfg(feature = "config")]
3105 #[test]
3106 fn deserialize_per_column_labels_from_yaml() {
3107 let yaml = r#"
3108name: labeled_cols
3109rate: 1
3110generator:
3111 type: csv_replay
3112 file: data.csv
3113 columns:
3114 - index: 1
3115 name: cpu_percent
3116 labels:
3117 instance: host1
3118 job: node
3119 - index: 2
3120 name: mem_percent
3121"#;
3122 let config: ScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
3123 match &config.generator {
3124 GeneratorConfig::CsvReplay { columns, .. } => {
3125 let cols = columns.as_ref().expect("columns should be Some");
3126 assert_eq!(cols.len(), 2);
3127
3128 let labels0 = cols[0].labels.as_ref().expect("col 0 labels must be Some");
3130 assert_eq!(labels0.get("instance").map(|s| s.as_str()), Some("host1"));
3131 assert_eq!(labels0.get("job").map(|s| s.as_str()), Some("node"));
3132
3133 assert!(cols[1].labels.is_none());
3135 }
3136 other => panic!("expected CsvReplay variant, got {other:?}"),
3137 }
3138 }
3139
3140 #[test]
3146 #[cfg(feature = "config")]
3147 fn histogram_config_deserializes_from_yaml() {
3148 let yaml = r#"
3149name: http_request_duration_seconds
3150rate: 1
3151duration: 5m
3152buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]
3153distribution:
3154 type: exponential
3155 rate: 10.0
3156observations_per_tick: 100
3157mean_shift_per_sec: 0.001
3158seed: 42
3159labels:
3160 method: GET
3161"#;
3162 let config: HistogramScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
3163 assert_eq!(config.name, "http_request_duration_seconds");
3164 assert_eq!(config.rate, 1.0);
3165 assert_eq!(config.buckets.as_ref().unwrap().len(), 11);
3166 assert_eq!(config.observations_per_tick, Some(100));
3167 assert_eq!(config.mean_shift_per_sec, Some(0.001));
3168 assert_eq!(config.seed, Some(42));
3169 }
3170
3171 #[test]
3173 #[cfg(feature = "config")]
3174 fn histogram_config_defaults_when_omitted() {
3175 let yaml = r#"
3176name: latency
3177rate: 1
3178distribution:
3179 type: exponential
3180 rate: 5.0
3181"#;
3182 let config: HistogramScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
3183 assert!(config.buckets.is_none());
3184 assert!(config.observations_per_tick.is_none());
3185 assert!(config.mean_shift_per_sec.is_none());
3186 assert!(config.seed.is_none());
3187 }
3188
3189 #[test]
3191 #[cfg(feature = "config")]
3192 fn histogram_config_normal_distribution() {
3193 let yaml = r#"
3194name: latency
3195rate: 1
3196distribution:
3197 type: normal
3198 mean: 0.1
3199 stddev: 0.02
3200"#;
3201 let config: HistogramScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
3202 match config.distribution {
3203 DistributionConfig::Normal { mean, stddev } => {
3204 assert_eq!(mean, 0.1);
3205 assert_eq!(stddev, 0.02);
3206 }
3207 _ => panic!("expected Normal distribution"),
3208 }
3209 }
3210
3211 #[test]
3213 #[cfg(feature = "config")]
3214 fn histogram_config_uniform_distribution() {
3215 let yaml = r#"
3216name: latency
3217rate: 1
3218distribution:
3219 type: uniform
3220 min: 0.0
3221 max: 1.0
3222"#;
3223 let config: HistogramScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
3224 match config.distribution {
3225 DistributionConfig::Uniform { min, max } => {
3226 assert_eq!(min, 0.0);
3227 assert_eq!(max, 1.0);
3228 }
3229 _ => panic!("expected Uniform distribution"),
3230 }
3231 }
3232
3233 #[test]
3239 #[cfg(feature = "config")]
3240 fn summary_config_deserializes_from_yaml() {
3241 let yaml = r#"
3242name: rpc_duration_seconds
3243rate: 1
3244duration: 5m
3245quantiles: [0.5, 0.9, 0.95, 0.99]
3246distribution:
3247 type: normal
3248 mean: 0.1
3249 stddev: 0.02
3250observations_per_tick: 100
3251seed: 42
3252"#;
3253 let config: SummaryScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
3254 assert_eq!(config.name, "rpc_duration_seconds");
3255 assert_eq!(config.rate, 1.0);
3256 assert_eq!(config.quantiles.as_ref().unwrap().len(), 4);
3257 assert_eq!(config.observations_per_tick, Some(100));
3258 assert_eq!(config.seed, Some(42));
3259 }
3260
3261 #[test]
3263 #[cfg(feature = "config")]
3264 fn summary_config_defaults_when_omitted() {
3265 let yaml = r#"
3266name: rpc_latency
3267rate: 1
3268distribution:
3269 type: exponential
3270 rate: 5.0
3271"#;
3272 let config: SummaryScenarioConfig = serde_yaml_ng::from_str(yaml).unwrap();
3273 assert!(config.quantiles.is_none());
3274 assert!(config.observations_per_tick.is_none());
3275 assert!(config.seed.is_none());
3276 }
3277
3278 #[test]
3284 #[cfg(feature = "config")]
3285 fn scenario_entry_base_works_for_histogram() {
3286 let yaml = r#"
3287signal_type: histogram
3288name: test_hist
3289rate: 5
3290distribution:
3291 type: exponential
3292 rate: 10.0
3293"#;
3294 let entry: ScenarioEntry = serde_yaml_ng::from_str(yaml).unwrap();
3295 assert_eq!(entry.base().name, "test_hist");
3296 assert_eq!(entry.base().rate, 5.0);
3297 }
3298
3299 #[test]
3301 #[cfg(feature = "config")]
3302 fn scenario_entry_base_works_for_summary() {
3303 let yaml = r#"
3304signal_type: summary
3305name: test_sum
3306rate: 5
3307distribution:
3308 type: normal
3309 mean: 0.1
3310 stddev: 0.02
3311"#;
3312 let entry: ScenarioEntry = serde_yaml_ng::from_str(yaml).unwrap();
3313 assert_eq!(entry.base().name, "test_sum");
3314 assert_eq!(entry.base().rate, 5.0);
3315 }
3316
3317 #[test]
3319 fn expand_entry_passes_through_histogram() {
3320 let entry = ScenarioEntry::Histogram(HistogramScenarioConfig {
3321 base: BaseScheduleConfig {
3322 name: "test_hist".to_string(),
3323 rate: 1.0,
3324 duration: None,
3325 gaps: None,
3326 bursts: None,
3327 cardinality_spikes: None,
3328 dynamic_labels: None,
3329 labels: None,
3330 sink: crate::sink::SinkConfig::Stdout,
3331 phase_offset: None,
3332 clock_group: None,
3333 clock_group_is_auto: None,
3334 jitter: None,
3335 jitter_seed: None,
3336 on_sink_error: crate::OnSinkError::Warn,
3337 },
3338 buckets: None,
3339 distribution: DistributionConfig::Exponential { rate: 10.0 },
3340 observations_per_tick: None,
3341 mean_shift_per_sec: None,
3342 seed: None,
3343 encoder: EncoderConfig::PrometheusText { precision: None },
3344 });
3345 let result = expand_entry(entry).expect("must succeed");
3346 assert_eq!(result.len(), 1);
3347 assert!(matches!(result[0], ScenarioEntry::Histogram(_)));
3348 }
3349
3350 #[test]
3352 fn expand_entry_passes_through_summary() {
3353 let entry = ScenarioEntry::Summary(SummaryScenarioConfig {
3354 base: BaseScheduleConfig {
3355 name: "test_sum".to_string(),
3356 rate: 1.0,
3357 duration: None,
3358 gaps: None,
3359 bursts: None,
3360 cardinality_spikes: None,
3361 dynamic_labels: None,
3362 labels: None,
3363 sink: crate::sink::SinkConfig::Stdout,
3364 phase_offset: None,
3365 clock_group: None,
3366 clock_group_is_auto: None,
3367 jitter: None,
3368 jitter_seed: None,
3369 on_sink_error: crate::OnSinkError::Warn,
3370 },
3371 quantiles: None,
3372 distribution: DistributionConfig::Normal {
3373 mean: 0.1,
3374 stddev: 0.02,
3375 },
3376 observations_per_tick: None,
3377 mean_shift_per_sec: None,
3378 seed: None,
3379 encoder: EncoderConfig::PrometheusText { precision: None },
3380 });
3381 let result = expand_entry(entry).expect("must succeed");
3382 assert_eq!(result.len(), 1);
3383 assert!(matches!(result[0], ScenarioEntry::Summary(_)));
3384 }
3385}