Skip to main content

sonda_core/generator/
mod.rs

1//! Value generators produce f64 values for each tick.
2//!
3//! All generators implement the `ValueGenerator` trait and are constructed
4//! via `create_generator()` which returns `Box<dyn ValueGenerator>`.
5//!
6//! Log generators implement the `LogGenerator` trait and produce `LogEvent`
7//! values. They are constructed via `create_log_generator()`.
8//!
9//! Histogram and summary generators produce multi-valued samples per tick
10//! (bucket counts + count + sum, or quantile values + count + sum). They
11//! hold cumulative state and do not implement `ValueGenerator`. See
12//! [`histogram::HistogramGenerator`] and [`summary::SummaryGenerator`].
13
14pub mod constant;
15pub mod csv_header;
16pub mod csv_replay;
17pub mod histogram;
18pub mod jitter;
19pub mod log_replay;
20pub mod log_template;
21pub mod sawtooth;
22pub mod sequence;
23pub mod sine;
24pub mod spike;
25pub mod step;
26pub mod summary;
27pub mod uniform;
28
29pub use self::jitter::JitterWrapper;
30
31use std::collections::{BTreeMap, HashMap};
32
33use self::constant::Constant;
34use self::csv_replay::CsvReplayGenerator;
35use self::log_replay::LogReplayGenerator;
36use self::log_template::{LogTemplateGenerator, TemplateEntry};
37use self::sawtooth::Sawtooth;
38use self::sequence::SequenceGenerator;
39use self::sine::Sine;
40use self::spike::SpikeGenerator;
41use self::step::StepGenerator;
42use self::uniform::UniformRandom;
43use crate::model::log::{LogEvent, Severity};
44use crate::{ConfigError, SondaError};
45
46/// A generator produces a single f64 value for a given tick index.
47///
48/// Implementations must be deterministic for a given configuration and tick.
49/// Side effects are not allowed in `value()`.
50pub trait ValueGenerator: Send + Sync {
51    /// Produce a value for the given tick index (0-based, monotonically increasing).
52    fn value(&self, tick: u64) -> f64;
53}
54
55/// Specification for a single CSV column in a multi-column `csv_replay`
56/// configuration.
57///
58/// When the `columns` field is set on a `CsvReplay` generator config, each
59/// `CsvColumnSpec` specifies a column index and the metric name to use when
60/// that column is expanded into its own independent scenario.
61///
62/// # Example YAML
63///
64/// ```yaml
65/// columns:
66///   - index: 1
67///     name: cpu_percent
68///   - index: 2
69///     name: mem_percent
70/// ```
71#[derive(Debug, Clone, PartialEq, Eq)]
72#[cfg_attr(feature = "config", derive(serde::Serialize, serde::Deserialize))]
73pub struct CsvColumnSpec {
74    /// Zero-based column index in the CSV file.
75    pub index: usize,
76    /// Metric name for the expanded scenario.
77    pub name: String,
78    /// Optional per-column labels merged with scenario-level labels during
79    /// expansion. Column labels override scenario-level labels on key conflict.
80    #[cfg_attr(feature = "config", serde(default))]
81    pub labels: Option<HashMap<String, String>>,
82}
83
84/// Domain-specific value mapping for the [`GeneratorConfig::Flap`] alias.
85///
86/// The variant selects an `(up_value, down_value)` pair aligned with
87/// gNMI / openconfig conventions:
88///
89/// | Variant | `up_value` | `down_value` | Convention |
90/// |---|---|---|---|
91/// | `Boolean` | 1.0 | 0.0 | Generic boolean |
92/// | `LinkState` | 1.0 | 0.0 | Synonym of `Boolean` |
93/// | `OperState` | 1.0 | 2.0 | gNMI oper-state (UP=1, DOWN=2) |
94/// | `AdminState` | 1.0 | 2.0 | gNMI admin-state (UP=1, DOWN=2) |
95/// | `NeighborState` | 6.0 | 1.0 | BGP neighbor-state (ESTABLISHED=6, IDLE=1) |
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97#[cfg_attr(
98    feature = "config",
99    derive(serde::Serialize, serde::Deserialize),
100    serde(rename_all = "snake_case")
101)]
102pub enum FlapEnum {
103    Boolean,
104    LinkState,
105    OperState,
106    AdminState,
107    NeighborState,
108}
109
110impl FlapEnum {
111    /// Return the `(up_value, down_value)` pair this variant selects.
112    pub fn defaults(self) -> (f64, f64) {
113        match self {
114            FlapEnum::Boolean | FlapEnum::LinkState => (1.0, 0.0),
115            FlapEnum::OperState | FlapEnum::AdminState => (1.0, 2.0),
116            FlapEnum::NeighborState => (6.0, 1.0),
117        }
118    }
119}
120
121/// Configuration for a value generator, used for YAML deserialization.
122///
123/// The `type` field selects which generator to instantiate. Additional fields
124/// are specific to each variant.
125///
126/// # Core generators
127///
128/// ```yaml
129/// generator:
130///   type: sine
131///   amplitude: 5.0
132///   period_secs: 30
133///   offset: 10.0
134/// ```
135///
136/// # Operational aliases
137///
138/// Aliases desugar into core generators at config expansion time. They use
139/// domain-relevant parameter names and have sensible defaults.
140///
141/// ```yaml
142/// # Normal healthy oscillation (desugars to sine + jitter)
143/// generator:
144///   type: steady
145///   center: 75.0
146///   amplitude: 10.0
147///   period: "60s"
148///   noise: 2.0
149///
150/// # Resource leak (desugars to sawtooth)
151/// generator:
152///   type: leak
153///   baseline: 40.0
154///   ceiling: 95.0
155///   time_to_ceiling: "120s"
156///
157/// # Interface flap (desugars to sequence)
158/// generator:
159///   type: flap
160///   up_duration: "10s"
161///   down_duration: "5s"
162/// ```
163#[derive(Debug, Clone)]
164#[cfg_attr(feature = "config", derive(serde::Serialize, serde::Deserialize))]
165#[cfg_attr(feature = "config", serde(tag = "type"))]
166#[non_exhaustive]
167pub enum GeneratorConfig {
168    /// A generator that always returns the same value.
169    #[cfg_attr(feature = "config", serde(rename = "constant"))]
170    Constant {
171        /// The fixed value returned on every tick.
172        value: f64,
173    },
174    /// A generator that returns deterministically random values in `[min, max]`.
175    #[cfg_attr(feature = "config", serde(rename = "uniform"))]
176    Uniform {
177        /// Lower bound of the output range (inclusive).
178        min: f64,
179        /// Upper bound of the output range (inclusive).
180        max: f64,
181        /// Optional seed for deterministic replay. Defaults to 0 when absent.
182        seed: Option<u64>,
183    },
184    /// A generator that follows a sine curve.
185    #[cfg_attr(feature = "config", serde(rename = "sine"))]
186    Sine {
187        /// Half the peak-to-peak swing of the wave.
188        amplitude: f64,
189        /// Duration of one full cycle in seconds.
190        period_secs: f64,
191        /// Vertical offset applied to every sample (the wave's midpoint).
192        offset: f64,
193    },
194    /// A generator that linearly ramps from `min` to `max` then resets.
195    #[cfg_attr(feature = "config", serde(rename = "sawtooth"))]
196    Sawtooth {
197        /// Value at the start of each period.
198        min: f64,
199        /// Value approached at the end of each period (never reached).
200        max: f64,
201        /// Duration of one full ramp in seconds.
202        period_secs: f64,
203    },
204    /// A generator that steps through an explicit sequence of values.
205    #[cfg_attr(feature = "config", serde(rename = "sequence"))]
206    Sequence {
207        /// The ordered list of values to step through. Must not be empty.
208        values: Vec<f64>,
209        /// When true (default), the sequence cycles. When false, the last value
210        /// is returned for all ticks beyond the sequence length.
211        repeat: Option<bool>,
212    },
213    /// A generator that outputs a baseline value with periodic spikes.
214    #[cfg_attr(feature = "config", serde(rename = "spike"))]
215    Spike {
216        /// The normal output value between spikes.
217        baseline: f64,
218        /// The amount added to baseline during a spike.
219        magnitude: f64,
220        /// How long each spike lasts in seconds.
221        duration_secs: f64,
222        /// Time between spike starts in seconds.
223        interval_secs: f64,
224    },
225    /// A generator that replays numeric values from a CSV file.
226    #[cfg_attr(feature = "config", serde(rename = "csv_replay"))]
227    CsvReplay {
228        /// Path to the CSV file containing numeric values.
229        file: String,
230        /// Internal: zero-based column index, set by `expand_scenario`.
231        ///
232        /// Not user-facing in YAML — set during config expansion. When
233        /// `None`, defaults to `0` at generator creation time.
234        #[cfg_attr(feature = "config", serde(skip))]
235        column: Option<usize>,
236        /// Explicit column specifications. When present, the config layer
237        /// expands this single scenario into N independent single-column
238        /// scenarios before launch.
239        ///
240        /// When absent, columns are auto-discovered from the CSV header row.
241        /// An empty list is an error.
242        #[cfg_attr(feature = "config", serde(default))]
243        columns: Option<Vec<CsvColumnSpec>>,
244        /// Whether to loop back to the first value after exhausting the CSV.
245        /// Defaults to `true`.
246        #[cfg_attr(feature = "config", serde(default))]
247        repeat: Option<bool>,
248    },
249    /// A monotonic step counter: `start + tick * step_size`, with optional wrap-around.
250    ///
251    /// Useful for testing `rate()` and `increase()` PromQL functions.
252    #[cfg_attr(feature = "config", serde(rename = "step"))]
253    Step {
254        /// Initial value at tick 0. Defaults to 0.0 when absent.
255        #[cfg_attr(feature = "config", serde(default))]
256        start: Option<f64>,
257        /// Increment applied per tick.
258        step_size: f64,
259        /// Optional wrap-around threshold. When set and greater than `start`,
260        /// the value wraps via modular arithmetic.
261        max: Option<f64>,
262    },
263
264    // -----------------------------------------------------------------
265    // Operational aliases — syntactic sugar that desugars into the above
266    // generators at config expansion time. The runtime never sees these.
267    // -----------------------------------------------------------------
268    /// Binary up/down toggle modeling an interface flap.
269    ///
270    /// Desugars into a [`Sequence`](GeneratorConfig::Sequence) generator that
271    /// alternates between `up_value` (default 1.0) and `down_value` (default 0.0).
272    /// The number of consecutive up/down ticks is derived from `up_duration` and
273    /// `down_duration` relative to the scenario `rate`.
274    ///
275    /// The optional `enum:` shorthand selects up/down values aligned with
276    /// common gNMI / openconfig conventions (oper-state, admin-state,
277    /// BGP neighbor-state). Mutually exclusive with explicit `up_value` /
278    /// `down_value`.
279    ///
280    /// # Example YAML
281    ///
282    /// ```yaml
283    /// generator:
284    ///   type: flap
285    ///   up_duration: "10s"
286    ///   down_duration: "5s"
287    ///   enum: oper_state    # up_value=1.0, down_value=2.0
288    /// ```
289    #[cfg_attr(feature = "config", serde(rename = "flap"))]
290    Flap {
291        /// How long the signal stays in the "up" state per cycle.
292        /// Defaults to `"10s"`.
293        #[cfg_attr(feature = "config", serde(default))]
294        up_duration: Option<String>,
295        /// How long the signal stays in the "down" state per cycle.
296        /// Defaults to `"5s"`.
297        #[cfg_attr(feature = "config", serde(default))]
298        down_duration: Option<String>,
299        /// Value emitted during the "up" state. Defaults to `1.0`.
300        #[cfg_attr(feature = "config", serde(default))]
301        up_value: Option<f64>,
302        /// Value emitted during the "down" state. Defaults to `0.0`.
303        #[cfg_attr(feature = "config", serde(default))]
304        down_value: Option<f64>,
305        /// Domain-specific shorthand selecting `(up_value, down_value)` per
306        /// the [`FlapEnum`] mapping. Mutually exclusive with `up_value` /
307        /// `down_value`.
308        #[cfg_attr(
309            feature = "config",
310            serde(default, rename = "enum", skip_serializing_if = "Option::is_none")
311        )]
312        enum_kind: Option<FlapEnum>,
313    },
314
315    /// Resource filling up and resetting on a repeating cycle (e.g. disk usage
316    /// sawtoothing after log rotation).
317    ///
318    /// Desugars into a [`Sawtooth`](GeneratorConfig::Sawtooth) generator with
319    /// `min = baseline`, `max = ceiling`, `period_secs` derived from
320    /// `time_to_saturate`. The sawtooth resets to `baseline` after each
321    /// `time_to_saturate` period, modeling a resource that fills and is
322    /// periodically reclaimed.
323    ///
324    /// # Distinction from `Leak`
325    ///
326    /// - **Saturation**: repeating fill-and-reset cycle. Default period is
327    ///   `"5m"`.
328    /// - **Leak**: one-way ramp, no reset expected within the scenario
329    ///   duration. Default period is `"10m"`.
330    ///
331    /// # Example YAML
332    ///
333    /// ```yaml
334    /// generator:
335    ///   type: saturation
336    ///   baseline: 20.0
337    ///   ceiling: 95.0
338    ///   time_to_saturate: "5m"
339    /// ```
340    #[cfg_attr(feature = "config", serde(rename = "saturation"))]
341    Saturation {
342        /// Resource level at the start of each cycle. Defaults to `0.0`.
343        #[cfg_attr(feature = "config", serde(default))]
344        baseline: Option<f64>,
345        /// Maximum resource level before reset. Defaults to `100.0`.
346        #[cfg_attr(feature = "config", serde(default))]
347        ceiling: Option<f64>,
348        /// Duration of one fill cycle. Defaults to `"5m"`.
349        #[cfg_attr(feature = "config", serde(default))]
350        time_to_saturate: Option<String>,
351    },
352
353    /// Resource growing toward a ceiling without resetting — a one-way ramp
354    /// modeling a memory leak or similar resource exhaustion.
355    ///
356    /// Desugars into a [`Sawtooth`](GeneratorConfig::Sawtooth) generator.
357    /// The intent is that `time_to_ceiling` equals or exceeds the scenario
358    /// `duration` so values only ramp upward and never reset within the run.
359    /// If the scenario has a `duration` set and `time_to_ceiling` is shorter
360    /// than that duration, desugaring returns a config error because the
361    /// sawtooth would reset mid-run, which is the
362    /// [`Saturation`](GeneratorConfig::Saturation) pattern instead.
363    ///
364    /// # Distinction from `Saturation`
365    ///
366    /// - **Leak**: one-way ramp, no reset expected. `time_to_ceiling` should
367    ///   be >= scenario `duration`. Default period is `"10m"`.
368    /// - **Saturation**: repeating fill-and-reset cycle. Default period is
369    ///   `"5m"`.
370    ///
371    /// # Example YAML
372    ///
373    /// ```yaml
374    /// generator:
375    ///   type: leak
376    ///   baseline: 40.0
377    ///   ceiling: 95.0
378    ///   time_to_ceiling: "120s"
379    /// ```
380    #[cfg_attr(feature = "config", serde(rename = "leak"))]
381    Leak {
382        /// Initial resource level. Defaults to `0.0`.
383        #[cfg_attr(feature = "config", serde(default))]
384        baseline: Option<f64>,
385        /// Target ceiling value. Defaults to `100.0`.
386        #[cfg_attr(feature = "config", serde(default))]
387        ceiling: Option<f64>,
388        /// Time to grow from baseline to ceiling. Defaults to `"10m"`.
389        /// The sawtooth period is set to this value so values only ramp
390        /// upward within the scenario duration.
391        #[cfg_attr(feature = "config", serde(default))]
392        time_to_ceiling: Option<String>,
393    },
394
395    /// Gradual performance loss with noise — models degradation over time
396    /// (e.g. growing latency, increasing error rate).
397    ///
398    /// Desugars into a [`Sawtooth`](GeneratorConfig::Sawtooth) generator with
399    /// jitter automatically applied on [`BaseScheduleConfig`].
400    ///
401    /// # Example YAML
402    ///
403    /// ```yaml
404    /// generator:
405    ///   type: degradation
406    ///   baseline: 0.05
407    ///   ceiling: 0.5
408    ///   time_to_degrade: "60s"
409    ///   noise: 0.02
410    /// ```
411    #[cfg_attr(feature = "config", serde(rename = "degradation"))]
412    Degradation {
413        /// Starting performance level. Defaults to `0.0`.
414        #[cfg_attr(feature = "config", serde(default))]
415        baseline: Option<f64>,
416        /// Worst-case performance level. Defaults to `100.0`.
417        #[cfg_attr(feature = "config", serde(default))]
418        ceiling: Option<f64>,
419        /// Duration of the degradation ramp. Defaults to `"5m"`.
420        #[cfg_attr(feature = "config", serde(default))]
421        time_to_degrade: Option<String>,
422        /// Jitter amplitude added as noise. Defaults to `1.0`.
423        #[cfg_attr(feature = "config", serde(default))]
424        noise: Option<f64>,
425        /// Seed for the noise generator. Defaults to `0`.
426        #[cfg_attr(feature = "config", serde(default))]
427        noise_seed: Option<u64>,
428    },
429
430    /// Normal healthy oscillation around a center value — the "everything is
431    /// fine" baseline signal.
432    ///
433    /// Desugars into a [`Sine`](GeneratorConfig::Sine) generator with jitter
434    /// automatically applied on [`BaseScheduleConfig`].
435    ///
436    /// # Example YAML
437    ///
438    /// ```yaml
439    /// generator:
440    ///   type: steady
441    ///   center: 75.0
442    ///   amplitude: 10.0
443    ///   period: "60s"
444    ///   noise: 2.0
445    /// ```
446    #[cfg_attr(feature = "config", serde(rename = "steady"))]
447    Steady {
448        /// Center of the oscillation (the sine wave's offset). Defaults to `50.0`.
449        #[cfg_attr(feature = "config", serde(default))]
450        center: Option<f64>,
451        /// Half the peak-to-peak swing. Defaults to `10.0`.
452        #[cfg_attr(feature = "config", serde(default))]
453        amplitude: Option<f64>,
454        /// Duration of one full oscillation cycle. Defaults to `"60s"`.
455        #[cfg_attr(feature = "config", serde(default))]
456        period: Option<String>,
457        /// Jitter amplitude added as noise. Defaults to `1.0`.
458        #[cfg_attr(feature = "config", serde(default))]
459        noise: Option<f64>,
460        /// Seed for the noise generator. Defaults to `0`.
461        #[cfg_attr(feature = "config", serde(default))]
462        noise_seed: Option<u64>,
463    },
464
465    /// Periodic anomalous bursts above a baseline — models sudden spikes
466    /// in CPU, memory, or request rate.
467    ///
468    /// Desugars into a [`Spike`](GeneratorConfig::Spike) generator.
469    ///
470    /// # Example YAML
471    ///
472    /// ```yaml
473    /// generator:
474    ///   type: spike_event
475    ///   baseline: 35.0
476    ///   spike_height: 60.0
477    ///   spike_duration: "10s"
478    ///   spike_interval: "30s"
479    /// ```
480    #[cfg_attr(feature = "config", serde(rename = "spike_event"))]
481    SpikeEvent {
482        /// Normal output value between spikes. Defaults to `0.0`.
483        #[cfg_attr(feature = "config", serde(default))]
484        baseline: Option<f64>,
485        /// Amount added to baseline during a spike. Defaults to `100.0`.
486        #[cfg_attr(feature = "config", serde(default))]
487        spike_height: Option<f64>,
488        /// How long each spike lasts. Defaults to `"10s"`.
489        #[cfg_attr(feature = "config", serde(default))]
490        spike_duration: Option<String>,
491        /// Time between spike starts. Defaults to `"30s"`.
492        #[cfg_attr(feature = "config", serde(default))]
493        spike_interval: Option<String>,
494    },
495}
496
497impl GeneratorConfig {
498    /// Returns `true` if this variant is an operational alias that must be
499    /// desugared before the generator factory can process it.
500    pub fn is_alias(&self) -> bool {
501        matches!(
502            self,
503            GeneratorConfig::Flap { .. }
504                | GeneratorConfig::Saturation { .. }
505                | GeneratorConfig::Leak { .. }
506                | GeneratorConfig::Degradation { .. }
507                | GeneratorConfig::Steady { .. }
508                | GeneratorConfig::SpikeEvent { .. }
509        )
510    }
511}
512
513/// Construct a `Box<dyn ValueGenerator>` from the given configuration.
514///
515/// The `rate` parameter (events per second) is required by time-based generators
516/// (`Sine`, `Sawtooth`) to convert `period_secs` into period ticks.
517///
518/// # Errors
519///
520/// Returns [`SondaError::Config`] if the generator configuration is invalid
521/// (e.g., an empty values list for the sequence generator).
522///
523/// **Note:** [`GeneratorConfig::CsvReplay`] configs with `columns` set must be expanded
524/// via [`crate::config::expand_scenario`] before calling this function. Passing an
525/// unexpanded multi-column config returns a [`ConfigError`].
526pub fn create_generator(
527    config: &GeneratorConfig,
528    rate: f64,
529) -> Result<Box<dyn ValueGenerator>, SondaError> {
530    match config {
531        GeneratorConfig::Constant { value } => Ok(Box::new(Constant::new(*value))),
532        GeneratorConfig::Uniform { min, max, seed } => {
533            Ok(Box::new(UniformRandom::new(*min, *max, seed.unwrap_or(0))))
534        }
535        GeneratorConfig::Sine {
536            amplitude,
537            period_secs,
538            offset,
539        } => Ok(Box::new(Sine::new(*amplitude, *period_secs, *offset, rate))),
540        GeneratorConfig::Sawtooth {
541            min,
542            max,
543            period_secs,
544        } => Ok(Box::new(Sawtooth::new(*min, *max, *period_secs, rate))),
545        GeneratorConfig::Spike {
546            baseline,
547            magnitude,
548            duration_secs,
549            interval_secs,
550        } => {
551            if *interval_secs <= 0.0 {
552                return Err(SondaError::Config(ConfigError::invalid(
553                    "spike generator requires interval_secs > 0",
554                )));
555            }
556            if *duration_secs < 0.0 {
557                return Err(SondaError::Config(ConfigError::invalid(
558                    "spike generator requires duration_secs >= 0",
559                )));
560            }
561            Ok(Box::new(SpikeGenerator::new(
562                *baseline,
563                *magnitude,
564                *duration_secs,
565                *interval_secs,
566                rate,
567            )))
568        }
569        GeneratorConfig::Sequence { values, repeat } => Ok(Box::new(SequenceGenerator::new(
570            values.clone(),
571            repeat.unwrap_or(true),
572        )?)),
573        GeneratorConfig::CsvReplay {
574            file,
575            column,
576            repeat,
577            columns,
578        } => {
579            if columns.is_some() {
580                return Err(SondaError::Config(ConfigError::invalid(
581                    "csv_replay: call expand_scenario before create_generator when 'columns' is set",
582                )));
583            }
584            Ok(Box::new(CsvReplayGenerator::new(
585                file,
586                column.unwrap_or(0),
587                repeat.unwrap_or(true),
588            )?))
589        }
590        GeneratorConfig::Step {
591            start,
592            step_size,
593            max,
594        } => Ok(Box::new(StepGenerator::new(
595            start.unwrap_or(0.0),
596            *step_size,
597            *max,
598        ))),
599        // Operational aliases must be desugared before reaching this factory.
600        // If one arrives here it means the config expansion pipeline was bypassed.
601        GeneratorConfig::Flap { .. }
602        | GeneratorConfig::Saturation { .. }
603        | GeneratorConfig::Leak { .. }
604        | GeneratorConfig::Degradation { .. }
605        | GeneratorConfig::Steady { .. }
606        | GeneratorConfig::SpikeEvent { .. } => Err(SondaError::Config(ConfigError::invalid(
607            "operational alias generator must be desugared via \
608             desugar_entry() or desugar_scenario_config() before calling create_generator()",
609        ))),
610    }
611}
612
613/// Optionally wrap a generator with jitter noise.
614///
615/// Returns the generator unchanged if `jitter` is `None` or `Some(0.0)`.
616/// When jitter is positive, wraps the generator in a [`JitterWrapper`] that
617/// adds deterministic uniform noise in `[-jitter, +jitter]` to every value.
618///
619/// # Parameters
620///
621/// - `generator` — the inner generator to wrap.
622/// - `jitter` — the jitter amplitude. `None` or `Some(0.0)` means no jitter.
623/// - `jitter_seed` — optional seed for the noise sequence. Defaults to `0`
624///   when `None`.
625pub fn wrap_with_jitter(
626    generator: Box<dyn ValueGenerator>,
627    jitter: Option<f64>,
628    jitter_seed: Option<u64>,
629) -> Box<dyn ValueGenerator> {
630    match jitter {
631        Some(j) if j != 0.0 => Box::new(JitterWrapper::new(generator, j, jitter_seed.unwrap_or(0))),
632        _ => generator,
633    }
634}
635
636// ---------------------------------------------------------------------------
637// Log generators
638// ---------------------------------------------------------------------------
639
640/// A log generator produces a `LogEvent` for a given tick index.
641///
642/// Implementations must be deterministic for a given configuration and tick.
643/// Side effects are not allowed in `generate()`.
644pub trait LogGenerator: Send + Sync {
645    /// Produce a `LogEvent` for the given tick index (0-based, monotonically increasing).
646    fn generate(&self, tick: u64) -> LogEvent;
647}
648
649/// Configuration for one message template used by [`LogGeneratorConfig::Template`].
650///
651/// The `message` field may contain `{placeholder}` tokens that are resolved
652/// using the corresponding value pool from `field_pools`.
653///
654/// # Example YAML
655///
656/// ```yaml
657/// message: "Request from {ip} to {endpoint}"
658/// field_pools:
659///   ip:
660///     - "10.0.0.1"
661///     - "10.0.0.2"
662///   endpoint:
663///     - "/api"
664///     - "/health"
665/// ```
666#[derive(Debug, Clone)]
667#[cfg_attr(feature = "config", derive(serde::Serialize, serde::Deserialize))]
668pub struct TemplateConfig {
669    /// The message template. Use `{field_name}` for dynamic placeholders.
670    pub message: String,
671    /// Maps placeholder names to their value pools.
672    ///
673    /// Uses `BTreeMap` for deterministic iteration order, matching the
674    /// codebase convention for ordered maps.
675    #[cfg_attr(feature = "config", serde(default))]
676    pub field_pools: BTreeMap<String, Vec<String>>,
677}
678
679/// Configuration for a log generator, used for YAML deserialization.
680///
681/// The `type` field selects which generator to instantiate.
682///
683/// # Example YAML — template generator
684///
685/// ```yaml
686/// generator:
687///   type: template
688///   templates:
689///     - message: "Request from {ip} to {endpoint}"
690///       field_pools:
691///         ip: ["10.0.0.1", "10.0.0.2"]
692///         endpoint: ["/api", "/health"]
693///   severity_weights:
694///     info: 0.7
695///     warn: 0.2
696///     error: 0.1
697///   seed: 42
698/// ```
699///
700/// # Example YAML — replay generator
701///
702/// ```yaml
703/// generator:
704///   type: replay
705///   file: /var/log/app.log
706/// ```
707#[derive(Debug, Clone)]
708#[cfg_attr(feature = "config", derive(serde::Serialize, serde::Deserialize))]
709#[cfg_attr(feature = "config", serde(tag = "type"))]
710pub enum LogGeneratorConfig {
711    /// Generates events from message templates with randomized field pool values.
712    #[cfg_attr(feature = "config", serde(rename = "template"))]
713    Template {
714        /// One or more template entries. Templates are selected round-robin by tick.
715        templates: Vec<TemplateConfig>,
716        /// Optional severity weight map. Keys are severity names (`info`, `warn`, etc.),
717        /// values are relative weights. Defaults to `info: 1.0` when absent.
718        #[cfg_attr(feature = "config", serde(default))]
719        severity_weights: Option<HashMap<String, f64>>,
720        /// Seed for deterministic replay. Defaults to `0` when absent.
721        seed: Option<u64>,
722    },
723    /// Replays lines from a file, cycling back to the start when exhausted.
724    #[cfg_attr(feature = "config", serde(rename = "replay"))]
725    Replay {
726        /// Path to the file containing log lines to replay.
727        file: String,
728    },
729}
730
731/// Parse a severity name string into a [`Severity`] variant.
732fn parse_severity(s: &str) -> Result<Severity, SondaError> {
733    match s.to_lowercase().as_str() {
734        "trace" => Ok(Severity::Trace),
735        "debug" => Ok(Severity::Debug),
736        "info" => Ok(Severity::Info),
737        "warn" | "warning" => Ok(Severity::Warn),
738        "error" => Ok(Severity::Error),
739        "fatal" => Ok(Severity::Fatal),
740        other => Err(SondaError::Config(ConfigError::invalid(format!(
741            "unknown severity {:?}: must be one of trace, debug, info, warn, error, fatal",
742            other
743        )))),
744    }
745}
746
747/// Construct a `Box<dyn LogGenerator>` from the given configuration.
748///
749/// # Errors
750/// - Returns [`SondaError::Config`] if severity weight keys are invalid.
751/// - Returns [`SondaError::Config`] if the replay file is empty or cannot be parsed.
752/// - Returns [`SondaError::Generator`] if the replay file cannot be read.
753pub fn create_log_generator(
754    config: &LogGeneratorConfig,
755) -> Result<Box<dyn LogGenerator>, SondaError> {
756    match config {
757        LogGeneratorConfig::Template {
758            templates,
759            severity_weights,
760            seed,
761        } => {
762            let seed = seed.unwrap_or(0);
763
764            // Build severity weight vector from the optional map.
765            let weights: Vec<(Severity, f64)> = if let Some(map) = severity_weights {
766                let mut pairs = Vec::with_capacity(map.len());
767                for (name, weight) in map {
768                    let severity = parse_severity(name)?;
769                    pairs.push((severity, *weight));
770                }
771                // Sort by severity ordinal for deterministic ordering.
772                pairs.sort_by_key(|a| a.0);
773                pairs
774            } else {
775                vec![]
776            };
777
778            // Convert TemplateConfig into TemplateEntry.
779            let entries: Vec<TemplateEntry> = templates
780                .iter()
781                .map(|tc| TemplateEntry {
782                    message: tc.message.clone(),
783                    field_pools: tc.field_pools.clone(),
784                })
785                .collect();
786
787            Ok(Box::new(LogTemplateGenerator::new(entries, weights, seed)))
788        }
789        LogGeneratorConfig::Replay { file } => {
790            let path = std::path::Path::new(file);
791            Ok(Box::new(LogReplayGenerator::from_file(path)?))
792        }
793    }
794}
795
796#[cfg(test)]
797mod tests {
798    use super::*;
799
800    // ---- Factory tests -------------------------------------------------------
801
802    #[test]
803    fn factory_constant_returns_configured_value() {
804        let config = GeneratorConfig::Constant { value: 1.0 };
805        let gen = create_generator(&config, 100.0).expect("constant factory");
806        assert_eq!(gen.value(0), 1.0);
807        assert_eq!(gen.value(1_000_000), 1.0);
808    }
809
810    #[test]
811    fn factory_uniform_returns_values_in_range() {
812        let config = GeneratorConfig::Uniform {
813            min: 0.0,
814            max: 1.0,
815            seed: Some(7),
816        };
817        let gen = create_generator(&config, 100.0).expect("uniform factory");
818        for tick in 0..1000 {
819            let v = gen.value(tick);
820            assert!(
821                v >= 0.0 && v <= 1.0,
822                "uniform value {v} out of [0,1] at tick {tick}"
823            );
824        }
825    }
826
827    #[test]
828    fn factory_uniform_seed_none_defaults_to_zero_seed() {
829        // When seed is None the factory must behave the same as seed Some(0).
830        let config_none = GeneratorConfig::Uniform {
831            min: 0.0,
832            max: 1.0,
833            seed: None,
834        };
835        let config_zero = GeneratorConfig::Uniform {
836            min: 0.0,
837            max: 1.0,
838            seed: Some(0),
839        };
840        let gen_none = create_generator(&config_none, 1.0).expect("uniform none factory");
841        let gen_zero = create_generator(&config_zero, 1.0).expect("uniform zero factory");
842        for tick in 0..100 {
843            assert_eq!(
844                gen_none.value(tick),
845                gen_zero.value(tick),
846                "seed=None must equal seed=Some(0) at tick {tick}"
847            );
848        }
849    }
850
851    #[test]
852    fn factory_sine_value_at_zero_equals_offset() {
853        let config = GeneratorConfig::Sine {
854            amplitude: 5.0,
855            period_secs: 10.0,
856            offset: 3.0,
857        };
858        let gen = create_generator(&config, 1.0).expect("sine factory");
859        assert!(
860            (gen.value(0) - 3.0).abs() < 1e-10,
861            "sine factory: value(0) must equal offset"
862        );
863    }
864
865    #[test]
866    fn factory_sawtooth_value_at_zero_equals_min() {
867        let config = GeneratorConfig::Sawtooth {
868            min: 5.0,
869            max: 15.0,
870            period_secs: 10.0,
871        };
872        let gen = create_generator(&config, 1.0).expect("sawtooth factory");
873        assert_eq!(
874            gen.value(0),
875            5.0,
876            "sawtooth factory: value(0) must equal min"
877        );
878    }
879
880    // ---- Sequence factory tests -----------------------------------------------
881
882    #[test]
883    fn factory_sequence_repeat_true_creates_working_generator() {
884        let config = GeneratorConfig::Sequence {
885            values: vec![1.0, 2.0, 3.0],
886            repeat: Some(true),
887        };
888        let gen = create_generator(&config, 1.0).expect("sequence factory repeat=true");
889        assert_eq!(gen.value(0), 1.0);
890        assert_eq!(gen.value(1), 2.0);
891        assert_eq!(gen.value(2), 3.0);
892        assert_eq!(gen.value(3), 1.0, "should wrap around");
893    }
894
895    #[test]
896    fn factory_sequence_repeat_false_creates_working_generator() {
897        let config = GeneratorConfig::Sequence {
898            values: vec![1.0, 2.0, 3.0],
899            repeat: Some(false),
900        };
901        let gen = create_generator(&config, 1.0).expect("sequence factory repeat=false");
902        assert_eq!(gen.value(0), 1.0);
903        assert_eq!(gen.value(4), 3.0, "should clamp to last value");
904    }
905
906    #[test]
907    fn factory_sequence_repeat_none_defaults_to_true() {
908        let config = GeneratorConfig::Sequence {
909            values: vec![1.0, 2.0],
910            repeat: None,
911        };
912        let gen = create_generator(&config, 1.0).expect("sequence factory repeat=None");
913        // With repeat defaulting to true, tick=2 on a 2-element seq should wrap to index 0
914        assert_eq!(
915            gen.value(2),
916            1.0,
917            "repeat=None should default to true (cycling)"
918        );
919    }
920
921    #[test]
922    fn factory_sequence_empty_values_returns_error() {
923        let config = GeneratorConfig::Sequence {
924            values: vec![],
925            repeat: Some(true),
926        };
927        let result = create_generator(&config, 1.0);
928        assert!(result.is_err(), "empty sequence must return an error");
929    }
930
931    // ---- Step factory tests ---------------------------------------------------
932
933    #[test]
934    fn factory_step_linear_growth() {
935        let config = GeneratorConfig::Step {
936            start: None,
937            step_size: 1.0,
938            max: None,
939        };
940        let gen = create_generator(&config, 1.0).expect("step factory");
941        assert_eq!(gen.value(0), 0.0);
942        assert_eq!(gen.value(1), 1.0);
943        assert_eq!(gen.value(100), 100.0);
944    }
945
946    #[test]
947    fn factory_step_with_start() {
948        let config = GeneratorConfig::Step {
949            start: Some(10.0),
950            step_size: 2.0,
951            max: None,
952        };
953        let gen = create_generator(&config, 1.0).expect("step factory with start");
954        assert_eq!(gen.value(0), 10.0);
955        assert_eq!(gen.value(1), 12.0);
956        assert_eq!(gen.value(5), 20.0);
957    }
958
959    #[test]
960    fn factory_step_with_wrap() {
961        let config = GeneratorConfig::Step {
962            start: Some(0.0),
963            step_size: 1.0,
964            max: Some(3.0),
965        };
966        let gen = create_generator(&config, 1.0).expect("step factory with wrap");
967        assert_eq!(gen.value(0), 0.0);
968        assert_eq!(gen.value(3), 0.0, "should wrap at max");
969        assert_eq!(gen.value(4), 1.0);
970    }
971
972    #[test]
973    fn factory_step_start_none_defaults_to_zero() {
974        let config_none = GeneratorConfig::Step {
975            start: None,
976            step_size: 1.0,
977            max: None,
978        };
979        let config_zero = GeneratorConfig::Step {
980            start: Some(0.0),
981            step_size: 1.0,
982            max: None,
983        };
984        let gen_none = create_generator(&config_none, 1.0).expect("step start=None");
985        let gen_zero = create_generator(&config_zero, 1.0).expect("step start=0");
986        for tick in 0..10 {
987            assert_eq!(
988                gen_none.value(tick),
989                gen_zero.value(tick),
990                "start=None must equal start=Some(0.0) at tick {tick}"
991            );
992        }
993    }
994
995    // ---- Spike factory tests --------------------------------------------------
996
997    #[test]
998    fn factory_spike_returns_baseline_outside_window() {
999        let config = GeneratorConfig::Spike {
1000            baseline: 50.0,
1001            magnitude: 200.0,
1002            duration_secs: 10.0,
1003            interval_secs: 60.0,
1004        };
1005        let gen = create_generator(&config, 1.0).expect("spike factory");
1006        // tick 15 is outside the 10-tick spike window
1007        assert_eq!(gen.value(15), 50.0);
1008    }
1009
1010    #[test]
1011    fn factory_spike_returns_spike_inside_window() {
1012        let config = GeneratorConfig::Spike {
1013            baseline: 50.0,
1014            magnitude: 200.0,
1015            duration_secs: 10.0,
1016            interval_secs: 60.0,
1017        };
1018        let gen = create_generator(&config, 1.0).expect("spike factory");
1019        // tick 5 is inside the 10-tick spike window
1020        assert_eq!(gen.value(5), 250.0);
1021    }
1022
1023    #[test]
1024    fn factory_spike_zero_interval_returns_error() {
1025        let config = GeneratorConfig::Spike {
1026            baseline: 50.0,
1027            magnitude: 200.0,
1028            duration_secs: 10.0,
1029            interval_secs: 0.0,
1030        };
1031        let result = create_generator(&config, 1.0);
1032        assert!(result.is_err(), "interval_secs=0 must return an error");
1033    }
1034
1035    #[test]
1036    fn factory_spike_negative_interval_returns_error() {
1037        let config = GeneratorConfig::Spike {
1038            baseline: 50.0,
1039            magnitude: 200.0,
1040            duration_secs: 10.0,
1041            interval_secs: -1.0,
1042        };
1043        let result = create_generator(&config, 1.0);
1044        assert!(
1045            result.is_err(),
1046            "negative interval_secs must return an error"
1047        );
1048    }
1049
1050    #[test]
1051    fn factory_spike_negative_duration_returns_error() {
1052        let config = GeneratorConfig::Spike {
1053            baseline: 50.0,
1054            magnitude: 200.0,
1055            duration_secs: -5.0,
1056            interval_secs: 60.0,
1057        };
1058        let result = create_generator(&config, 1.0);
1059        assert!(
1060            result.is_err(),
1061            "negative duration_secs must return an error"
1062        );
1063    }
1064
1065    #[test]
1066    fn factory_spike_zero_duration_succeeds() {
1067        let config = GeneratorConfig::Spike {
1068            baseline: 50.0,
1069            magnitude: 200.0,
1070            duration_secs: 0.0,
1071            interval_secs: 60.0,
1072        };
1073        let gen = create_generator(&config, 1.0).expect("duration_secs=0 is valid");
1074        // With zero duration, all ticks should return baseline
1075        assert_eq!(gen.value(0), 50.0);
1076        assert_eq!(gen.value(30), 50.0);
1077    }
1078
1079    #[test]
1080    fn factory_csv_replay_with_columns_returns_error() {
1081        let config = GeneratorConfig::CsvReplay {
1082            file: "data.csv".to_string(),
1083            column: None,
1084            repeat: None,
1085            columns: Some(vec![CsvColumnSpec {
1086                index: 1,
1087                name: "cpu".to_string(),
1088                labels: None,
1089            }]),
1090        };
1091        let result = create_generator(&config, 1.0);
1092        match result {
1093            Err(e) => {
1094                let msg = e.to_string();
1095                assert!(
1096                    msg.contains("expand_scenario"),
1097                    "error must mention expand_scenario, got: {msg}"
1098                );
1099            }
1100            Ok(_) => panic!("csv_replay with columns set must return an error"),
1101        }
1102    }
1103
1104    // ---- Config deserialization tests ----------------------------------------
1105    // These tests require the `config` feature (serde_yaml_ng).
1106
1107    #[cfg(feature = "config")]
1108    #[test]
1109    fn deserialize_constant_config() {
1110        let yaml = "type: constant\nvalue: 42.0\n";
1111        let config: GeneratorConfig = serde_yaml_ng::from_str(yaml).expect("deserialize constant");
1112        match config {
1113            GeneratorConfig::Constant { value } => {
1114                assert_eq!(value, 42.0);
1115            }
1116            _ => panic!("expected Constant variant"),
1117        }
1118    }
1119
1120    #[cfg(feature = "config")]
1121    #[test]
1122    fn deserialize_uniform_config_with_seed() {
1123        let yaml = "type: uniform\nmin: 1.0\nmax: 5.0\nseed: 99\n";
1124        let config: GeneratorConfig = serde_yaml_ng::from_str(yaml).expect("deserialize uniform");
1125        match config {
1126            GeneratorConfig::Uniform { min, max, seed } => {
1127                assert_eq!(min, 1.0);
1128                assert_eq!(max, 5.0);
1129                assert_eq!(seed, Some(99));
1130            }
1131            _ => panic!("expected Uniform variant"),
1132        }
1133    }
1134
1135    #[cfg(feature = "config")]
1136    #[test]
1137    fn deserialize_uniform_config_without_seed() {
1138        let yaml = "type: uniform\nmin: 0.0\nmax: 10.0\n";
1139        let config: GeneratorConfig =
1140            serde_yaml_ng::from_str(yaml).expect("deserialize uniform no seed");
1141        match config {
1142            GeneratorConfig::Uniform { min, max, seed } => {
1143                assert_eq!(min, 0.0);
1144                assert_eq!(max, 10.0);
1145                assert_eq!(seed, None);
1146            }
1147            _ => panic!("expected Uniform variant"),
1148        }
1149    }
1150
1151    #[cfg(feature = "config")]
1152    #[test]
1153    fn deserialize_sine_config() {
1154        let yaml = "type: sine\namplitude: 5.0\nperiod_secs: 30\noffset: 10.0\n";
1155        let config: GeneratorConfig = serde_yaml_ng::from_str(yaml).expect("deserialize sine");
1156        match config {
1157            GeneratorConfig::Sine {
1158                amplitude,
1159                period_secs,
1160                offset,
1161            } => {
1162                assert_eq!(amplitude, 5.0);
1163                assert_eq!(period_secs, 30.0);
1164                assert_eq!(offset, 10.0);
1165            }
1166            _ => panic!("expected Sine variant"),
1167        }
1168    }
1169
1170    #[cfg(feature = "config")]
1171    #[test]
1172    fn deserialize_sawtooth_config() {
1173        let yaml = "type: sawtooth\nmin: 0.0\nmax: 100.0\nperiod_secs: 60.0\n";
1174        let config: GeneratorConfig = serde_yaml_ng::from_str(yaml).expect("deserialize sawtooth");
1175        match config {
1176            GeneratorConfig::Sawtooth {
1177                min,
1178                max,
1179                period_secs,
1180            } => {
1181                assert_eq!(min, 0.0);
1182                assert_eq!(max, 100.0);
1183                assert_eq!(period_secs, 60.0);
1184            }
1185            _ => panic!("expected Sawtooth variant"),
1186        }
1187    }
1188
1189    #[cfg(feature = "config")]
1190    #[test]
1191    fn deserialize_step_config_full() {
1192        let yaml = "type: step\nstart: 10.0\nstep_size: 2.5\nmax: 100.0\n";
1193        let config: GeneratorConfig = serde_yaml_ng::from_str(yaml).expect("deserialize step");
1194        match config {
1195            GeneratorConfig::Step {
1196                start,
1197                step_size,
1198                max,
1199            } => {
1200                assert_eq!(start, Some(10.0));
1201                assert_eq!(step_size, 2.5);
1202                assert_eq!(max, Some(100.0));
1203            }
1204            _ => panic!("expected Step variant"),
1205        }
1206    }
1207
1208    #[cfg(feature = "config")]
1209    #[test]
1210    fn deserialize_step_config_minimal() {
1211        let yaml = "type: step\nstep_size: 1.0\n";
1212        let config: GeneratorConfig =
1213            serde_yaml_ng::from_str(yaml).expect("deserialize step minimal");
1214        match config {
1215            GeneratorConfig::Step {
1216                start,
1217                step_size,
1218                max,
1219            } => {
1220                assert_eq!(start, None, "start should default to None when omitted");
1221                assert_eq!(step_size, 1.0);
1222                assert_eq!(max, None, "max should be None when omitted");
1223            }
1224            _ => panic!("expected Step variant"),
1225        }
1226    }
1227
1228    #[cfg(feature = "config")]
1229    #[test]
1230    fn deserialize_step_config_integer_values() {
1231        // YAML integers should coerce to f64
1232        let yaml = "type: step\nstart: 0\nstep_size: 1\nmax: 1000\n";
1233        let config: GeneratorConfig =
1234            serde_yaml_ng::from_str(yaml).expect("deserialize step with integers");
1235        match config {
1236            GeneratorConfig::Step {
1237                start,
1238                step_size,
1239                max,
1240            } => {
1241                assert_eq!(start, Some(0.0));
1242                assert_eq!(step_size, 1.0);
1243                assert_eq!(max, Some(1000.0));
1244            }
1245            _ => panic!("expected Step variant"),
1246        }
1247    }
1248
1249    #[cfg(feature = "config")]
1250    #[test]
1251    fn deserialize_sequence_config_with_repeat() {
1252        let yaml = "type: sequence\nvalues: [1.0, 2.0, 3.0]\nrepeat: true\n";
1253        let config: GeneratorConfig =
1254            serde_yaml_ng::from_str(yaml).expect("deserialize sequence with repeat");
1255        match config {
1256            GeneratorConfig::Sequence { values, repeat } => {
1257                assert_eq!(values, vec![1.0, 2.0, 3.0]);
1258                assert_eq!(repeat, Some(true));
1259            }
1260            _ => panic!("expected Sequence variant"),
1261        }
1262    }
1263
1264    #[cfg(feature = "config")]
1265    #[test]
1266    fn deserialize_sequence_config_without_repeat() {
1267        let yaml = "type: sequence\nvalues: [10.0, 20.0]\n";
1268        let config: GeneratorConfig =
1269            serde_yaml_ng::from_str(yaml).expect("deserialize sequence without repeat");
1270        match config {
1271            GeneratorConfig::Sequence { values, repeat } => {
1272                assert_eq!(values, vec![10.0, 20.0]);
1273                assert_eq!(repeat, None, "repeat should be None when omitted");
1274            }
1275            _ => panic!("expected Sequence variant"),
1276        }
1277    }
1278
1279    #[cfg(feature = "config")]
1280    #[test]
1281    fn deserialize_sequence_config_repeat_false() {
1282        let yaml = "type: sequence\nvalues: [5.0]\nrepeat: false\n";
1283        let config: GeneratorConfig =
1284            serde_yaml_ng::from_str(yaml).expect("deserialize sequence repeat=false");
1285        match config {
1286            GeneratorConfig::Sequence { values, repeat } => {
1287                assert_eq!(values, vec![5.0]);
1288                assert_eq!(repeat, Some(false));
1289            }
1290            _ => panic!("expected Sequence variant"),
1291        }
1292    }
1293
1294    #[cfg(feature = "config")]
1295    #[test]
1296    fn deserialize_sequence_config_integer_values() {
1297        // YAML integers should coerce to f64
1298        let yaml = "type: sequence\nvalues: [10, 20, 30]\nrepeat: true\n";
1299        let config: GeneratorConfig =
1300            serde_yaml_ng::from_str(yaml).expect("deserialize sequence with integer values");
1301        match config {
1302            GeneratorConfig::Sequence { values, repeat } => {
1303                assert_eq!(values, vec![10.0, 20.0, 30.0]);
1304                assert_eq!(repeat, Some(true));
1305            }
1306            _ => panic!("expected Sequence variant"),
1307        }
1308    }
1309
1310    #[cfg(feature = "config")]
1311    #[test]
1312    fn deserialize_spike_config() {
1313        let yaml =
1314            "type: spike\nbaseline: 50.0\nmagnitude: 200.0\nduration_secs: 10\ninterval_secs: 60\n";
1315        let config: GeneratorConfig = serde_yaml_ng::from_str(yaml).expect("deserialize spike");
1316        match config {
1317            GeneratorConfig::Spike {
1318                baseline,
1319                magnitude,
1320                duration_secs,
1321                interval_secs,
1322            } => {
1323                assert_eq!(baseline, 50.0);
1324                assert_eq!(magnitude, 200.0);
1325                assert_eq!(duration_secs, 10.0);
1326                assert_eq!(interval_secs, 60.0);
1327            }
1328            _ => panic!("expected Spike variant"),
1329        }
1330    }
1331
1332    #[cfg(feature = "config")]
1333    #[test]
1334    fn deserialize_spike_config_negative_magnitude() {
1335        let yaml =
1336            "type: spike\nbaseline: 100.0\nmagnitude: -50.0\nduration_secs: 5\ninterval_secs: 20\n";
1337        let config: GeneratorConfig =
1338            serde_yaml_ng::from_str(yaml).expect("deserialize spike negative magnitude");
1339        match config {
1340            GeneratorConfig::Spike {
1341                baseline,
1342                magnitude,
1343                ..
1344            } => {
1345                assert_eq!(baseline, 100.0);
1346                assert_eq!(magnitude, -50.0);
1347            }
1348            _ => panic!("expected Spike variant"),
1349        }
1350    }
1351
1352    #[cfg(feature = "config")]
1353    #[test]
1354    fn deserialize_example_yaml_scenario_file() {
1355        // Validate the example file from examples/sequence-alert-test.yaml
1356        let yaml = "\
1357name: cpu_spike_test
1358rate: 1
1359duration: 80s
1360
1361generator:
1362  type: sequence
1363  values: [10, 10, 10, 10, 10, 95, 95, 95, 95, 95, 10, 10, 10, 10, 10, 10]
1364  repeat: true
1365
1366labels:
1367  instance: server-01
1368  job: node
1369
1370encoder:
1371  type: prometheus_text
1372sink:
1373  type: stdout
1374";
1375        let config: crate::config::ScenarioConfig =
1376            serde_yaml_ng::from_str(yaml).expect("example YAML must deserialize");
1377        assert_eq!(config.name, "cpu_spike_test");
1378        assert_eq!(config.rate, 1.0);
1379        assert_eq!(config.duration, Some("80s".to_string()));
1380        match &config.generator {
1381            GeneratorConfig::Sequence { values, repeat } => {
1382                assert_eq!(values.len(), 16);
1383                assert_eq!(values[0], 10.0);
1384                assert_eq!(values[5], 95.0);
1385                assert_eq!(values[10], 10.0);
1386                assert_eq!(*repeat, Some(true));
1387            }
1388            _ => panic!("expected Sequence generator variant in example YAML"),
1389        }
1390    }
1391
1392    // ---- Send + Sync contract tests ------------------------------------------
1393
1394    // ---- wrap_with_jitter factory tests ----------------------------------------
1395
1396    #[test]
1397    fn wrap_with_jitter_none_returns_unchanged() {
1398        let config = GeneratorConfig::Constant { value: 42.0 };
1399        let gen = create_generator(&config, 1.0).expect("constant factory");
1400        let wrapped = wrap_with_jitter(gen, None, None);
1401        for tick in 0..100 {
1402            assert_eq!(
1403                wrapped.value(tick),
1404                42.0,
1405                "jitter=None must return original values at tick {tick}"
1406            );
1407        }
1408    }
1409
1410    #[test]
1411    fn wrap_with_jitter_zero_returns_unchanged() {
1412        let config = GeneratorConfig::Constant { value: 42.0 };
1413        let gen = create_generator(&config, 1.0).expect("constant factory");
1414        let wrapped = wrap_with_jitter(gen, Some(0.0), Some(99));
1415        for tick in 0..100 {
1416            assert_eq!(
1417                wrapped.value(tick),
1418                42.0,
1419                "jitter=0.0 must return original values at tick {tick}"
1420            );
1421        }
1422    }
1423
1424    #[test]
1425    fn wrap_with_jitter_positive_produces_values_in_range() {
1426        let base = 100.0;
1427        let jitter_amp = 5.0;
1428        let config = GeneratorConfig::Constant { value: base };
1429        let gen = create_generator(&config, 1.0).expect("constant factory");
1430        let wrapped = wrap_with_jitter(gen, Some(jitter_amp), Some(42));
1431        for tick in 0..10_000 {
1432            let v = wrapped.value(tick);
1433            assert!(
1434                v >= base - jitter_amp && v <= base + jitter_amp,
1435                "value {v} at tick {tick} outside [{}, {}]",
1436                base - jitter_amp,
1437                base + jitter_amp
1438            );
1439        }
1440    }
1441
1442    #[test]
1443    fn wrap_with_jitter_seed_none_defaults_to_zero() {
1444        let config = GeneratorConfig::Constant { value: 50.0 };
1445        let gen_none = create_generator(&config, 1.0).expect("factory");
1446        let gen_zero = create_generator(&config, 1.0).expect("factory");
1447        let wrapped_none = wrap_with_jitter(gen_none, Some(5.0), None);
1448        let wrapped_zero = wrap_with_jitter(gen_zero, Some(5.0), Some(0));
1449        for tick in 0..100 {
1450            assert_eq!(
1451                wrapped_none.value(tick),
1452                wrapped_zero.value(tick),
1453                "jitter_seed=None must equal jitter_seed=Some(0) at tick {tick}"
1454            );
1455        }
1456    }
1457
1458    // ---- Send + Sync contract tests ------------------------------------------
1459
1460    fn assert_send_sync<T: Send + Sync>() {}
1461
1462    #[test]
1463    fn generators_are_send_and_sync() {
1464        // These are compile-time checks — if the types don't implement Send+Sync the
1465        // test binary will not compile.
1466        assert_send_sync::<crate::generator::uniform::UniformRandom>();
1467        assert_send_sync::<crate::generator::sine::Sine>();
1468        assert_send_sync::<crate::generator::sawtooth::Sawtooth>();
1469        assert_send_sync::<crate::generator::constant::Constant>();
1470        assert_send_sync::<crate::generator::sequence::SequenceGenerator>();
1471        assert_send_sync::<crate::generator::spike::SpikeGenerator>();
1472        assert_send_sync::<crate::generator::csv_replay::CsvReplayGenerator>();
1473        assert_send_sync::<crate::generator::step::StepGenerator>();
1474        assert_send_sync::<crate::generator::jitter::JitterWrapper>();
1475    }
1476
1477    // ---- LogGeneratorConfig deserialization tests ----------------------------
1478    // These tests require the `config` feature (serde_yaml_ng).
1479
1480    #[cfg(feature = "config")]
1481    #[test]
1482    fn deserialize_log_template_config_minimal() {
1483        let yaml = "\
1484type: template
1485templates:
1486  - message: \"hello {name}\"
1487    field_pools:
1488      name:
1489        - alice
1490        - bob
1491";
1492        let config: LogGeneratorConfig =
1493            serde_yaml_ng::from_str(yaml).expect("deserialize template config");
1494        match config {
1495            LogGeneratorConfig::Template {
1496                templates,
1497                severity_weights,
1498                seed,
1499            } => {
1500                assert_eq!(templates.len(), 1);
1501                assert_eq!(templates[0].message, "hello {name}");
1502                assert!(templates[0].field_pools.contains_key("name"));
1503                assert_eq!(
1504                    templates[0].field_pools["name"],
1505                    vec!["alice".to_string(), "bob".to_string()]
1506                );
1507                assert!(
1508                    severity_weights.is_none(),
1509                    "severity_weights must default to None"
1510                );
1511                assert!(seed.is_none(), "seed must default to None");
1512            }
1513            _ => panic!("expected Template variant"),
1514        }
1515    }
1516
1517    #[cfg(feature = "config")]
1518    #[test]
1519    fn deserialize_log_template_config_with_weights_and_seed() {
1520        let yaml = "\
1521type: template
1522templates:
1523  - message: \"msg\"
1524    field_pools: {}
1525severity_weights:
1526  info: 0.7
1527  warn: 0.2
1528  error: 0.1
1529seed: 42
1530";
1531        let config: LogGeneratorConfig =
1532            serde_yaml_ng::from_str(yaml).expect("deserialize template config with weights");
1533        match config {
1534            LogGeneratorConfig::Template {
1535                severity_weights,
1536                seed,
1537                ..
1538            } => {
1539                let weights = severity_weights.expect("severity_weights should be present");
1540                assert!((weights["info"] - 0.7).abs() < 1e-10);
1541                assert!((weights["warn"] - 0.2).abs() < 1e-10);
1542                assert!((weights["error"] - 0.1).abs() < 1e-10);
1543                assert_eq!(seed, Some(42));
1544            }
1545            _ => panic!("expected Template variant"),
1546        }
1547    }
1548
1549    #[cfg(feature = "config")]
1550    #[test]
1551    fn deserialize_log_replay_config() {
1552        let yaml = "type: replay\nfile: /var/log/app.log\n";
1553        let config: LogGeneratorConfig =
1554            serde_yaml_ng::from_str(yaml).expect("deserialize replay config");
1555        match config {
1556            LogGeneratorConfig::Replay { file } => {
1557                assert_eq!(file, "/var/log/app.log");
1558            }
1559            _ => panic!("expected Replay variant"),
1560        }
1561    }
1562
1563    // ---- create_log_generator factory tests ----------------------------------
1564
1565    #[test]
1566    fn factory_template_config_creates_working_generator() {
1567        let config = LogGeneratorConfig::Template {
1568            templates: vec![TemplateConfig {
1569                message: "event {id}".into(),
1570                field_pools: {
1571                    let mut m = BTreeMap::new();
1572                    m.insert("id".into(), vec!["1".into(), "2".into(), "3".into()]);
1573                    m
1574                },
1575            }],
1576            severity_weights: None,
1577            seed: Some(0),
1578        };
1579        let gen = create_log_generator(&config).expect("template factory must succeed");
1580        let event = gen.generate(0);
1581        // Must not contain unresolved placeholder.
1582        assert!(!event.message.contains('{'));
1583    }
1584
1585    #[test]
1586    fn factory_template_config_seed_none_defaults_correctly() {
1587        // seed: None should not error and should produce a generator.
1588        let config = LogGeneratorConfig::Template {
1589            templates: vec![TemplateConfig {
1590                message: "static message".into(),
1591                field_pools: BTreeMap::new(),
1592            }],
1593            severity_weights: None,
1594            seed: None,
1595        };
1596        let gen = create_log_generator(&config).expect("template with seed=None must succeed");
1597        assert_eq!(gen.generate(0).message, "static message");
1598    }
1599
1600    #[test]
1601    fn factory_template_invalid_severity_key_returns_error() {
1602        let config = LogGeneratorConfig::Template {
1603            templates: vec![TemplateConfig {
1604                message: "msg".into(),
1605                field_pools: BTreeMap::new(),
1606            }],
1607            severity_weights: {
1608                let mut m = HashMap::new();
1609                m.insert("bogus".into(), 1.0);
1610                Some(m)
1611            },
1612            seed: None,
1613        };
1614        let result = create_log_generator(&config);
1615        assert!(
1616            result.is_err(),
1617            "invalid severity key 'bogus' must produce Err"
1618        );
1619    }
1620
1621    #[test]
1622    fn factory_replay_config_missing_file_returns_error() {
1623        let config = LogGeneratorConfig::Replay {
1624            file: "/this/path/does/not/exist.log".into(),
1625        };
1626        let result = create_log_generator(&config);
1627        assert!(result.is_err(), "missing replay file must produce Err");
1628    }
1629
1630    #[test]
1631    fn factory_replay_config_creates_working_generator() {
1632        use std::io::Write;
1633        use tempfile::NamedTempFile;
1634        let mut tmp = NamedTempFile::new().expect("create temp file");
1635        writeln!(tmp, "line one").expect("write");
1636        writeln!(tmp, "line two").expect("write");
1637        let config = LogGeneratorConfig::Replay {
1638            file: tmp.path().to_string_lossy().into_owned(),
1639        };
1640        let gen =
1641            create_log_generator(&config).expect("replay factory with real file must succeed");
1642        assert_eq!(gen.generate(0).message, "line one");
1643        assert_eq!(gen.generate(1).message, "line two");
1644        assert_eq!(gen.generate(2).message, "line one");
1645    }
1646
1647    #[test]
1648    fn log_generators_are_send_and_sync() {
1649        assert_send_sync::<crate::generator::log_template::LogTemplateGenerator>();
1650        assert_send_sync::<crate::generator::log_replay::LogReplayGenerator>();
1651    }
1652}