Skip to main content

sonda_core/
compile.rs

1//! One-shot v2 scenario compilation from YAML to the runtime's input shape.
2//!
3//! This module composes the v2 compilation phases — `env_interpolate`,
4//! `parse`, `normalize`, `expand`, `compile_after`, and `prepare` — behind a
5//! single callable so that library consumers can go from YAML text to
6//! `Vec<ScenarioEntry>` in one step. `env_interpolate` runs first so every
7//! caller (CLI file load, HTTP body POST, programmatic) gets the same
8//! `${VAR}` / `${VAR:-default}` substitution semantics.
9//!
10//! Every caller (CLI, server, tests) goes through this entry point — the
11//! runtime accepts `Vec<ScenarioEntry>` directly and there is no v1 fallback.
12//!
13//! # Phase boundaries
14//!
15//! Callers who need to inspect an intermediate representation (e.g. a
16//! [`NormalizedFile`][crate::compiler::normalize::NormalizedFile]) should
17//! invoke the phase functions individually. [`compile_scenario_file`] is a
18//! convenience wrapper; every error variant it returns is the same error the
19//! underlying phase would have produced — see [`CompileError`].
20
21use crate::compiler::compile_after::{compile_after, CompileAfterError, CompiledFile};
22use crate::compiler::env_interpolate::{interpolate, InterpolateError};
23use crate::compiler::expand::{expand, ExpandError, PackResolver};
24use crate::compiler::normalize::{normalize, NormalizeError};
25use crate::compiler::parse::{parse, ParseError};
26use crate::compiler::prepare::{prepare, PrepareError};
27use crate::config::ScenarioEntry;
28
29/// Errors produced by [`compile_scenario_file`].
30///
31/// Each variant wraps the corresponding phase's error so callers can
32/// programmatically discriminate where compilation failed without string
33/// matching. The `#[from]` conversions let each phase's fallible call site
34/// bubble up naturally via `?`.
35#[derive(Debug, thiserror::Error)]
36#[non_exhaustive]
37pub enum CompileError {
38    /// **Phase 0** (env_interpolate): `${VAR}` substitution against the
39    /// process environment failed (unset required variable, malformed
40    /// reference, or invalid variable name).
41    #[error("env interpolation error")]
42    EnvInterpolate(#[from] InterpolateError),
43
44    /// **Phase 1** (parse): YAML parsing or schema validation failed.
45    #[error("parse error")]
46    Parse(#[from] ParseError),
47
48    /// **Phase 2** (normalize): defaults resolution failed (e.g. an entry
49    /// was missing a required field with no default available).
50    #[error("normalize error")]
51    Normalize(#[from] NormalizeError),
52
53    /// **Phase 3** (expand): pack expansion failed (unknown pack, unknown
54    /// override key, duplicate id, or resolver I/O error).
55    #[error("expand error")]
56    Expand(#[from] ExpandError),
57
58    /// **Phase 4+5** (compile_after): `after:` resolution, dependency
59    /// graph, or clock-group assignment failed.
60    #[error("compile_after error")]
61    CompileAfter(#[from] CompileAfterError),
62
63    /// **Phase 6** (prepare): translation to the runtime input shape
64    /// failed. Shape invariants not visible to earlier phases surface
65    /// here — e.g. an unknown `signal_type` on a programmatically-
66    /// constructed [`CompiledFile`][crate::compiler::compile_after::CompiledFile].
67    ///
68    /// Note: the [`PrepareError::UnknownSignalType`],
69    /// [`PrepareError::MissingGenerator`],
70    /// [`PrepareError::MissingLogGenerator`], and
71    /// [`PrepareError::MissingDistribution`] cases are effectively
72    /// unreachable when the input comes through
73    /// [`compile_scenario_file`] — earlier phases gate those shapes at
74    /// YAML-level. They remain reachable for programmatic callers that
75    /// build a [`CompiledFile`][crate::compiler::compile_after::CompiledFile]
76    /// in code and feed it directly to
77    /// [`prepare`][crate::compiler::prepare::prepare].
78    #[error("prepare error")]
79    Prepare(#[from] PrepareError),
80
81    /// The YAML uses `while:` or `delay:` clauses, which require the
82    /// gated runtime. [`compile_scenario_file`] returns
83    /// `Vec<ScenarioEntry>`, a shape that has no fields for these
84    /// clauses, so silently accepting such input would drop the gate
85    /// semantics and run the downstream as ungated. Call
86    /// [`compile_scenario_file_compiled`] instead and feed the
87    /// resulting [`CompiledFile`] to
88    /// [`run_multi_compiled`][crate::schedule::multi_runner::run_multi_compiled].
89    #[error(
90        "scenario `{id}` uses {clause} (continuous coupling); call \
91         `compile_scenario_file_compiled` and feed the result to \
92         `run_multi_compiled` to preserve gate semantics"
93    )]
94    GatedClauseRequiresCompiledPath {
95        /// The id of the entry that carries the gated clause.
96        id: String,
97        /// Which clause kind tripped the check (`"while:"` or `"delay:"`).
98        clause: &'static str,
99    },
100}
101
102/// Compile a v2 scenario YAML into the runtime's `Vec<ScenarioEntry>` input
103/// shape.
104///
105/// The returned entries are ready to hand to
106/// [`prepare_entries`][crate::schedule::launch::prepare_entries] (which
107/// handles phase-offset parsing, csv_replay expansion, and validation) and
108/// subsequently [`launch_scenario`][crate::schedule::launch::launch_scenario]
109/// or [`run_multi`][crate::schedule::multi_runner::run_multi].
110///
111/// # Parameters
112///
113/// * `yaml` — raw v2 scenario YAML source. Version 2 is mandatory; v1
114///   scenario shapes (flat single-entry, `pack:` shorthand, top-level
115///   `scenarios:` list without `version: 2`) are rejected by
116///   [`parse`][crate::compiler::parse::parse] with a clear error.
117/// * `resolver` — pack-reference resolver used by
118///   [`expand`][crate::compiler::expand::expand]. Pass an
119///   [`InMemoryPackResolver`][crate::compiler::expand::InMemoryPackResolver]
120///   seeded with the packs your scenario references, or a filesystem-backed
121///   implementation for CLI-style usage.
122///
123/// # Errors
124///
125/// Returns a [`CompileError`] variant corresponding to the phase that
126/// rejected the input; no partial output is produced.
127pub fn compile_scenario_file(
128    yaml: &str,
129    resolver: &dyn PackResolver,
130) -> Result<Vec<ScenarioEntry>, CompileError> {
131    let compiled = compile_scenario_file_compiled(yaml, resolver)?;
132    for (idx, entry) in compiled.entries.iter().enumerate() {
133        let entry_label = || entry.id.clone().unwrap_or_else(|| format!("entry[{idx}]"));
134        if entry.while_clause.is_some() {
135            return Err(CompileError::GatedClauseRequiresCompiledPath {
136                id: entry_label(),
137                clause: "while:",
138            });
139        }
140        if entry.delay_clause.is_some() {
141            return Err(CompileError::GatedClauseRequiresCompiledPath {
142                id: entry_label(),
143                clause: "delay:",
144            });
145        }
146    }
147    Ok(prepare(compiled)?)
148}
149
150/// Compile a v2 scenario YAML to a [`CompiledFile`], preserving `while:` /
151/// `delay:` clauses for the gated multi-runner.
152///
153/// Use this entry point when the runtime needs to wire `while:` gates
154/// across scenarios. [`compile_scenario_file`] discards `while_clause` /
155/// `delay_clause` because [`ScenarioEntry`] has no fields for them — the
156/// gated multi-runner subscribes downstreams to upstream
157/// [`GateBus`][crate::schedule::gate_bus::GateBus]es via
158/// [`run_multi_compiled`][crate::schedule::multi_runner::run_multi_compiled],
159/// which consumes a [`CompiledFile`].
160pub fn compile_scenario_file_compiled(
161    yaml: &str,
162    resolver: &dyn PackResolver,
163) -> Result<CompiledFile, CompileError> {
164    // `expand` uses a `Sized` generic bound, so wrap the trait object in a
165    // local `Sized` adapter that forwards each call. This keeps the public
166    // signature `&dyn PackResolver` (object-safe, no monomorphization blow-up
167    // for callers that cross module boundaries) without modifying `expand`'s
168    // API.
169    let wrapped = DynPackResolver(resolver);
170    let interpolated = interpolate(yaml)?;
171    let parsed = parse(&interpolated)?;
172    let normalized = normalize(parsed)?;
173    let expanded = expand(normalized, &wrapped)?;
174    Ok(compile_after(expanded)?)
175}
176
177/// Adapter that implements the `Sized` bound `expand` requires while
178/// delegating to an underlying `&dyn PackResolver`.
179struct DynPackResolver<'a>(&'a dyn PackResolver);
180
181impl<'a> PackResolver for DynPackResolver<'a> {
182    fn resolve(
183        &self,
184        reference: &str,
185    ) -> Result<crate::packs::MetricPackDef, crate::compiler::expand::PackResolveError> {
186        self.0.resolve(reference)
187    }
188}
189
190// ---------------------------------------------------------------------------
191// Tests
192// ---------------------------------------------------------------------------
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use crate::compiler::expand::InMemoryPackResolver;
198
199    fn empty_resolver() -> InMemoryPackResolver {
200        InMemoryPackResolver::new()
201    }
202
203    /// Happy path: a minimal inline v2 YAML compiles cleanly and produces
204    /// one [`ScenarioEntry`].
205    #[test]
206    fn one_shot_compiles_minimal_inline_scenario() {
207        let yaml = r#"
208version: 2
209
210defaults:
211  rate: 10
212  duration: 500ms
213
214scenarios:
215  - id: cpu
216    signal_type: metrics
217    name: cpu_usage
218    generator:
219      type: constant
220      value: 1.0
221"#;
222        let resolver = empty_resolver();
223        let entries = compile_scenario_file(yaml, &resolver).expect("one-shot must succeed");
224        assert_eq!(entries.len(), 1);
225        assert_eq!(entries[0].base().name, "cpu_usage");
226        assert_eq!(entries[0].base().rate, 10.0);
227    }
228
229    /// `parse` failures surface as `CompileError::Parse`.
230    #[test]
231    fn parse_failure_surfaces_as_parse_variant() {
232        let yaml = "version: 1\nscenarios: []\n";
233        let resolver = empty_resolver();
234        let err = compile_scenario_file(yaml, &resolver).expect_err("v1 yaml must fail");
235        assert!(
236            matches!(err, CompileError::Parse(_)),
237            "v1 version must surface as Parse, got {err:?}"
238        );
239    }
240
241    #[test]
242    fn yaml_with_while_clause_rejected_with_compiled_path_hint() {
243        let yaml = r#"
244version: 2
245
246defaults:
247  rate: 1
248  duration: 30s
249
250scenarios:
251  - id: upstream
252    signal_type: metrics
253    name: upstream
254    generator:
255      type: flap
256      up_duration: 5s
257      down_duration: 5s
258
259  - id: downstream
260    signal_type: metrics
261    name: downstream
262    generator:
263      type: constant
264      value: 1.0
265    while:
266      ref: upstream
267      op: "<"
268      value: 1
269"#;
270        let resolver = empty_resolver();
271        let err = compile_scenario_file(yaml, &resolver)
272            .expect_err("while: must reject through the lossy entry point");
273        match err {
274            CompileError::GatedClauseRequiresCompiledPath { id, clause } => {
275                assert_eq!(id, "downstream");
276                assert_eq!(clause, "while:");
277            }
278            other => panic!("expected GatedClauseRequiresCompiledPath, got {other:?}"),
279        }
280    }
281
282    #[test]
283    fn yaml_with_delay_clause_rejected_with_compiled_path_hint() {
284        let yaml = r#"
285version: 2
286
287defaults:
288  rate: 1
289  duration: 30s
290
291scenarios:
292  - id: upstream
293    signal_type: metrics
294    name: upstream
295    generator:
296      type: flap
297      up_duration: 5s
298      down_duration: 5s
299
300  - id: downstream
301    signal_type: metrics
302    name: downstream
303    generator:
304      type: constant
305      value: 1.0
306    while:
307      ref: upstream
308      op: "<"
309      value: 1
310    delay:
311      open: 2s
312      close: 0s
313"#;
314        let resolver = empty_resolver();
315        let err = compile_scenario_file(yaml, &resolver)
316            .expect_err("delay: must reject through the lossy entry point");
317        match err {
318            CompileError::GatedClauseRequiresCompiledPath { id, clause } => {
319                assert_eq!(id, "downstream");
320                assert!(
321                    clause == "while:" || clause == "delay:",
322                    "expected while: or delay:, got {clause}"
323                );
324            }
325            other => panic!("expected GatedClauseRequiresCompiledPath, got {other:?}"),
326        }
327    }
328
329    /// `normalize` failures surface as `CompileError::Normalize`.
330    /// A metrics entry without `rate` (and no default) fails at Phase 2.
331    #[test]
332    fn normalize_failure_surfaces_as_normalize_variant() {
333        let yaml = r#"
334version: 2
335
336scenarios:
337  - id: no_rate
338    signal_type: metrics
339    name: no_rate
340    generator:
341      type: constant
342      value: 1.0
343"#;
344        let resolver = empty_resolver();
345        let err = compile_scenario_file(yaml, &resolver).expect_err("missing rate must fail");
346        assert!(
347            matches!(err, CompileError::Normalize(_)),
348            "missing rate must surface as Normalize, got {err:?}"
349        );
350    }
351
352    /// `expand` failures surface as `CompileError::Expand`.
353    /// An unresolvable pack name produces ResolveFailed.
354    #[test]
355    fn expand_failure_surfaces_as_expand_variant() {
356        let yaml = r#"
357version: 2
358
359defaults:
360  rate: 1
361
362scenarios:
363  - signal_type: metrics
364    pack: unknown_pack_xyz
365"#;
366        let resolver = empty_resolver();
367        let err = compile_scenario_file(yaml, &resolver).expect_err("unknown pack must fail");
368        assert!(
369            matches!(err, CompileError::Expand(_)),
370            "unresolvable pack must surface as Expand, got {err:?}"
371        );
372    }
373
374    /// `compile_after` failures surface as `CompileError::CompileAfter`.
375    /// A self-reference fires `SelfReference`.
376    #[test]
377    fn compile_after_failure_surfaces_as_compile_after_variant() {
378        let yaml = r#"
379version: 2
380
381defaults:
382  rate: 1
383
384scenarios:
385  - id: loopy
386    signal_type: metrics
387    name: loopy
388    generator:
389      type: flap
390      up_duration: 60s
391      down_duration: 30s
392    after:
393      ref: loopy
394      op: "<"
395      value: 1
396"#;
397        let resolver = empty_resolver();
398        let err = compile_scenario_file(yaml, &resolver).expect_err("self-ref must fail");
399        assert!(
400            matches!(err, CompileError::CompileAfter(_)),
401            "self-reference must surface as CompileAfter, got {err:?}"
402        );
403    }
404
405    /// Error types satisfy Send + Sync so they can cross thread boundaries.
406    #[test]
407    fn compile_error_is_send_and_sync() {
408        fn assert_send_sync<T: Send + Sync>() {}
409        assert_send_sync::<CompileError>();
410    }
411}