Skip to main content

sonda_core/config/
mod.rs

1//! Scenario configuration types and validation.
2//!
3//! The `Serialize` and `Deserialize` impls on all config types are available
4//! only when the `config` Cargo feature is enabled (active by default). Without
5//! the feature, configs can still be constructed in code — only YAML/JSON
6//! serialization and parsing are gated.
7
8pub 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/// Gap window configuration — a recurring silent period within a scenario.
19///
20/// During a gap the scheduler emits no events. The gap repeats on a fixed
21/// cycle defined by `every`, and each instance lasts for `for`.
22#[derive(Debug, Clone)]
23#[cfg_attr(feature = "config", derive(serde::Serialize, serde::Deserialize))]
24pub struct GapConfig {
25    /// How often the gap recurs (e.g. `"2m"`).
26    pub every: String,
27    /// How long each gap lasts (e.g. `"20s"`). Must be less than `every`.
28    pub r#for: String,
29}
30
31/// Strategy for generating unique label values during a cardinality spike.
32///
33/// Determines how the spike window produces distinct values for the injected
34/// label key on each tick.
35#[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    /// Sequential counter: `prefix + (tick % cardinality)`.
40    ///
41    /// Produces deterministic, predictable label values without needing a seed.
42    #[default]
43    Counter,
44    /// Deterministic random: SplitMix64 hash of `seed ^ tick`, formatted as hex.
45    ///
46    /// Produces label values that look random but are reproducible given the
47    /// same seed.
48    Random,
49}
50
51/// Configuration for a cardinality spike — a recurring window that injects
52/// dynamic label values to simulate cardinality explosions.
53///
54/// During the spike window, a label key is injected with one of `cardinality`
55/// unique values per tick. Outside the window, the label key is absent.
56///
57/// # Example YAML
58///
59/// ```yaml
60/// cardinality_spikes:
61///   - label: pod_name
62///     every: 2m
63///     for: 30s
64///     cardinality: 500
65///     strategy: counter
66///     prefix: "pod-"
67/// ```
68#[derive(Debug, Clone)]
69#[cfg_attr(feature = "config", derive(serde::Serialize, serde::Deserialize))]
70pub struct CardinalitySpikeConfig {
71    /// The label key to inject during the spike window.
72    ///
73    /// Must be a valid Prometheus label key: `[a-zA-Z_][a-zA-Z0-9_]*`.
74    pub label: String,
75    /// How often the spike recurs (e.g. `"2m"`).
76    pub every: String,
77    /// How long each spike lasts (e.g. `"30s"`). Must be less than `every`.
78    pub r#for: String,
79    /// Number of unique label values generated during the spike.
80    ///
81    /// Must be greater than zero.
82    pub cardinality: u64,
83    /// Strategy for generating unique label values.
84    ///
85    /// Defaults to `counter` if not specified.
86    #[cfg_attr(feature = "config", serde(default))]
87    pub strategy: SpikeStrategy,
88    /// Optional prefix for generated label values.
89    ///
90    /// Defaults to `"{label}_"` when not specified.
91    #[cfg_attr(feature = "config", serde(default))]
92    pub prefix: Option<String>,
93    /// Optional RNG seed for the `random` strategy.
94    ///
95    /// Ignored for the `counter` strategy.
96    #[cfg_attr(feature = "config", serde(default))]
97    pub seed: Option<u64>,
98}
99
100/// Strategy for generating dynamic label values.
101///
102/// Determines how a [`DynamicLabelConfig`] produces per-tick values for the
103/// label key.
104#[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    /// Cycle through an explicit list of values.
109    ///
110    /// The label value at each tick is `values[tick % values.len()]`.
111    /// Cardinality is implicit (length of the list).
112    ValuesList {
113        /// The explicit list of label values to cycle through.
114        values: Vec<String>,
115    },
116    /// Sequential counter: `"{prefix}{tick % cardinality}"`.
117    ///
118    /// Produces deterministic, predictable label values that cycle through
119    /// `cardinality` distinct values indefinitely.
120    Counter {
121        /// Prefix prepended to the counter index (e.g. `"host-"` produces
122        /// `"host-0"`, `"host-1"`, ...).
123        #[cfg_attr(feature = "config", serde(default))]
124        prefix: Option<String>,
125        /// Number of unique label values in the cycle. Must be greater than zero.
126        cardinality: u64,
127    },
128}
129
130/// Configuration for a dynamic label — an always-on rotating label value
131/// attached to every emitted event.
132///
133/// Unlike [`CardinalitySpikeConfig`], dynamic labels are not time-windowed:
134/// they appear in every event for the lifetime of the scenario. This enables
135/// simulating a stable fleet of N distinct sources (e.g., 10 hostnames, 5 pod
136/// names) without a spike/window concept.
137///
138/// # Example YAML (counter strategy)
139///
140/// ```yaml
141/// dynamic_labels:
142///   - key: hostname
143///     prefix: "host-"
144///     cardinality: 10
145/// ```
146///
147/// # Example YAML (values list strategy)
148///
149/// ```yaml
150/// dynamic_labels:
151///   - key: region
152///     values: [us-east-1, us-west-2, eu-west-1]
153/// ```
154#[derive(Debug, Clone)]
155#[cfg_attr(feature = "config", derive(serde::Serialize, serde::Deserialize))]
156pub struct DynamicLabelConfig {
157    /// The label key to attach to every event.
158    ///
159    /// Must be a valid Prometheus label key: `[a-zA-Z_][a-zA-Z0-9_]*`.
160    pub key: String,
161    /// The strategy for generating per-tick label values.
162    ///
163    /// Deserialized via untagged enum: provide either `values: [...]` or
164    /// `prefix: / cardinality:` fields directly alongside `key:`.
165    #[cfg_attr(feature = "config", serde(flatten))]
166    pub strategy: DynamicLabelStrategy,
167}
168
169/// Burst window configuration — a recurring high-rate period within a scenario.
170///
171/// During a burst the event rate is multiplied by `multiplier`. The burst
172/// repeats on a fixed cycle defined by `every`, and each instance lasts for `for`.
173///
174/// If a gap and burst overlap in time, the gap takes priority and no events
175/// are emitted.
176#[derive(Debug, Clone)]
177#[cfg_attr(feature = "config", derive(serde::Serialize, serde::Deserialize))]
178pub struct BurstConfig {
179    /// How often the burst recurs (e.g. `"10s"`).
180    pub every: String,
181    /// How long each burst lasts (e.g. `"2s"`). Must be less than `every`.
182    pub r#for: String,
183    /// Rate multiplier during the burst (must be strictly positive).
184    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/// Policy for handling sink I/O errors during a running scenario.
203///
204/// `Warn` (the default) logs a rate-limited message, increments error stats,
205/// drops the failing batch, and continues ticking. `Fail` propagates the
206/// error and terminates the scenario thread — the historical behavior.
207#[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/// Shared schedule and delivery fields common to all signal types.
217///
218/// Both [`ScenarioConfig`] (metrics) and [`LogScenarioConfig`] (logs) embed
219/// this struct via `#[serde(flatten)]`. It contains every field that is
220/// identical across signal types — everything except the generator
221/// configuration and the encoder default.
222///
223/// New schedule-level fields (rate control, windows, labels, sink, phase
224/// offset) should be added here once and automatically propagate to both
225/// signal types.
226#[derive(Debug, Clone)]
227#[cfg_attr(feature = "config", derive(serde::Serialize, serde::Deserialize))]
228pub struct BaseScheduleConfig {
229    /// Scenario name (metric name for metrics, identifier for logs).
230    pub name: String,
231    /// Target event rate in events per second. Must be strictly positive.
232    pub rate: f64,
233    /// Optional total run duration (e.g. `"30s"`, `"5m"`). `None` means run indefinitely.
234    #[cfg_attr(feature = "config", serde(default))]
235    pub duration: Option<String>,
236    /// Optional gap window: recurring silent periods in the event stream.
237    #[cfg_attr(feature = "config", serde(default))]
238    pub gaps: Option<GapConfig>,
239    /// Optional burst window: recurring high-rate periods in the event stream.
240    ///
241    /// When both a gap and a burst overlap in time, the gap takes priority.
242    #[cfg_attr(feature = "config", serde(default))]
243    pub bursts: Option<BurstConfig>,
244    /// Optional cardinality spikes: recurring windows that inject dynamic
245    /// labels to simulate cardinality explosions.
246    #[cfg_attr(feature = "config", serde(default))]
247    pub cardinality_spikes: Option<Vec<CardinalitySpikeConfig>>,
248    /// Optional dynamic labels: always-on rotating label values that cycle
249    /// through a fixed set of values on every tick.
250    ///
251    /// Unlike [`CardinalitySpikeConfig`], dynamic labels are never gated by a
252    /// time window — they appear in every emitted event. Use this to simulate
253    /// a fleet of N hosts, pods, or regions.
254    #[cfg_attr(feature = "config", serde(default))]
255    pub dynamic_labels: Option<Vec<DynamicLabelConfig>>,
256    /// Static labels attached to every emitted event.
257    #[cfg_attr(feature = "config", serde(default))]
258    pub labels: Option<HashMap<String, String>>,
259    /// Output sink. Defaults to `stdout`.
260    #[cfg_attr(feature = "config", serde(default = "default_sink"))]
261    pub sink: SinkConfig,
262    /// Delay before starting this scenario, relative to the group start time.
263    ///
264    /// Only meaningful in multi-scenario mode. Enables temporal correlation
265    /// between scenarios: "metric A starts immediately, metric B starts 30s
266    /// later". Accepts a duration string (e.g. `"30s"`, `"1m"`, `"500ms"`).
267    #[cfg_attr(feature = "config", serde(default))]
268    pub phase_offset: Option<String>,
269    /// Clock group identifier for multi-scenario correlation.
270    ///
271    /// Scenarios with the same `clock_group` value share a common start time
272    /// reference. For MVP this provides a shared start reference only; advanced
273    /// cross-scenario signaling is deferred to a future phase.
274    #[cfg_attr(feature = "config", serde(default))]
275    pub clock_group: Option<String>,
276    /// Provenance of [`Self::clock_group`] from the v2 compiler.
277    ///
278    /// Populated by [`crate::compiler::prepare`] when an entry traverses
279    /// the v2 compile pipeline. Carries:
280    ///
281    /// - `Some(true)` — the compiler synthesized
282    ///   `chain_{lowest_lex_id}` because the `after:` component had no
283    ///   user-supplied `clock_group`.
284    /// - `Some(false)` — the value was adopted from an explicit user
285    ///   assignment (including explicit values that happen to start with
286    ///   `chain_`).
287    /// - `None` — the entry did not flow through the v2 compiler (v1
288    ///   loaders, hand-built configs); display code must not show an
289    ///   `(auto)` marker.
290    ///
291    /// Hidden from YAML serialization because it is a compiler-derived
292    /// field, not user-supplied input. Skipped from deserialization for
293    /// the same reason — round-tripping a config never resurrects this
294    /// flag.
295    #[cfg_attr(feature = "config", serde(skip))]
296    pub clock_group_is_auto: Option<bool>,
297    /// Optional jitter amplitude. When set, adds uniform noise in
298    /// `[-jitter, +jitter]` to every generated value. Defaults to `None` (no jitter).
299    #[cfg_attr(feature = "config", serde(default))]
300    pub jitter: Option<f64>,
301    /// Optional seed for jitter noise. Defaults to `0` when absent.
302    /// Different seeds produce different noise sequences.
303    #[cfg_attr(feature = "config", serde(default))]
304    pub jitter_seed: Option<u64>,
305    /// Behavior when a sink write returns an I/O error mid-run.
306    #[cfg_attr(feature = "config", serde(default))]
307    pub on_sink_error: OnSinkError,
308}
309
310/// Full configuration for a single metric scenario run.
311///
312/// Embeds [`BaseScheduleConfig`] for the shared schedule and delivery fields,
313/// adding only the metric-specific value generator and a Prometheus-defaulting
314/// encoder.
315///
316/// Fields from [`BaseScheduleConfig`] are accessible directly via `Deref` (e.g.
317/// `config.name`, `config.rate`) for ergonomic read access. Struct construction
318/// uses the explicit `base` field.
319///
320/// # Example YAML
321///
322/// ```yaml
323/// name: interface_oper_state
324/// rate: 1000
325/// duration: 30s
326/// generator:
327///   type: sine
328///   amplitude: 5.0
329///   period_secs: 30
330///   offset: 10.0
331/// gaps:
332///   every: 2m
333///   for: 20s
334/// labels:
335///   hostname: t0-a1
336///   zone: eu1
337/// encoder:
338///   type: prometheus_text
339/// sink:
340///   type: stdout
341/// ```
342#[derive(Debug, Clone)]
343#[cfg_attr(feature = "config", derive(serde::Serialize, serde::Deserialize))]
344pub struct ScenarioConfig {
345    /// Shared schedule and delivery fields.
346    #[cfg_attr(feature = "config", serde(flatten))]
347    pub base: BaseScheduleConfig,
348    /// Value generator configuration.
349    pub generator: GeneratorConfig,
350    /// Output encoder. Defaults to `prometheus_text`.
351    #[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/// Distribution model configuration for histogram and summary generators.
370///
371/// Determines how sample values are distributed when the generator produces
372/// observations on each tick. Deserialized from YAML via the `type` tag.
373///
374/// # Example YAML
375///
376/// ```yaml
377/// distribution:
378///   type: exponential
379///   rate: 10.0
380/// ```
381#[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    /// Exponential distribution with rate parameter lambda.
387    ///
388    /// Mean = 1/lambda. Models latency distributions.
389    #[cfg_attr(feature = "config", serde(rename = "exponential"))]
390    Exponential {
391        /// Rate parameter (lambda). Must be strictly positive.
392        rate: f64,
393    },
394    /// Normal (Gaussian) distribution.
395    #[cfg_attr(feature = "config", serde(rename = "normal"))]
396    Normal {
397        /// Center of the distribution.
398        mean: f64,
399        /// Spread of the distribution. Must be strictly positive.
400        stddev: f64,
401    },
402    /// Uniform distribution over `[min, max]`.
403    #[cfg_attr(feature = "config", serde(rename = "uniform"))]
404    Uniform {
405        /// Lower bound (inclusive).
406        min: f64,
407        /// Upper bound (inclusive).
408        max: f64,
409    },
410}
411
412/// Full configuration for a single histogram scenario run.
413///
414/// Embeds [`BaseScheduleConfig`] for the shared schedule and delivery fields,
415/// adding histogram-specific parameters: bucket boundaries, distribution model,
416/// observations per tick, mean shift, and seed.
417///
418/// # Example YAML
419///
420/// ```yaml
421/// signal_type: histogram
422/// name: http_request_duration_seconds
423/// rate: 1
424/// duration: 5m
425/// buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]
426/// distribution:
427///   type: exponential
428///   rate: 10.0
429/// observations_per_tick: 100
430/// seed: 42
431/// labels:
432///   method: GET
433/// encoder:
434///   type: prometheus_text
435/// sink:
436///   type: stdout
437/// ```
438#[derive(Debug, Clone)]
439#[cfg_attr(feature = "config", derive(serde::Serialize, serde::Deserialize))]
440pub struct HistogramScenarioConfig {
441    /// Shared schedule and delivery fields.
442    #[cfg_attr(feature = "config", serde(flatten))]
443    pub base: BaseScheduleConfig,
444    /// Histogram bucket upper bounds. When `None`, uses the default Prometheus
445    /// bucket boundaries: `[0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]`.
446    #[cfg_attr(feature = "config", serde(default))]
447    pub buckets: Option<Vec<f64>>,
448    /// Distribution model for generating observations.
449    pub distribution: DistributionConfig,
450    /// Number of observations to sample per tick. Defaults to 100.
451    #[cfg_attr(feature = "config", serde(default))]
452    pub observations_per_tick: Option<u64>,
453    /// Linear drift applied to the distribution center per second. Defaults to 0.0.
454    #[cfg_attr(feature = "config", serde(default))]
455    pub mean_shift_per_sec: Option<f64>,
456    /// Determinism seed for the RNG. Defaults to 0.
457    #[cfg_attr(feature = "config", serde(default))]
458    pub seed: Option<u64>,
459    /// Output encoder. Defaults to `prometheus_text`.
460    #[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/// Full configuration for a single summary scenario run.
479///
480/// Embeds [`BaseScheduleConfig`] for the shared schedule and delivery fields,
481/// adding summary-specific parameters: quantile targets, distribution model,
482/// observations per tick, mean shift, and seed.
483///
484/// # Example YAML
485///
486/// ```yaml
487/// signal_type: summary
488/// name: rpc_duration_seconds
489/// rate: 1
490/// duration: 5m
491/// quantiles: [0.5, 0.9, 0.95, 0.99]
492/// distribution:
493///   type: normal
494///   mean: 0.1
495///   stddev: 0.02
496/// observations_per_tick: 100
497/// seed: 42
498/// ```
499#[derive(Debug, Clone)]
500#[cfg_attr(feature = "config", derive(serde::Serialize, serde::Deserialize))]
501pub struct SummaryScenarioConfig {
502    /// Shared schedule and delivery fields.
503    #[cfg_attr(feature = "config", serde(flatten))]
504    pub base: BaseScheduleConfig,
505    /// Quantile targets to compute. When `None`, uses default quantiles:
506    /// `[0.5, 0.9, 0.95, 0.99]`.
507    #[cfg_attr(feature = "config", serde(default))]
508    pub quantiles: Option<Vec<f64>>,
509    /// Distribution model for generating observations.
510    pub distribution: DistributionConfig,
511    /// Number of observations to sample per tick. Defaults to 100.
512    #[cfg_attr(feature = "config", serde(default))]
513    pub observations_per_tick: Option<u64>,
514    /// Linear drift applied to the distribution center per second. Defaults to 0.0.
515    #[cfg_attr(feature = "config", serde(default))]
516    pub mean_shift_per_sec: Option<f64>,
517    /// Determinism seed for the RNG. Defaults to 0.
518    #[cfg_attr(feature = "config", serde(default))]
519    pub seed: Option<u64>,
520    /// Output encoder. Defaults to `prometheus_text`.
521    #[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/// A single entry in a multi-scenario configuration.
540///
541/// The `signal_type` tag selects whether this entry is a metrics, logs,
542/// histogram, or summary scenario.
543/// Deserialized from a YAML multi-scenario file where each element of the
544/// `scenarios` list carries a `signal_type` key.
545#[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    /// A metrics scenario entry.
551    #[cfg_attr(feature = "config", serde(rename = "metrics"))]
552    Metrics(ScenarioConfig),
553    /// A logs scenario entry.
554    #[cfg_attr(feature = "config", serde(rename = "logs"))]
555    Logs(LogScenarioConfig),
556    /// A histogram scenario entry.
557    #[cfg_attr(feature = "config", serde(rename = "histogram"))]
558    Histogram(HistogramScenarioConfig),
559    /// A summary scenario entry.
560    #[cfg_attr(feature = "config", serde(rename = "summary"))]
561    Summary(SummaryScenarioConfig),
562}
563
564impl ScenarioEntry {
565    /// Return a reference to the shared [`BaseScheduleConfig`].
566    ///
567    /// Useful when only schedule-level fields (name, rate, duration, gaps,
568    /// labels, sink, etc.) are needed regardless of signal type.
569    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    /// Return the `phase_offset` duration string, if set on the inner config.
579    pub fn phase_offset(&self) -> Option<&str> {
580        self.base().phase_offset.as_deref()
581    }
582
583    /// Return the `clock_group` identifier, if set on the inner config.
584    pub fn clock_group(&self) -> Option<&str> {
585        self.base().clock_group.as_deref()
586    }
587
588    /// Return the v2-compiler-derived provenance for [`Self::clock_group`].
589    ///
590    /// `Some(true)` when the v2 compiler synthesized the `chain_<id>`
591    /// name; `Some(false)` for explicit user assignments via the v2
592    /// pipeline; `None` for entries that bypassed the v2 compiler (v1
593    /// loaders, hand-built configs).
594    pub fn clock_group_is_auto(&self) -> Option<bool> {
595        self.base().clock_group_is_auto
596    }
597
598    /// Return the human-readable signal type name for this entry.
599    ///
600    /// Matches the `signal_type:` discriminant used in v2 scenario YAML
601    /// (`"metrics"`, `"logs"`, `"histogram"`, `"summary"`). Intended for
602    /// diagnostics and user-facing mismatch messages.
603    ///
604    /// [`ScenarioEntry`] is `#[non_exhaustive]`; variants added in the
605    /// future surface here as `"unknown"` until wired in. The catch-all
606    /// arm is intra-crate-unreachable today (Rust only enforces
607    /// `#[non_exhaustive]` across crate boundaries) but is retained so
608    /// the forward-compat contract is visible at the method site.
609    #[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            // `ScenarioEntry` is `#[non_exhaustive]` across the crate boundary;
617            // future signal kinds will surface here as "unknown" until wired in.
618            _ => "unknown",
619        }
620    }
621}
622
623/// Validate the `columns` field of a `CsvReplay` generator config.
624///
625/// Returns an error when:
626/// - `columns` is `Some` but the list is empty.
627/// - `columns` contains duplicate indices.
628/// - `columns` contains duplicate metric names.
629///
630/// This validation is called before expansion so that invalid configs are
631/// rejected early with a clear error message.
632///
633/// # Errors
634///
635/// Returns [`SondaError::Config`] with a descriptive message.
636fn 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        // Reject duplicate column indices.
645        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        // Reject duplicate metric names.
656        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
669/// Read the first non-comment, non-empty line from a CSV file.
670///
671/// Uses a [`BufReader`](std::io::BufReader) to read only as many lines as
672/// needed, avoiding loading the entire file into memory.
673///
674/// # Errors
675///
676/// Returns [`SondaError::Generator`] with [`GeneratorError::FileRead`] if the
677/// file cannot be opened or read. Returns [`SondaError::Config`] if the file
678/// has no non-comment, non-empty lines.
679fn 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
710/// Re-export of the shared header detection logic from [`crate::generator::csv_header`].
711fn is_csv_header_line(line: &str) -> bool {
712    crate::generator::csv_header::is_header_line(line)
713}
714
715/// Expand a [`ScenarioConfig`] that uses multi-column `csv_replay` into N
716/// independent single-column scenarios.
717///
718/// When the `generator` is `CsvReplay` with `columns: Some(specs)`, this
719/// function returns one `ScenarioConfig` per column spec.
720///
721/// When `columns` is `None`, the function auto-discovers columns from the
722/// CSV file header. **Note:** this performs file I/O — it reads the first
723/// line of the CSV file at `generator.file` to detect the header row.
724/// If the first data line is detected as a header (any non-time field is
725/// non-numeric), column specs are built from the parsed header using
726/// [`crate::generator::csv_header`]. If the first line is all numeric
727/// (no header), an error is returned asking the user to provide explicit
728/// `columns`.
729///
730/// Each expanded config has:
731/// - `name` set to the column spec's `name`.
732/// - `generator.column` set to `Some(spec.index)`.
733/// - `generator.columns` set to `None`.
734/// - Per-column labels merged into `base.labels` (column labels override
735///   scenario-level labels on key conflict).
736/// - All other fields (rate, duration, sink, encoder, gaps, bursts,
737///   jitter, etc.) cloned from the parent.
738///
739/// # Errors
740///
741/// Returns [`SondaError::Config`] if:
742/// - `columns` is an empty list.
743/// - `columns` has duplicate indices or names.
744/// - Auto-discovery finds a column with no metric name and no fallback.
745/// - Auto-discovery finds no header row (all-numeric first line).
746pub fn expand_scenario(config: ScenarioConfig) -> Result<Vec<ScenarioConfig>, SondaError> {
747    // Only the CsvReplay variant can have `columns`.
748    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                // Auto-discover columns from the CSV header.
756                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                // Skip column 0 (timestamp).
768                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            // Merge per-column labels into the child's base labels.
808            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            // Replace the generator's column/columns fields.
816            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
832/// Expand a [`ScenarioEntry`] that uses multi-column `csv_replay`.
833///
834/// For `ScenarioEntry::Metrics`, delegates to [`expand_scenario`] and wraps
835/// the results back in `ScenarioEntry::Metrics`. For `ScenarioEntry::Logs`,
836/// returns the entry unchanged (log scenarios do not use `csv_replay`).
837///
838/// # Errors
839///
840/// Propagates errors from [`expand_scenario`].
841pub 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        // Histogram, Summary, and Logs entries do not support csv_replay expansion.
848        other => Ok(vec![other]),
849    }
850}
851
852/// Full configuration for a single log scenario run.
853///
854/// Embeds [`BaseScheduleConfig`] for the shared schedule and delivery fields,
855/// adding only the log-specific generator and a JSON-Lines-defaulting encoder.
856///
857/// Fields from [`BaseScheduleConfig`] are accessible directly via `Deref` (e.g.
858/// `config.name`, `config.rate`) for ergonomic read access. Struct construction
859/// uses the explicit `base` field.
860///
861/// # Example YAML
862///
863/// ```yaml
864/// name: app_logs
865/// rate: 10
866/// duration: 60s
867/// generator:
868///   type: template
869///   templates:
870///     - message: "Request from {ip} to {endpoint}"
871///       field_pools:
872///         ip: ["10.0.0.1", "10.0.0.2"]
873///         endpoint: ["/api", "/health"]
874///   severity_weights:
875///     info: 0.7
876///     warn: 0.2
877///     error: 0.1
878/// encoder:
879///   type: json_lines
880/// sink:
881///   type: stdout
882/// ```
883#[derive(Debug, Clone)]
884#[cfg_attr(feature = "config", derive(serde::Serialize, serde::Deserialize))]
885pub struct LogScenarioConfig {
886    /// Shared schedule and delivery fields.
887    #[cfg_attr(feature = "config", serde(flatten))]
888    pub base: BaseScheduleConfig,
889    /// Log generator configuration.
890    pub generator: LogGeneratorConfig,
891    /// Output encoder. Defaults to `json_lines`.
892    #[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    // -----------------------------------------------------------------------
917    // phase_offset deserialization: ScenarioConfig
918    // -----------------------------------------------------------------------
919
920    /// phase_offset deserializes from YAML on ScenarioConfig.
921    #[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    /// phase_offset defaults to None when not specified in YAML.
936    #[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    /// phase_offset with milliseconds deserializes correctly.
950    #[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    /// phase_offset with minutes deserializes correctly.
965    #[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    // -----------------------------------------------------------------------
980    // phase_offset deserialization: LogScenarioConfig
981    // -----------------------------------------------------------------------
982
983    /// phase_offset deserializes from YAML on LogScenarioConfig.
984    #[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    /// phase_offset defaults to None for LogScenarioConfig.
1001    #[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    // -----------------------------------------------------------------------
1017    // clock_group deserialization
1018    // -----------------------------------------------------------------------
1019
1020    /// clock_group deserializes from YAML on ScenarioConfig.
1021    #[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    /// clock_group defaults to None when absent.
1036    #[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    /// clock_group deserializes from YAML on LogScenarioConfig.
1050    #[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    /// clock_group defaults to None for LogScenarioConfig.
1067    #[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    // -----------------------------------------------------------------------
1083    // jitter deserialization
1084    // -----------------------------------------------------------------------
1085
1086    /// jitter and jitter_seed deserialize from YAML on ScenarioConfig.
1087    #[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    /// jitter defaults to None when not specified in YAML.
1104    #[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    /// jitter_seed defaults to None when only jitter is specified.
1119    #[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    /// jitter deserializes from YAML on LogScenarioConfig.
1137    #[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    // -----------------------------------------------------------------------
1156    // LogScenarioConfig: labels deserialization
1157    // -----------------------------------------------------------------------
1158
1159    /// YAML with labels section deserializes into Some(HashMap).
1160    #[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    /// YAML without labels field deserializes with labels: None.
1185    #[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    /// YAML with empty labels section deserializes as Some(empty HashMap).
1204    #[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    /// ScenarioConfig (metrics) also supports labels deserialization.
1225    #[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    // -----------------------------------------------------------------------
1244    // Both phase_offset and clock_group together
1245    // -----------------------------------------------------------------------
1246
1247    /// Both phase_offset and clock_group set on ScenarioConfig.
1248    #[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    // -----------------------------------------------------------------------
1265    // ScenarioEntry::phase_offset() accessor
1266    // -----------------------------------------------------------------------
1267
1268    /// ScenarioEntry::phase_offset() returns the phase_offset for a Metrics entry.
1269    #[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    /// ScenarioEntry::phase_offset() returns None when not set on Metrics.
1296    #[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    /// ScenarioEntry::phase_offset() returns the phase_offset for a Logs entry.
1323    #[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    // -----------------------------------------------------------------------
1357    // ScenarioEntry::clock_group() accessor
1358    // -----------------------------------------------------------------------
1359
1360    /// ScenarioEntry::clock_group() returns the value for a Metrics entry.
1361    #[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    /// ScenarioEntry::clock_group() returns None when not set.
1388    #[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    // -----------------------------------------------------------------------
1415    // ScenarioEntry::base() accessor
1416    // -----------------------------------------------------------------------
1417
1418    /// ScenarioEntry::base() returns the shared config for a Metrics entry.
1419    #[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    /// ScenarioEntry::base() returns the shared config for a Logs entry.
1447    #[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    // -----------------------------------------------------------------------
1482    // phase_offset parseable by parse_duration
1483    // -----------------------------------------------------------------------
1484
1485    /// phase_offset values are parseable by parse_duration.
1486    #[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    // -----------------------------------------------------------------------
1504    // cardinality_spikes deserialization
1505    // -----------------------------------------------------------------------
1506
1507    /// YAML with cardinality_spikes deserializes into Some(Vec).
1508    #[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    /// YAML without cardinality_spikes defaults to None.
1546    #[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    /// SpikeStrategy defaults to Counter when not specified in YAML.
1563    #[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    /// LogScenarioConfig also supports cardinality_spikes.
1583    #[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    /// Backward compatibility: existing YAML without cardinality_spikes still works.
1606    #[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    // -----------------------------------------------------------------------
1629    // BaseScheduleConfig: Clone + Debug contract
1630    // -----------------------------------------------------------------------
1631
1632    /// BaseScheduleConfig is Clone and Debug.
1633    #[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    // -----------------------------------------------------------------------
1663    // Deref: ScenarioConfig fields accessible directly
1664    // -----------------------------------------------------------------------
1665
1666    /// ScenarioConfig fields from BaseScheduleConfig are accessible via Deref.
1667    #[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        // All these access via Deref — no explicit `.base.` needed.
1691        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    /// LogScenarioConfig fields from BaseScheduleConfig are accessible via Deref.
1700    #[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    // -----------------------------------------------------------------------
1736    // DerefMut: ScenarioConfig base fields mutable via DerefMut
1737    // -----------------------------------------------------------------------
1738
1739    /// ScenarioConfig base fields can be mutated via DerefMut.
1740    #[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    // -----------------------------------------------------------------------
1772    // Flatten: YAML with base fields and generator deserializes correctly
1773    // -----------------------------------------------------------------------
1774
1775    /// ScenarioConfig deserializes with all fields at the top level (serde flatten).
1776    #[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    /// LogScenarioConfig deserializes with all fields at the top level (serde flatten).
1822    #[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    // -----------------------------------------------------------------------
1855    // Encoder defaults remain correct per signal type
1856    // -----------------------------------------------------------------------
1857
1858    /// ScenarioConfig defaults encoder to prometheus_text.
1859    #[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    /// LogScenarioConfig defaults encoder to json_lines.
1877    #[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    // -----------------------------------------------------------------------
1897    // dynamic_labels deserialization
1898    // -----------------------------------------------------------------------
1899
1900    /// dynamic_labels with counter strategy deserializes from YAML.
1901    #[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    /// dynamic_labels with values list strategy deserializes from YAML.
1934    #[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    /// dynamic_labels defaults to None when not specified.
1962    #[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    /// Multiple dynamic_labels entries deserialize correctly.
1976    #[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    /// dynamic_labels on LogScenarioConfig deserializes from YAML.
2002    #[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    /// Counter strategy with no prefix defaults prefix to None in config.
2027    #[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    /// static labels and dynamic_labels coexist in the same config.
2057    #[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    // -----------------------------------------------------------------------
2083    // csv_replay multi-column: YAML deserialization
2084    // -----------------------------------------------------------------------
2085
2086    /// csv_replay with `columns` deserializes correctly from YAML.
2087    #[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    /// csv_replay without `columns` deserializes with columns as None.
2119    #[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    // -----------------------------------------------------------------------
2144    // ScenarioEntry::signal_type_name()
2145    // -----------------------------------------------------------------------
2146
2147    /// `signal_type_name()` returns the v2 YAML discriminant string for
2148    /// every currently wired [`ScenarioEntry`] variant.
2149    #[test]
2150    fn scenario_entry_signal_type_name_covers_all_variants() {
2151        // Metrics entry.
2152        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        // Logs entry.
2164        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        // Histogram entry.
2176        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        // Summary entry.
2191        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    /// Build a base `ScenarioConfig` with a csv_replay generator for testing.
2215    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    // -----------------------------------------------------------------------
2245    // expand_scenario: pass-through (no columns)
2246    // -----------------------------------------------------------------------
2247
2248    /// When columns is None and the CSV has a header, expand_scenario
2249    /// auto-discovers columns from the header.
2250    #[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    /// When columns is None and the CSV has no header (all numeric),
2270    /// expand_scenario returns an error asking for explicit columns.
2271    #[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    /// A non-csv_replay generator passes through unchanged.
2294    #[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    // -----------------------------------------------------------------------
2323    // expand_scenario: two-column expansion
2324    // -----------------------------------------------------------------------
2325
2326    /// Two columns expand into two configs with correct names and column indices.
2327    #[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        // First expanded config
2347        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        // Second expanded config
2364        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    // -----------------------------------------------------------------------
2377    // expand_scenario: three-column expansion
2378    // -----------------------------------------------------------------------
2379
2380    /// Three columns expand into three configs.
2381    #[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        // Verify each has the correct column index
2409        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    // -----------------------------------------------------------------------
2420    // expand_scenario: inherited fields
2421    // -----------------------------------------------------------------------
2422
2423    /// Expanded configs inherit all schedule/delivery fields from the parent.
2424    #[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        // Schedule fields
2438        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        // Labels
2446        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        // Jitter
2450        assert_eq!(child.jitter, Some(0.5));
2451        assert_eq!(child.jitter_seed, Some(42));
2452
2453        // Encoder and sink
2454        assert!(matches!(
2455            child.encoder,
2456            EncoderConfig::PrometheusText { .. }
2457        ));
2458        assert!(matches!(child.sink, SinkConfig::Stdout));
2459    }
2460
2461    /// Expanded configs inherit non-None schedule fields (gaps, bursts) from the parent.
2462    #[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    // -----------------------------------------------------------------------
2494    // expand_scenario: error — empty columns list
2495    // -----------------------------------------------------------------------
2496
2497    /// An empty columns list returns an error.
2498    #[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    // -----------------------------------------------------------------------
2510    // expand_scenario: error — duplicate column indices
2511    // -----------------------------------------------------------------------
2512
2513    /// Two columns with the same index returns an error.
2514    #[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    /// Three columns where only the last two share an index.
2538    #[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    // -----------------------------------------------------------------------
2567    // expand_scenario: error — duplicate column names
2568    // -----------------------------------------------------------------------
2569
2570    /// Two columns with the same name returns an error.
2571    #[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    /// Three columns where only the last two share a name.
2595    #[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    // -----------------------------------------------------------------------
2624    // expand_entry: metrics wrapping
2625    // -----------------------------------------------------------------------
2626
2627    /// expand_entry wraps expanded metrics configs back in ScenarioEntry::Metrics.
2628    #[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    /// expand_entry passes log entries through unchanged.
2652    #[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    // -----------------------------------------------------------------------
2691    // expand_scenario: per-column labels
2692    // -----------------------------------------------------------------------
2693
2694    /// Per-column labels are merged into the child scenario's base labels.
2695    #[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        // First child should have instance=host1, plus inherited host=srv1.
2723        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        // Second child should have instance=host2, plus inherited host=srv1.
2728        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    /// Per-column labels override scenario-level labels on key conflict.
2734    #[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    /// Columns without labels do not disturb scenario-level labels.
2758    #[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    // -----------------------------------------------------------------------
2778    // expand_scenario: auto-discovery (columns: None)
2779    // -----------------------------------------------------------------------
2780
2781    /// Auto-discovery reads header from temp file and expands.
2782    #[test]
2783    fn auto_discovery_expands_from_csv_header() {
2784        use std::io::Write;
2785
2786        // Use a simpler header format — format 4 plain names.
2787        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        // Both should inherit env=test
2829        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        // Verify expanded generators have correct column indices.
2835        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        // Keep temp file alive until assertions complete.
2852        drop(tmp);
2853    }
2854
2855    /// Auto-discovery with Grafana-style headers extracts labels.
2856    #[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        // Use RFC 4180 quoting: "" inside quoted fields becomes "
2862        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        // Both should have metric name "up".
2902        assert_eq!(result[0].name, "up");
2903        assert_eq!(result[1].name, "up");
2904
2905        // First column: instance=host1, job=prom, env=production
2906        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        // Second column: instance=host2, job=node, env=production
2912        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    // -----------------------------------------------------------------------
2921    // Auto-discovery: edge cases
2922    // -----------------------------------------------------------------------
2923
2924    /// Auto-discovery on a file with no data columns (only timestamp) errors.
2925    #[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    /// A CSV with a single data column (header + values, no time column)
2971    /// auto-discovers one column, but column 0 is skipped as time, yielding
2972    /// no data columns and producing an error.
2973    #[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    /// Auto-discovery on a missing file returns a generator error.
3019    #[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    /// Auto-discovery on a file with all-numeric first row returns an error.
3055    #[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    // -----------------------------------------------------------------------
3101    // Deserialization: per-column labels
3102    // -----------------------------------------------------------------------
3103
3104    #[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                // First column has labels.
3129                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                // Second column has no labels.
3134                assert!(cols[1].labels.is_none());
3135            }
3136            other => panic!("expected CsvReplay variant, got {other:?}"),
3137        }
3138    }
3139
3140    // -----------------------------------------------------------------------
3141    // HistogramScenarioConfig deserialization
3142    // -----------------------------------------------------------------------
3143
3144    /// Histogram config deserializes from YAML with all fields.
3145    #[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    /// Histogram config with omitted optional fields uses defaults.
3172    #[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    /// Histogram config with normal distribution.
3190    #[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    /// Histogram config with uniform distribution.
3212    #[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    // -----------------------------------------------------------------------
3234    // SummaryScenarioConfig deserialization
3235    // -----------------------------------------------------------------------
3236
3237    /// Summary config deserializes from YAML with all fields.
3238    #[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    /// Summary config with omitted optional fields uses defaults.
3262    #[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    // -----------------------------------------------------------------------
3279    // ScenarioEntry: Histogram and Summary variants
3280    // -----------------------------------------------------------------------
3281
3282    /// ScenarioEntry::base() works for histogram entries.
3283    #[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    /// ScenarioEntry::base() works for summary entries.
3300    #[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    /// expand_entry passes through Histogram and Summary unchanged.
3318    #[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    /// expand_entry passes through Summary unchanged.
3351    #[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}