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}