Skip to main content

sonda_core/compiler/
prepare.rs

1//! Translation boundary from the v2 compiler output to the existing runtime's
2//! input shape.
3//!
4//! This module is **Phase 6** of the v2 compilation pipeline (the execution
5//! plan's "Prepare Entries" step). It consumes a [`CompiledFile`] produced by
6//! [`compile_after`][crate::compiler::compile_after::compile_after] and
7//! produces a `Vec<ScenarioEntry>` — the exact input shape that the existing
8//! [`prepare_entries`][crate::schedule::launch::prepare_entries] function
9//! already understands.
10//!
11//! # Why a dedicated module
12//!
13//! The runtime's [`ScenarioEntry`] was the v1 launch input and cannot be
14//! changed without breaking the scheduler contract. The compiler's
15//! [`CompiledEntry`] is a forward-compatible shape that can carry fields the
16//! runtime does not yet consume. Keeping the translation in its own module
17//! gives us:
18//!
19//! - A single obvious place to update when either shape evolves.
20//! - A thin, allocation-conservative one-shot conversion (runs once at launch
21//!   time, not per tick).
22//! - Typed errors for the narrow set of "shape invariant broken" cases that
23//!   only become visible at the dispatch boundary (e.g. unknown `signal_type`
24//!   strings, missing per-variant required fields).
25//!
26//! The translator does **not** parse durations — `phase_offset` is passed
27//! through verbatim so
28//! [`prepare_entries`][crate::schedule::launch::prepare_entries] remains the
29//! sole `parse_phase_offset` caller.
30
31use std::collections::{BTreeMap, HashMap};
32
33use crate::compiler::compile_after::{CompiledEntry, CompiledFile};
34use crate::config::{
35    BaseScheduleConfig, HistogramScenarioConfig, LogScenarioConfig, ScenarioConfig, ScenarioEntry,
36    SummaryScenarioConfig,
37};
38
39// ---------------------------------------------------------------------------
40// Error type
41// ---------------------------------------------------------------------------
42
43/// Errors produced by [`prepare`].
44///
45/// Every variant carries enough context to identify the offending
46/// [`CompiledEntry`] — either its user-provided `id` or, when the id is
47/// absent, its `name`. Preferring `id` matches the convention used by
48/// [`CompileAfterError`][crate::compiler::compile_after::CompileAfterError],
49/// so diagnostics chain cleanly when both phases need to report on the same
50/// entry.
51#[derive(Debug, thiserror::Error)]
52#[non_exhaustive]
53pub enum PrepareError {
54    /// The entry's `signal_type` was not one of the four recognized variants
55    /// (`"metrics"`, `"logs"`, `"histogram"`, `"summary"`).
56    ///
57    /// The parser already rejects unknown signal types, but this variant
58    /// keeps the translator self-contained: callers that construct a
59    /// [`CompiledFile`] in code without going through
60    /// [`parse`][crate::compiler::parse::parse] still get a proper error
61    /// instead of a `match` panic.
62    #[error("entry '{entry_label}': unknown signal_type '{signal_type}'")]
63    UnknownSignalType {
64        /// The entry's id (or name when id is absent).
65        entry_label: String,
66        /// The unrecognized signal type string as it appeared in the compiled entry.
67        signal_type: String,
68    },
69
70    /// A metrics entry had no `generator` field set.
71    ///
72    /// `signal_type: metrics` is the only variant that reads `generator`
73    /// from the compiled entry. When the YAML path is used the parser
74    /// rejects a metrics entry missing `generator` at parse-time; reaching
75    /// this variant therefore implies the [`CompiledFile`] was built in
76    /// code rather than through [`parse`][crate::compiler::parse::parse].
77    #[error("entry '{entry_label}' (signal_type: metrics): missing required field 'generator'")]
78    MissingGenerator {
79        /// The entry's id (or name when id is absent).
80        entry_label: String,
81    },
82
83    /// A logs entry had no `log_generator` field set.
84    ///
85    /// `signal_type: logs` is the only variant that reads `log_generator`
86    /// from the compiled entry. When the YAML path is used the parser
87    /// rejects a logs entry missing `log_generator` at parse-time;
88    /// reaching this variant therefore implies the [`CompiledFile`] was
89    /// built in code rather than through
90    /// [`parse`][crate::compiler::parse::parse].
91    #[error("entry '{entry_label}' (signal_type: logs): missing required field 'log_generator'")]
92    MissingLogGenerator {
93        /// The entry's id (or name when id is absent).
94        entry_label: String,
95    },
96
97    /// A histogram or summary entry had no `distribution` field set.
98    ///
99    /// `signal_type: histogram` and `signal_type: summary` both read
100    /// `distribution` from the compiled entry. When the YAML path is used
101    /// the parser rejects either shape missing `distribution` at
102    /// parse-time; reaching this variant therefore implies the
103    /// [`CompiledFile`] was built in code rather than through
104    /// [`parse`][crate::compiler::parse::parse].
105    #[error(
106        "entry '{entry_label}' (signal_type: {signal_type}): missing required field 'distribution'"
107    )]
108    MissingDistribution {
109        /// The entry's id (or name when id is absent).
110        entry_label: String,
111        /// The signal type that requires a distribution (`"histogram"` or `"summary"`).
112        signal_type: String,
113    },
114
115    /// The compiled file's `version` was not `2`.
116    ///
117    /// Defense-in-depth against programmatic callers that construct a
118    /// [`CompiledFile`] in code with a non-v2 version. Going through
119    /// [`parse`][crate::compiler::parse::parse] already pins the version
120    /// at parse-time, so this variant is unreachable via the YAML path.
121    #[error("unsupported compiled file version: expected 2, got {version}")]
122    UnsupportedVersion {
123        /// The rejected version value as carried by the compiled file.
124        version: u32,
125    },
126}
127
128// ---------------------------------------------------------------------------
129// Public API
130// ---------------------------------------------------------------------------
131
132/// Translate a [`CompiledFile`] into the runtime's
133/// `Vec<ScenarioEntry>` input shape.
134///
135/// This is a **one-shot** conversion intended to run once at launch time.
136/// Per-tick allocations are not affected — every [`ScenarioEntry`] produced
137/// by this function takes the same hot path as if it had been constructed
138/// directly by the v1 loader.
139///
140/// Field-by-field mapping lives in per-variant helpers; see the module
141/// source for the exact wiring. Each helper consumes its [`CompiledEntry`]
142/// by value so no deep clone is performed on the generator or label maps.
143///
144/// `CompiledEntry::id` is intentionally dropped during translation: its
145/// job ended in Phase 4+5's dependency resolution, and [`ScenarioEntry`]
146/// has no `id` field. Future observability wiring that wants to correlate
147/// runtime back to v2 ids will need another channel (e.g. a side map keyed
148/// on `name` + `clock_group`).
149///
150/// # Errors
151///
152/// Returns [`PrepareError`] on the first entry that fails translation:
153///
154/// - [`PrepareError::UnsupportedVersion`] if `file.version` is not `2`.
155/// - [`PrepareError::UnknownSignalType`] when `signal_type` is not one of
156///   `"metrics"`, `"logs"`, `"histogram"`, or `"summary"`.
157/// - [`PrepareError::MissingGenerator`] for a metrics entry missing `generator`.
158/// - [`PrepareError::MissingLogGenerator`] for a logs entry missing `log_generator`.
159/// - [`PrepareError::MissingDistribution`] for a histogram/summary entry
160///   missing `distribution`.
161///
162/// The short-circuiting semantics match the v2 compiler's other passes —
163/// no partial output is returned on failure.
164pub fn prepare(file: CompiledFile) -> Result<Vec<ScenarioEntry>, PrepareError> {
165    let CompiledFile {
166        version, entries, ..
167    } = file;
168    if version != 2 {
169        return Err(PrepareError::UnsupportedVersion { version });
170    }
171    let mut out = Vec::with_capacity(entries.len());
172    for entry in entries {
173        out.push(translate_entry(entry)?);
174    }
175    Ok(out)
176}
177
178/// Translate a single [`CompiledEntry`] into the matching [`ScenarioEntry`]
179/// variant.
180///
181/// Exposed for callers that want to fan out translation themselves (e.g.
182/// partial runtime wiring in tests). Most callers should use [`prepare`].
183///
184/// # Errors
185///
186/// See [`prepare`] for the full error semantics.
187pub fn translate_entry(entry: CompiledEntry) -> Result<ScenarioEntry, PrepareError> {
188    match entry.signal_type.as_str() {
189        "metrics" => metrics_entry(entry).map(ScenarioEntry::Metrics),
190        "logs" => logs_entry(entry).map(ScenarioEntry::Logs),
191        "histogram" => histogram_entry(entry).map(ScenarioEntry::Histogram),
192        "summary" => summary_entry(entry).map(ScenarioEntry::Summary),
193        _ => Err(PrepareError::UnknownSignalType {
194            entry_label: describe(&entry),
195            signal_type: entry.signal_type,
196        }),
197    }
198}
199
200// ---------------------------------------------------------------------------
201// Per-variant helpers
202// ---------------------------------------------------------------------------
203
204/// Produce a human-readable label for a [`CompiledEntry`] — prefers the
205/// explicit `id`, falls back to `name`.
206fn describe(entry: &CompiledEntry) -> String {
207    entry.id.clone().unwrap_or_else(|| entry.name.clone())
208}
209
210/// Build a [`BaseScheduleConfig`] from the shared fields of a
211/// [`CompiledEntry`].
212///
213/// The only non-trivial conversion is `labels: Option<BTreeMap<...>>` →
214/// `Option<HashMap<...>>`. The runtime uses `HashMap` internally but the
215/// compiler operates on `BTreeMap` for deterministic iteration — this
216/// one-shot conversion is the only place the shape changes.
217///
218/// The `clock_group_is_auto` provenance is mapped to
219/// `Some(bool)` so v1-loaded entries (which never traverse this
220/// translator) read `None` and render without the `(auto)` suffix.
221fn build_base(entry: &mut CompiledEntry) -> BaseScheduleConfig {
222    let labels = entry.labels.take().map(btree_to_hash);
223
224    let clock_group = entry.clock_group.take();
225    let clock_group_is_auto = clock_group.as_ref().map(|_| entry.clock_group_is_auto);
226
227    BaseScheduleConfig {
228        name: std::mem::take(&mut entry.name),
229        rate: entry.rate,
230        duration: entry.duration.take(),
231        gaps: entry.gaps.take(),
232        bursts: entry.bursts.take(),
233        cardinality_spikes: entry.cardinality_spikes.take(),
234        dynamic_labels: entry.dynamic_labels.take(),
235        labels,
236        sink: std::mem::replace(&mut entry.sink, crate::sink::SinkConfig::Stdout),
237        phase_offset: entry.phase_offset.take(),
238        clock_group,
239        clock_group_is_auto,
240        jitter: entry.jitter,
241        jitter_seed: entry.jitter_seed,
242        on_sink_error: entry.on_sink_error,
243    }
244}
245
246/// Convert an ordered label map into an unordered one without reallocating
247/// any keys or values.
248fn btree_to_hash(m: BTreeMap<String, String>) -> HashMap<String, String> {
249    let mut hm = HashMap::with_capacity(m.len());
250    hm.extend(m);
251    hm
252}
253
254/// Materialize a metrics [`ScenarioConfig`] from a compiled entry.
255fn metrics_entry(mut entry: CompiledEntry) -> Result<ScenarioConfig, PrepareError> {
256    let generator = entry
257        .generator
258        .take()
259        .ok_or_else(|| PrepareError::MissingGenerator {
260            entry_label: describe(&entry),
261        })?;
262    // `encoder` is non-`Option` on CompiledEntry (Phase 2 normalize filled it
263    // in), so a mem::replace with a cheap placeholder is sufficient.
264    let encoder = std::mem::replace(
265        &mut entry.encoder,
266        crate::encoder::EncoderConfig::PrometheusText { precision: None },
267    );
268    let base = build_base(&mut entry);
269    Ok(ScenarioConfig {
270        base,
271        generator,
272        encoder,
273    })
274}
275
276/// Materialize a logs [`LogScenarioConfig`] from a compiled entry.
277fn logs_entry(mut entry: CompiledEntry) -> Result<LogScenarioConfig, PrepareError> {
278    let generator =
279        entry
280            .log_generator
281            .take()
282            .ok_or_else(|| PrepareError::MissingLogGenerator {
283                entry_label: describe(&entry),
284            })?;
285    let encoder = std::mem::replace(
286        &mut entry.encoder,
287        crate::encoder::EncoderConfig::JsonLines { precision: None },
288    );
289    let base = build_base(&mut entry);
290    Ok(LogScenarioConfig {
291        base,
292        generator,
293        encoder,
294    })
295}
296
297/// Materialize a [`HistogramScenarioConfig`] from a compiled entry.
298fn histogram_entry(mut entry: CompiledEntry) -> Result<HistogramScenarioConfig, PrepareError> {
299    let distribution =
300        entry
301            .distribution
302            .take()
303            .ok_or_else(|| PrepareError::MissingDistribution {
304                entry_label: describe(&entry),
305                signal_type: "histogram".to_string(),
306            })?;
307    let buckets = entry.buckets.take();
308    let observations_per_tick = entry.observations_per_tick.map(u64::from);
309    let mean_shift_per_sec = entry.mean_shift_per_sec;
310    let seed = entry.seed;
311    let encoder = std::mem::replace(
312        &mut entry.encoder,
313        crate::encoder::EncoderConfig::PrometheusText { precision: None },
314    );
315    let base = build_base(&mut entry);
316    Ok(HistogramScenarioConfig {
317        base,
318        buckets,
319        distribution,
320        observations_per_tick,
321        mean_shift_per_sec,
322        seed,
323        encoder,
324    })
325}
326
327/// Materialize a [`SummaryScenarioConfig`] from a compiled entry.
328fn summary_entry(mut entry: CompiledEntry) -> Result<SummaryScenarioConfig, PrepareError> {
329    let distribution =
330        entry
331            .distribution
332            .take()
333            .ok_or_else(|| PrepareError::MissingDistribution {
334                entry_label: describe(&entry),
335                signal_type: "summary".to_string(),
336            })?;
337    let quantiles = entry.quantiles.take();
338    let observations_per_tick = entry.observations_per_tick.map(u64::from);
339    let mean_shift_per_sec = entry.mean_shift_per_sec;
340    let seed = entry.seed;
341    let encoder = std::mem::replace(
342        &mut entry.encoder,
343        crate::encoder::EncoderConfig::PrometheusText { precision: None },
344    );
345    let base = build_base(&mut entry);
346    Ok(SummaryScenarioConfig {
347        base,
348        quantiles,
349        distribution,
350        observations_per_tick,
351        mean_shift_per_sec,
352        seed,
353        encoder,
354    })
355}
356
357// ---------------------------------------------------------------------------
358// Tests
359// ---------------------------------------------------------------------------
360
361#[cfg(all(test, feature = "config"))]
362mod tests {
363    use std::collections::BTreeMap;
364
365    use rstest::rstest;
366
367    use super::*;
368    use crate::config::DistributionConfig;
369    use crate::encoder::EncoderConfig;
370    use crate::generator::{GeneratorConfig, LogGeneratorConfig, TemplateConfig};
371    use crate::sink::SinkConfig;
372
373    // -- Builders -----------------------------------------------------------
374
375    /// Build a minimal [`CompiledEntry`] — every variant-specific field is
376    /// absent so tests can pick which ones to set.
377    fn bare(signal_type: &str, name: &str) -> CompiledEntry {
378        CompiledEntry {
379            id: None,
380            signal_type: signal_type.to_string(),
381            name: name.to_string(),
382            rate: 10.0,
383            duration: Some("1s".to_string()),
384            generator: None,
385            log_generator: None,
386            labels: None,
387            dynamic_labels: None,
388            encoder: EncoderConfig::PrometheusText { precision: None },
389            sink: SinkConfig::Stdout,
390            jitter: None,
391            jitter_seed: None,
392            gaps: None,
393            bursts: None,
394            cardinality_spikes: None,
395            phase_offset: None,
396            clock_group: None,
397            clock_group_is_auto: false,
398            distribution: None,
399            buckets: None,
400            quantiles: None,
401            observations_per_tick: None,
402            mean_shift_per_sec: None,
403            seed: None,
404            on_sink_error: crate::OnSinkError::Warn,
405            while_clause: None,
406            delay_clause: None,
407            after_ref: None,
408        }
409    }
410
411    fn metrics_compiled(name: &str) -> CompiledEntry {
412        let mut e = bare("metrics", name);
413        e.generator = Some(GeneratorConfig::Constant { value: 1.0 });
414        e
415    }
416
417    fn logs_compiled(name: &str) -> CompiledEntry {
418        let mut e = bare("logs", name);
419        e.log_generator = Some(LogGeneratorConfig::Template {
420            templates: vec![TemplateConfig {
421                message: "hi".to_string(),
422                field_pools: BTreeMap::new(),
423            }],
424            severity_weights: None,
425            seed: Some(0),
426        });
427        e.encoder = EncoderConfig::JsonLines { precision: None };
428        e
429    }
430
431    fn histogram_compiled(name: &str) -> CompiledEntry {
432        let mut e = bare("histogram", name);
433        e.distribution = Some(DistributionConfig::Exponential { rate: 10.0 });
434        e.buckets = Some(vec![0.1, 1.0, 10.0]);
435        e
436    }
437
438    fn summary_compiled(name: &str) -> CompiledEntry {
439        let mut e = bare("summary", name);
440        e.distribution = Some(DistributionConfig::Normal {
441            mean: 0.1,
442            stddev: 0.02,
443        });
444        e.quantiles = Some(vec![0.5, 0.9, 0.99]);
445        e
446    }
447
448    fn file_with(entry: CompiledEntry) -> CompiledFile {
449        CompiledFile {
450            version: 2,
451            scenario_name: None,
452            entries: vec![entry],
453        }
454    }
455
456    // -- Happy paths --------------------------------------------------------
457
458    /// A metrics entry with a constant generator round-trips into
459    /// `ScenarioEntry::Metrics` with name and rate preserved.
460    #[test]
461    fn metrics_entry_translates_to_scenario_entry_metrics() {
462        let file = file_with(metrics_compiled("cpu_usage"));
463        let out = prepare(file).expect("translate must succeed");
464        assert_eq!(out.len(), 1);
465        match &out[0] {
466            ScenarioEntry::Metrics(c) => {
467                assert_eq!(c.base.name, "cpu_usage");
468                assert_eq!(c.base.rate, 10.0);
469                assert!(matches!(c.generator, GeneratorConfig::Constant { .. }));
470            }
471            other => panic!("expected Metrics, got {other:?}"),
472        }
473    }
474
475    /// A logs entry with a template generator round-trips into
476    /// `ScenarioEntry::Logs` with the log_generator preserved.
477    #[test]
478    fn logs_entry_translates_to_scenario_entry_logs() {
479        let file = file_with(logs_compiled("app_logs"));
480        let out = prepare(file).expect("translate must succeed");
481        match &out[0] {
482            ScenarioEntry::Logs(c) => {
483                assert_eq!(c.base.name, "app_logs");
484                assert!(matches!(c.generator, LogGeneratorConfig::Template { .. }));
485            }
486            other => panic!("expected Logs, got {other:?}"),
487        }
488    }
489
490    /// A histogram entry translates with distribution, buckets, and encoder
491    /// preserved.
492    #[test]
493    fn histogram_entry_translates_with_distribution_and_buckets() {
494        let file = file_with(histogram_compiled("http_request_duration"));
495        let out = prepare(file).expect("translate must succeed");
496        match &out[0] {
497            ScenarioEntry::Histogram(c) => {
498                assert_eq!(c.base.name, "http_request_duration");
499                assert_eq!(c.buckets.as_deref(), Some(&[0.1, 1.0, 10.0][..]));
500                assert!(matches!(
501                    c.distribution,
502                    DistributionConfig::Exponential { .. }
503                ));
504            }
505            other => panic!("expected Histogram, got {other:?}"),
506        }
507    }
508
509    /// A summary entry translates with distribution, quantiles, and encoder
510    /// preserved.
511    #[test]
512    fn summary_entry_translates_with_distribution_and_quantiles() {
513        let file = file_with(summary_compiled("rpc_duration"));
514        let out = prepare(file).expect("translate must succeed");
515        match &out[0] {
516            ScenarioEntry::Summary(c) => {
517                assert_eq!(c.base.name, "rpc_duration");
518                assert_eq!(c.quantiles.as_deref(), Some(&[0.5, 0.9, 0.99][..]));
519                assert!(matches!(c.distribution, DistributionConfig::Normal { .. }));
520            }
521            other => panic!("expected Summary, got {other:?}"),
522        }
523    }
524
525    /// The order of entries in the compiled file is preserved verbatim.
526    #[test]
527    fn prepare_preserves_entry_order() {
528        let file = CompiledFile {
529            version: 2,
530            scenario_name: None,
531            entries: vec![
532                metrics_compiled("first"),
533                logs_compiled("second"),
534                histogram_compiled("third"),
535                summary_compiled("fourth"),
536            ],
537        };
538        let out = prepare(file).expect("translate must succeed");
539        assert_eq!(out.len(), 4);
540        assert_eq!(out[0].base().name, "first");
541        assert_eq!(out[1].base().name, "second");
542        assert_eq!(out[2].base().name, "third");
543        assert_eq!(out[3].base().name, "fourth");
544    }
545
546    /// An empty compiled file translates to an empty vec without error.
547    #[test]
548    fn prepare_empty_file_returns_empty_vec() {
549        let file = CompiledFile {
550            version: 2,
551            scenario_name: None,
552            entries: vec![],
553        };
554        let out = prepare(file).expect("empty file must translate cleanly");
555        assert!(out.is_empty());
556    }
557
558    // -- phase_offset / clock_group carry-through ---------------------------
559
560    /// phase_offset is passed through as a string — the translator never
561    /// parses it (that is `prepare_entries`'s job).
562    #[test]
563    fn phase_offset_string_is_passed_through_verbatim() {
564        let mut entry = metrics_compiled("delayed");
565        entry.phase_offset = Some("152.308s".to_string());
566        let out = prepare(file_with(entry)).expect("translate");
567        assert_eq!(out[0].phase_offset(), Some("152.308s"));
568    }
569
570    /// clock_group passes through unchanged on every variant.
571    #[test]
572    fn clock_group_is_passed_through_on_all_variants() {
573        for factory in [
574            metrics_compiled as fn(&str) -> CompiledEntry,
575            logs_compiled,
576            histogram_compiled,
577            summary_compiled,
578        ] {
579            let mut entry = factory("any");
580            entry.clock_group = Some("chain_alpha".to_string());
581            let out = prepare(file_with(entry)).expect("translate");
582            assert_eq!(out[0].clock_group(), Some("chain_alpha"));
583        }
584    }
585
586    // -- Labels conversion --------------------------------------------------
587
588    /// Every (key, value) pair from the BTreeMap appears in the resulting
589    /// HashMap.
590    #[test]
591    fn labels_btree_to_hash_preserves_all_pairs() {
592        let mut labels = BTreeMap::new();
593        labels.insert("k1".to_string(), "v1".to_string());
594        labels.insert("k2".to_string(), "v2".to_string());
595        labels.insert("k3".to_string(), "v3".to_string());
596
597        let mut entry = metrics_compiled("labeled");
598        entry.labels = Some(labels.clone());
599
600        let out = prepare(file_with(entry)).expect("translate");
601        let hm = out[0]
602            .base()
603            .labels
604            .as_ref()
605            .expect("labels must carry through");
606        assert_eq!(hm.len(), labels.len());
607        for (k, v) in &labels {
608            assert_eq!(hm.get(k).map(String::as_str), Some(v.as_str()));
609        }
610    }
611
612    /// An empty `labels: Some(BTreeMap::new())` survives as
613    /// `Some(HashMap::new())` (the shape semantically differs from `None`).
614    #[test]
615    fn labels_empty_btree_maps_to_empty_hash() {
616        let mut entry = metrics_compiled("empty_labels");
617        entry.labels = Some(BTreeMap::new());
618        let out = prepare(file_with(entry)).expect("translate");
619        let hm = out[0].base().labels.as_ref().expect("Some stays Some");
620        assert!(hm.is_empty());
621    }
622
623    /// `labels: None` on the compiled entry survives as `None` on the
624    /// scenario entry.
625    #[test]
626    fn labels_none_stays_none() {
627        let entry = metrics_compiled("no_labels");
628        let out = prepare(file_with(entry)).expect("translate");
629        assert!(out[0].base().labels.is_none());
630    }
631
632    // -- observations_per_tick widening --------------------------------------
633
634    /// `observations_per_tick: Some(0u32)` widens to `Some(0u64)` without
635    /// clamping or overflow on histograms.
636    #[test]
637    fn histogram_observations_per_tick_widens_zero_correctly() {
638        let mut entry = histogram_compiled("zero_obs");
639        entry.observations_per_tick = Some(0);
640        let out = prepare(file_with(entry)).expect("translate");
641        match &out[0] {
642            ScenarioEntry::Histogram(c) => {
643                assert_eq!(c.observations_per_tick, Some(0u64));
644            }
645            _ => panic!("expected Histogram"),
646        }
647    }
648
649    /// `observations_per_tick: Some(u32::MAX)` widens to `Some(4_294_967_295u64)`
650    /// on histograms — no sign extension surprises.
651    #[test]
652    fn histogram_observations_per_tick_widens_u32_max_correctly() {
653        let mut entry = histogram_compiled("max_obs");
654        entry.observations_per_tick = Some(u32::MAX);
655        let out = prepare(file_with(entry)).expect("translate");
656        match &out[0] {
657            ScenarioEntry::Histogram(c) => {
658                assert_eq!(c.observations_per_tick, Some(u64::from(u32::MAX)));
659                assert_eq!(c.observations_per_tick, Some(4_294_967_295_u64));
660            }
661            _ => panic!("expected Histogram"),
662        }
663    }
664
665    /// The same widening holds for summary entries.
666    #[test]
667    fn summary_observations_per_tick_widens_u32_max_correctly() {
668        let mut entry = summary_compiled("max_obs_summary");
669        entry.observations_per_tick = Some(u32::MAX);
670        let out = prepare(file_with(entry)).expect("translate");
671        match &out[0] {
672            ScenarioEntry::Summary(c) => {
673                assert_eq!(c.observations_per_tick, Some(u64::from(u32::MAX)));
674            }
675            _ => panic!("expected Summary"),
676        }
677    }
678
679    // -- Error cases --------------------------------------------------------
680
681    /// An unknown `signal_type` string produces `UnknownSignalType` with the
682    /// offending value surfaced.
683    #[test]
684    fn unknown_signal_type_produces_unknown_signal_type_error() {
685        let mut entry = bare("traces", "bad");
686        entry.id = Some("bad".to_string());
687        let err = prepare(file_with(entry)).expect_err("unknown signal_type must fail");
688        match err {
689            PrepareError::UnknownSignalType {
690                entry_label,
691                signal_type,
692            } => {
693                assert_eq!(entry_label, "bad");
694                assert_eq!(signal_type, "traces");
695            }
696            other => panic!("expected UnknownSignalType, got {other:?}"),
697        }
698    }
699
700    /// The `entry_label` falls back to `name` when `id` is absent.
701    #[test]
702    fn unknown_signal_type_falls_back_to_name_when_id_absent() {
703        let entry = bare("traces", "bad_by_name");
704        let err = prepare(file_with(entry)).expect_err("unknown signal_type must fail");
705        match err {
706            PrepareError::UnknownSignalType { entry_label, .. } => {
707                assert_eq!(entry_label, "bad_by_name");
708            }
709            other => panic!("expected UnknownSignalType, got {other:?}"),
710        }
711    }
712
713    /// A metrics entry missing `generator` produces `MissingGenerator` with
714    /// the entry label.
715    #[test]
716    fn metrics_without_generator_produces_missing_generator_error() {
717        let mut entry = bare("metrics", "no_gen");
718        entry.id = Some("no_gen".to_string());
719        // generator deliberately left None
720        let err = prepare(file_with(entry)).expect_err("missing generator must fail");
721        match err {
722            PrepareError::MissingGenerator { entry_label } => {
723                assert_eq!(entry_label, "no_gen");
724            }
725            other => panic!("expected MissingGenerator, got {other:?}"),
726        }
727    }
728
729    /// A logs entry missing `log_generator` produces `MissingLogGenerator`.
730    #[test]
731    fn logs_without_log_generator_produces_missing_log_generator_error() {
732        let entry = bare("logs", "no_log_gen");
733        let err = prepare(file_with(entry)).expect_err("missing log_generator must fail");
734        match err {
735            PrepareError::MissingLogGenerator { entry_label } => {
736                assert_eq!(entry_label, "no_log_gen");
737            }
738            other => panic!("expected MissingLogGenerator, got {other:?}"),
739        }
740    }
741
742    /// A histogram entry missing `distribution` produces `MissingDistribution`
743    /// with `signal_type: "histogram"`.
744    #[test]
745    fn histogram_without_distribution_produces_missing_distribution_error() {
746        let entry = bare("histogram", "no_dist_hist");
747        let err = prepare(file_with(entry)).expect_err("missing distribution must fail");
748        match err {
749            PrepareError::MissingDistribution {
750                entry_label,
751                signal_type,
752            } => {
753                assert_eq!(entry_label, "no_dist_hist");
754                assert_eq!(signal_type, "histogram");
755            }
756            other => panic!("expected MissingDistribution, got {other:?}"),
757        }
758    }
759
760    /// A summary entry missing `distribution` produces `MissingDistribution`
761    /// with `signal_type: "summary"`.
762    #[test]
763    fn summary_without_distribution_produces_missing_distribution_error() {
764        let entry = bare("summary", "no_dist_summary");
765        let err = prepare(file_with(entry)).expect_err("missing distribution must fail");
766        match err {
767            PrepareError::MissingDistribution {
768                entry_label,
769                signal_type,
770            } => {
771                assert_eq!(entry_label, "no_dist_summary");
772                assert_eq!(signal_type, "summary");
773            }
774            other => panic!("expected MissingDistribution, got {other:?}"),
775        }
776    }
777
778    /// Which `PrepareError` variant a missing-required-field shape must
779    /// produce. Indexes the rstest matrix below: `metrics` -> generator,
780    /// `logs` -> log_generator, `histogram`/`summary` -> distribution.
781    #[derive(Debug, Clone, Copy)]
782    enum ExpectedMissing {
783        Generator,
784        LogGenerator,
785        Distribution,
786    }
787
788    /// Rstest matrix covering every signal_type whose shape-invariant can
789    /// fail when the required generator/distribution field is absent.
790    /// Each case asserts the exact `PrepareError` variant — strengthening
791    /// the previous `is_err()` smoke check.
792    #[rustfmt::skip]
793    #[rstest]
794    #[case::metrics("metrics", ExpectedMissing::Generator)]
795    #[case::logs("logs", ExpectedMissing::LogGenerator)]
796    #[case::histogram("histogram", ExpectedMissing::Distribution)]
797    #[case::summary("summary", ExpectedMissing::Distribution)]
798    fn missing_required_field_fails_per_signal_type(
799        #[case] signal_type: &str,
800        #[case] expected: ExpectedMissing,
801    ) {
802        let entry = bare(signal_type, "empty_shape");
803        let err = prepare(file_with(entry)).err().unwrap_or_else(|| {
804            panic!("signal_type '{signal_type}' missing required field must error")
805        });
806        let matched = match expected {
807            ExpectedMissing::Generator => {
808                matches!(err, PrepareError::MissingGenerator { ref entry_label } if entry_label == "empty_shape")
809            }
810            ExpectedMissing::LogGenerator => {
811                matches!(err, PrepareError::MissingLogGenerator { ref entry_label } if entry_label == "empty_shape")
812            }
813            ExpectedMissing::Distribution => matches!(
814                err,
815                PrepareError::MissingDistribution { ref entry_label, signal_type: ref st }
816                if entry_label == "empty_shape" && st == signal_type
817            ),
818        };
819        assert!(
820            matched,
821            "signal_type '{signal_type}': expected {expected:?}, got {err:?}"
822        );
823    }
824
825    // -- First-error propagation --------------------------------------------
826
827    /// `prepare` surfaces the FIRST failing entry's error, leaving later
828    /// entries unevaluated. Callers cannot observe partial output.
829    #[test]
830    fn prepare_fails_fast_on_first_bad_entry() {
831        let file = CompiledFile {
832            version: 2,
833            scenario_name: None,
834            entries: vec![
835                metrics_compiled("ok_1"),
836                bare("traces", "bad"),
837                metrics_compiled("ok_2"),
838            ],
839        };
840        let err = prepare(file).expect_err("bad entry in middle must fail");
841        assert!(
842            matches!(err, PrepareError::UnknownSignalType { .. }),
843            "middle bad entry must produce UnknownSignalType, got {err:?}"
844        );
845    }
846
847    // -- Contract: PrepareError is Send + Sync ------------------------------
848
849    #[test]
850    fn prepare_error_is_send_and_sync() {
851        fn assert_send_sync<T: Send + Sync>() {}
852        assert_send_sync::<PrepareError>();
853    }
854
855    // -- Version gate -------------------------------------------------------
856
857    /// A [`CompiledFile`] with a non-v2 version is rejected with
858    /// [`PrepareError::UnsupportedVersion`] carrying the offending value.
859    #[test]
860    fn prepare_rejects_non_v2_version() {
861        let file = CompiledFile {
862            version: 3,
863            scenario_name: None,
864            entries: vec![metrics_compiled("never_translated")],
865        };
866        let err = prepare(file).expect_err("version != 2 must fail");
867        match err {
868            PrepareError::UnsupportedVersion { version } => assert_eq!(version, 3),
869            other => panic!("expected UnsupportedVersion, got {other:?}"),
870        }
871    }
872
873    /// The `UnsupportedVersion` check fires before any entry-level
874    /// translation, so a bogus version with an otherwise-invalid entry
875    /// surfaces the version error (not the entry error).
876    #[test]
877    fn prepare_version_check_precedes_entry_translation() {
878        let file = CompiledFile {
879            version: 0,
880            scenario_name: None,
881            entries: vec![bare("traces", "would_fail_if_translated")],
882        };
883        let err = prepare(file).expect_err("version 0 must fail");
884        assert!(
885            matches!(err, PrepareError::UnsupportedVersion { version: 0 }),
886            "expected UnsupportedVersion {{ version: 0 }}, got {err:?}"
887        );
888    }
889}