1use std::collections::{BTreeMap, HashMap};
32
33use crate::compiler::compile_after::{CompiledEntry, CompiledFile};
34use crate::config::{
35 BaseScheduleConfig, HistogramScenarioConfig, LogScenarioConfig, ScenarioConfig, ScenarioEntry,
36 SummaryScenarioConfig,
37};
38
39#[derive(Debug, thiserror::Error)]
52#[non_exhaustive]
53pub enum PrepareError {
54 #[error("entry '{entry_label}': unknown signal_type '{signal_type}'")]
63 UnknownSignalType {
64 entry_label: String,
66 signal_type: String,
68 },
69
70 #[error("entry '{entry_label}' (signal_type: metrics): missing required field 'generator'")]
78 MissingGenerator {
79 entry_label: String,
81 },
82
83 #[error("entry '{entry_label}' (signal_type: logs): missing required field 'log_generator'")]
92 MissingLogGenerator {
93 entry_label: String,
95 },
96
97 #[error(
106 "entry '{entry_label}' (signal_type: {signal_type}): missing required field 'distribution'"
107 )]
108 MissingDistribution {
109 entry_label: String,
111 signal_type: String,
113 },
114
115 #[error("unsupported compiled file version: expected 2, got {version}")]
122 UnsupportedVersion {
123 version: u32,
125 },
126}
127
128pub fn prepare(file: CompiledFile) -> Result<Vec<ScenarioEntry>, PrepareError> {
165 let CompiledFile {
166 version, entries, ..
167 } = file;
168 if version != 2 {
169 return Err(PrepareError::UnsupportedVersion { version });
170 }
171 let mut out = Vec::with_capacity(entries.len());
172 for entry in entries {
173 out.push(translate_entry(entry)?);
174 }
175 Ok(out)
176}
177
178pub fn translate_entry(entry: CompiledEntry) -> Result<ScenarioEntry, PrepareError> {
188 match entry.signal_type.as_str() {
189 "metrics" => metrics_entry(entry).map(ScenarioEntry::Metrics),
190 "logs" => logs_entry(entry).map(ScenarioEntry::Logs),
191 "histogram" => histogram_entry(entry).map(ScenarioEntry::Histogram),
192 "summary" => summary_entry(entry).map(ScenarioEntry::Summary),
193 _ => Err(PrepareError::UnknownSignalType {
194 entry_label: describe(&entry),
195 signal_type: entry.signal_type,
196 }),
197 }
198}
199
200fn describe(entry: &CompiledEntry) -> String {
207 entry.id.clone().unwrap_or_else(|| entry.name.clone())
208}
209
210fn build_base(entry: &mut CompiledEntry) -> BaseScheduleConfig {
222 let labels = entry.labels.take().map(btree_to_hash);
223
224 let clock_group = entry.clock_group.take();
225 let clock_group_is_auto = clock_group.as_ref().map(|_| entry.clock_group_is_auto);
226
227 BaseScheduleConfig {
228 name: std::mem::take(&mut entry.name),
229 rate: entry.rate,
230 duration: entry.duration.take(),
231 gaps: entry.gaps.take(),
232 bursts: entry.bursts.take(),
233 cardinality_spikes: entry.cardinality_spikes.take(),
234 dynamic_labels: entry.dynamic_labels.take(),
235 labels,
236 sink: std::mem::replace(&mut entry.sink, crate::sink::SinkConfig::Stdout),
237 phase_offset: entry.phase_offset.take(),
238 clock_group,
239 clock_group_is_auto,
240 jitter: entry.jitter,
241 jitter_seed: entry.jitter_seed,
242 on_sink_error: entry.on_sink_error,
243 }
244}
245
246fn btree_to_hash(m: BTreeMap<String, String>) -> HashMap<String, String> {
249 let mut hm = HashMap::with_capacity(m.len());
250 hm.extend(m);
251 hm
252}
253
254fn metrics_entry(mut entry: CompiledEntry) -> Result<ScenarioConfig, PrepareError> {
256 let generator = entry
257 .generator
258 .take()
259 .ok_or_else(|| PrepareError::MissingGenerator {
260 entry_label: describe(&entry),
261 })?;
262 let encoder = std::mem::replace(
265 &mut entry.encoder,
266 crate::encoder::EncoderConfig::PrometheusText { precision: None },
267 );
268 let base = build_base(&mut entry);
269 Ok(ScenarioConfig {
270 base,
271 generator,
272 encoder,
273 })
274}
275
276fn logs_entry(mut entry: CompiledEntry) -> Result<LogScenarioConfig, PrepareError> {
278 let generator =
279 entry
280 .log_generator
281 .take()
282 .ok_or_else(|| PrepareError::MissingLogGenerator {
283 entry_label: describe(&entry),
284 })?;
285 let encoder = std::mem::replace(
286 &mut entry.encoder,
287 crate::encoder::EncoderConfig::JsonLines { precision: None },
288 );
289 let base = build_base(&mut entry);
290 Ok(LogScenarioConfig {
291 base,
292 generator,
293 encoder,
294 })
295}
296
297fn histogram_entry(mut entry: CompiledEntry) -> Result<HistogramScenarioConfig, PrepareError> {
299 let distribution =
300 entry
301 .distribution
302 .take()
303 .ok_or_else(|| PrepareError::MissingDistribution {
304 entry_label: describe(&entry),
305 signal_type: "histogram".to_string(),
306 })?;
307 let buckets = entry.buckets.take();
308 let observations_per_tick = entry.observations_per_tick.map(u64::from);
309 let mean_shift_per_sec = entry.mean_shift_per_sec;
310 let seed = entry.seed;
311 let encoder = std::mem::replace(
312 &mut entry.encoder,
313 crate::encoder::EncoderConfig::PrometheusText { precision: None },
314 );
315 let base = build_base(&mut entry);
316 Ok(HistogramScenarioConfig {
317 base,
318 buckets,
319 distribution,
320 observations_per_tick,
321 mean_shift_per_sec,
322 seed,
323 encoder,
324 })
325}
326
327fn summary_entry(mut entry: CompiledEntry) -> Result<SummaryScenarioConfig, PrepareError> {
329 let distribution =
330 entry
331 .distribution
332 .take()
333 .ok_or_else(|| PrepareError::MissingDistribution {
334 entry_label: describe(&entry),
335 signal_type: "summary".to_string(),
336 })?;
337 let quantiles = entry.quantiles.take();
338 let observations_per_tick = entry.observations_per_tick.map(u64::from);
339 let mean_shift_per_sec = entry.mean_shift_per_sec;
340 let seed = entry.seed;
341 let encoder = std::mem::replace(
342 &mut entry.encoder,
343 crate::encoder::EncoderConfig::PrometheusText { precision: None },
344 );
345 let base = build_base(&mut entry);
346 Ok(SummaryScenarioConfig {
347 base,
348 quantiles,
349 distribution,
350 observations_per_tick,
351 mean_shift_per_sec,
352 seed,
353 encoder,
354 })
355}
356
357#[cfg(all(test, feature = "config"))]
362mod tests {
363 use std::collections::BTreeMap;
364
365 use rstest::rstest;
366
367 use super::*;
368 use crate::config::DistributionConfig;
369 use crate::encoder::EncoderConfig;
370 use crate::generator::{GeneratorConfig, LogGeneratorConfig, TemplateConfig};
371 use crate::sink::SinkConfig;
372
373 fn bare(signal_type: &str, name: &str) -> CompiledEntry {
378 CompiledEntry {
379 id: None,
380 signal_type: signal_type.to_string(),
381 name: name.to_string(),
382 rate: 10.0,
383 duration: Some("1s".to_string()),
384 generator: None,
385 log_generator: None,
386 labels: None,
387 dynamic_labels: None,
388 encoder: EncoderConfig::PrometheusText { precision: None },
389 sink: SinkConfig::Stdout,
390 jitter: None,
391 jitter_seed: None,
392 gaps: None,
393 bursts: None,
394 cardinality_spikes: None,
395 phase_offset: None,
396 clock_group: None,
397 clock_group_is_auto: false,
398 distribution: None,
399 buckets: None,
400 quantiles: None,
401 observations_per_tick: None,
402 mean_shift_per_sec: None,
403 seed: None,
404 on_sink_error: crate::OnSinkError::Warn,
405 while_clause: None,
406 delay_clause: None,
407 after_ref: None,
408 }
409 }
410
411 fn metrics_compiled(name: &str) -> CompiledEntry {
412 let mut e = bare("metrics", name);
413 e.generator = Some(GeneratorConfig::Constant { value: 1.0 });
414 e
415 }
416
417 fn logs_compiled(name: &str) -> CompiledEntry {
418 let mut e = bare("logs", name);
419 e.log_generator = Some(LogGeneratorConfig::Template {
420 templates: vec![TemplateConfig {
421 message: "hi".to_string(),
422 field_pools: BTreeMap::new(),
423 }],
424 severity_weights: None,
425 seed: Some(0),
426 });
427 e.encoder = EncoderConfig::JsonLines { precision: None };
428 e
429 }
430
431 fn histogram_compiled(name: &str) -> CompiledEntry {
432 let mut e = bare("histogram", name);
433 e.distribution = Some(DistributionConfig::Exponential { rate: 10.0 });
434 e.buckets = Some(vec![0.1, 1.0, 10.0]);
435 e
436 }
437
438 fn summary_compiled(name: &str) -> CompiledEntry {
439 let mut e = bare("summary", name);
440 e.distribution = Some(DistributionConfig::Normal {
441 mean: 0.1,
442 stddev: 0.02,
443 });
444 e.quantiles = Some(vec![0.5, 0.9, 0.99]);
445 e
446 }
447
448 fn file_with(entry: CompiledEntry) -> CompiledFile {
449 CompiledFile {
450 version: 2,
451 scenario_name: None,
452 entries: vec![entry],
453 }
454 }
455
456 #[test]
461 fn metrics_entry_translates_to_scenario_entry_metrics() {
462 let file = file_with(metrics_compiled("cpu_usage"));
463 let out = prepare(file).expect("translate must succeed");
464 assert_eq!(out.len(), 1);
465 match &out[0] {
466 ScenarioEntry::Metrics(c) => {
467 assert_eq!(c.base.name, "cpu_usage");
468 assert_eq!(c.base.rate, 10.0);
469 assert!(matches!(c.generator, GeneratorConfig::Constant { .. }));
470 }
471 other => panic!("expected Metrics, got {other:?}"),
472 }
473 }
474
475 #[test]
478 fn logs_entry_translates_to_scenario_entry_logs() {
479 let file = file_with(logs_compiled("app_logs"));
480 let out = prepare(file).expect("translate must succeed");
481 match &out[0] {
482 ScenarioEntry::Logs(c) => {
483 assert_eq!(c.base.name, "app_logs");
484 assert!(matches!(c.generator, LogGeneratorConfig::Template { .. }));
485 }
486 other => panic!("expected Logs, got {other:?}"),
487 }
488 }
489
490 #[test]
493 fn histogram_entry_translates_with_distribution_and_buckets() {
494 let file = file_with(histogram_compiled("http_request_duration"));
495 let out = prepare(file).expect("translate must succeed");
496 match &out[0] {
497 ScenarioEntry::Histogram(c) => {
498 assert_eq!(c.base.name, "http_request_duration");
499 assert_eq!(c.buckets.as_deref(), Some(&[0.1, 1.0, 10.0][..]));
500 assert!(matches!(
501 c.distribution,
502 DistributionConfig::Exponential { .. }
503 ));
504 }
505 other => panic!("expected Histogram, got {other:?}"),
506 }
507 }
508
509 #[test]
512 fn summary_entry_translates_with_distribution_and_quantiles() {
513 let file = file_with(summary_compiled("rpc_duration"));
514 let out = prepare(file).expect("translate must succeed");
515 match &out[0] {
516 ScenarioEntry::Summary(c) => {
517 assert_eq!(c.base.name, "rpc_duration");
518 assert_eq!(c.quantiles.as_deref(), Some(&[0.5, 0.9, 0.99][..]));
519 assert!(matches!(c.distribution, DistributionConfig::Normal { .. }));
520 }
521 other => panic!("expected Summary, got {other:?}"),
522 }
523 }
524
525 #[test]
527 fn prepare_preserves_entry_order() {
528 let file = CompiledFile {
529 version: 2,
530 scenario_name: None,
531 entries: vec![
532 metrics_compiled("first"),
533 logs_compiled("second"),
534 histogram_compiled("third"),
535 summary_compiled("fourth"),
536 ],
537 };
538 let out = prepare(file).expect("translate must succeed");
539 assert_eq!(out.len(), 4);
540 assert_eq!(out[0].base().name, "first");
541 assert_eq!(out[1].base().name, "second");
542 assert_eq!(out[2].base().name, "third");
543 assert_eq!(out[3].base().name, "fourth");
544 }
545
546 #[test]
548 fn prepare_empty_file_returns_empty_vec() {
549 let file = CompiledFile {
550 version: 2,
551 scenario_name: None,
552 entries: vec![],
553 };
554 let out = prepare(file).expect("empty file must translate cleanly");
555 assert!(out.is_empty());
556 }
557
558 #[test]
563 fn phase_offset_string_is_passed_through_verbatim() {
564 let mut entry = metrics_compiled("delayed");
565 entry.phase_offset = Some("152.308s".to_string());
566 let out = prepare(file_with(entry)).expect("translate");
567 assert_eq!(out[0].phase_offset(), Some("152.308s"));
568 }
569
570 #[test]
572 fn clock_group_is_passed_through_on_all_variants() {
573 for factory in [
574 metrics_compiled as fn(&str) -> CompiledEntry,
575 logs_compiled,
576 histogram_compiled,
577 summary_compiled,
578 ] {
579 let mut entry = factory("any");
580 entry.clock_group = Some("chain_alpha".to_string());
581 let out = prepare(file_with(entry)).expect("translate");
582 assert_eq!(out[0].clock_group(), Some("chain_alpha"));
583 }
584 }
585
586 #[test]
591 fn labels_btree_to_hash_preserves_all_pairs() {
592 let mut labels = BTreeMap::new();
593 labels.insert("k1".to_string(), "v1".to_string());
594 labels.insert("k2".to_string(), "v2".to_string());
595 labels.insert("k3".to_string(), "v3".to_string());
596
597 let mut entry = metrics_compiled("labeled");
598 entry.labels = Some(labels.clone());
599
600 let out = prepare(file_with(entry)).expect("translate");
601 let hm = out[0]
602 .base()
603 .labels
604 .as_ref()
605 .expect("labels must carry through");
606 assert_eq!(hm.len(), labels.len());
607 for (k, v) in &labels {
608 assert_eq!(hm.get(k).map(String::as_str), Some(v.as_str()));
609 }
610 }
611
612 #[test]
615 fn labels_empty_btree_maps_to_empty_hash() {
616 let mut entry = metrics_compiled("empty_labels");
617 entry.labels = Some(BTreeMap::new());
618 let out = prepare(file_with(entry)).expect("translate");
619 let hm = out[0].base().labels.as_ref().expect("Some stays Some");
620 assert!(hm.is_empty());
621 }
622
623 #[test]
626 fn labels_none_stays_none() {
627 let entry = metrics_compiled("no_labels");
628 let out = prepare(file_with(entry)).expect("translate");
629 assert!(out[0].base().labels.is_none());
630 }
631
632 #[test]
637 fn histogram_observations_per_tick_widens_zero_correctly() {
638 let mut entry = histogram_compiled("zero_obs");
639 entry.observations_per_tick = Some(0);
640 let out = prepare(file_with(entry)).expect("translate");
641 match &out[0] {
642 ScenarioEntry::Histogram(c) => {
643 assert_eq!(c.observations_per_tick, Some(0u64));
644 }
645 _ => panic!("expected Histogram"),
646 }
647 }
648
649 #[test]
652 fn histogram_observations_per_tick_widens_u32_max_correctly() {
653 let mut entry = histogram_compiled("max_obs");
654 entry.observations_per_tick = Some(u32::MAX);
655 let out = prepare(file_with(entry)).expect("translate");
656 match &out[0] {
657 ScenarioEntry::Histogram(c) => {
658 assert_eq!(c.observations_per_tick, Some(u64::from(u32::MAX)));
659 assert_eq!(c.observations_per_tick, Some(4_294_967_295_u64));
660 }
661 _ => panic!("expected Histogram"),
662 }
663 }
664
665 #[test]
667 fn summary_observations_per_tick_widens_u32_max_correctly() {
668 let mut entry = summary_compiled("max_obs_summary");
669 entry.observations_per_tick = Some(u32::MAX);
670 let out = prepare(file_with(entry)).expect("translate");
671 match &out[0] {
672 ScenarioEntry::Summary(c) => {
673 assert_eq!(c.observations_per_tick, Some(u64::from(u32::MAX)));
674 }
675 _ => panic!("expected Summary"),
676 }
677 }
678
679 #[test]
684 fn unknown_signal_type_produces_unknown_signal_type_error() {
685 let mut entry = bare("traces", "bad");
686 entry.id = Some("bad".to_string());
687 let err = prepare(file_with(entry)).expect_err("unknown signal_type must fail");
688 match err {
689 PrepareError::UnknownSignalType {
690 entry_label,
691 signal_type,
692 } => {
693 assert_eq!(entry_label, "bad");
694 assert_eq!(signal_type, "traces");
695 }
696 other => panic!("expected UnknownSignalType, got {other:?}"),
697 }
698 }
699
700 #[test]
702 fn unknown_signal_type_falls_back_to_name_when_id_absent() {
703 let entry = bare("traces", "bad_by_name");
704 let err = prepare(file_with(entry)).expect_err("unknown signal_type must fail");
705 match err {
706 PrepareError::UnknownSignalType { entry_label, .. } => {
707 assert_eq!(entry_label, "bad_by_name");
708 }
709 other => panic!("expected UnknownSignalType, got {other:?}"),
710 }
711 }
712
713 #[test]
716 fn metrics_without_generator_produces_missing_generator_error() {
717 let mut entry = bare("metrics", "no_gen");
718 entry.id = Some("no_gen".to_string());
719 let err = prepare(file_with(entry)).expect_err("missing generator must fail");
721 match err {
722 PrepareError::MissingGenerator { entry_label } => {
723 assert_eq!(entry_label, "no_gen");
724 }
725 other => panic!("expected MissingGenerator, got {other:?}"),
726 }
727 }
728
729 #[test]
731 fn logs_without_log_generator_produces_missing_log_generator_error() {
732 let entry = bare("logs", "no_log_gen");
733 let err = prepare(file_with(entry)).expect_err("missing log_generator must fail");
734 match err {
735 PrepareError::MissingLogGenerator { entry_label } => {
736 assert_eq!(entry_label, "no_log_gen");
737 }
738 other => panic!("expected MissingLogGenerator, got {other:?}"),
739 }
740 }
741
742 #[test]
745 fn histogram_without_distribution_produces_missing_distribution_error() {
746 let entry = bare("histogram", "no_dist_hist");
747 let err = prepare(file_with(entry)).expect_err("missing distribution must fail");
748 match err {
749 PrepareError::MissingDistribution {
750 entry_label,
751 signal_type,
752 } => {
753 assert_eq!(entry_label, "no_dist_hist");
754 assert_eq!(signal_type, "histogram");
755 }
756 other => panic!("expected MissingDistribution, got {other:?}"),
757 }
758 }
759
760 #[test]
763 fn summary_without_distribution_produces_missing_distribution_error() {
764 let entry = bare("summary", "no_dist_summary");
765 let err = prepare(file_with(entry)).expect_err("missing distribution must fail");
766 match err {
767 PrepareError::MissingDistribution {
768 entry_label,
769 signal_type,
770 } => {
771 assert_eq!(entry_label, "no_dist_summary");
772 assert_eq!(signal_type, "summary");
773 }
774 other => panic!("expected MissingDistribution, got {other:?}"),
775 }
776 }
777
778 #[derive(Debug, Clone, Copy)]
782 enum ExpectedMissing {
783 Generator,
784 LogGenerator,
785 Distribution,
786 }
787
788 #[rustfmt::skip]
793 #[rstest]
794 #[case::metrics("metrics", ExpectedMissing::Generator)]
795 #[case::logs("logs", ExpectedMissing::LogGenerator)]
796 #[case::histogram("histogram", ExpectedMissing::Distribution)]
797 #[case::summary("summary", ExpectedMissing::Distribution)]
798 fn missing_required_field_fails_per_signal_type(
799 #[case] signal_type: &str,
800 #[case] expected: ExpectedMissing,
801 ) {
802 let entry = bare(signal_type, "empty_shape");
803 let err = prepare(file_with(entry)).err().unwrap_or_else(|| {
804 panic!("signal_type '{signal_type}' missing required field must error")
805 });
806 let matched = match expected {
807 ExpectedMissing::Generator => {
808 matches!(err, PrepareError::MissingGenerator { ref entry_label } if entry_label == "empty_shape")
809 }
810 ExpectedMissing::LogGenerator => {
811 matches!(err, PrepareError::MissingLogGenerator { ref entry_label } if entry_label == "empty_shape")
812 }
813 ExpectedMissing::Distribution => matches!(
814 err,
815 PrepareError::MissingDistribution { ref entry_label, signal_type: ref st }
816 if entry_label == "empty_shape" && st == signal_type
817 ),
818 };
819 assert!(
820 matched,
821 "signal_type '{signal_type}': expected {expected:?}, got {err:?}"
822 );
823 }
824
825 #[test]
830 fn prepare_fails_fast_on_first_bad_entry() {
831 let file = CompiledFile {
832 version: 2,
833 scenario_name: None,
834 entries: vec![
835 metrics_compiled("ok_1"),
836 bare("traces", "bad"),
837 metrics_compiled("ok_2"),
838 ],
839 };
840 let err = prepare(file).expect_err("bad entry in middle must fail");
841 assert!(
842 matches!(err, PrepareError::UnknownSignalType { .. }),
843 "middle bad entry must produce UnknownSignalType, got {err:?}"
844 );
845 }
846
847 #[test]
850 fn prepare_error_is_send_and_sync() {
851 fn assert_send_sync<T: Send + Sync>() {}
852 assert_send_sync::<PrepareError>();
853 }
854
855 #[test]
860 fn prepare_rejects_non_v2_version() {
861 let file = CompiledFile {
862 version: 3,
863 scenario_name: None,
864 entries: vec![metrics_compiled("never_translated")],
865 };
866 let err = prepare(file).expect_err("version != 2 must fail");
867 match err {
868 PrepareError::UnsupportedVersion { version } => assert_eq!(version, 3),
869 other => panic!("expected UnsupportedVersion, got {other:?}"),
870 }
871 }
872
873 #[test]
877 fn prepare_version_check_precedes_entry_translation() {
878 let file = CompiledFile {
879 version: 0,
880 scenario_name: None,
881 entries: vec![bare("traces", "would_fail_if_translated")],
882 };
883 let err = prepare(file).expect_err("version 0 must fail");
884 assert!(
885 matches!(err, PrepareError::UnsupportedVersion { version: 0 }),
886 "expected UnsupportedVersion {{ version: 0 }}, got {err:?}"
887 );
888 }
889}