1pub mod compiler;
22pub mod config;
23pub mod emit;
24pub mod encoder;
25pub mod generator;
26pub mod model;
27pub mod packs;
28pub mod scenarios;
29pub mod schedule;
30pub mod sink;
31pub(crate) mod util;
32
33pub use config::aliases::{desugar_entry, desugar_scenario_config};
34pub use config::BaseScheduleConfig;
35pub use config::BurstConfig;
36pub use config::CardinalitySpikeConfig;
37pub use config::DistributionConfig;
38pub use config::DynamicLabelConfig;
39pub use config::DynamicLabelStrategy;
40pub use config::HistogramScenarioConfig;
41pub use config::LogScenarioConfig;
42pub use config::OnSinkError;
43pub use config::ScenarioEntry;
44pub use config::SpikeStrategy;
45pub use config::SummaryScenarioConfig;
46pub use config::{expand_entry, expand_scenario};
47pub use model::log::LogEvent;
48pub use model::log::Severity;
49pub use model::metric::Labels;
50pub use model::metric::MetricEvent;
51pub use model::metric::ValidatedMetricName;
52pub use scenarios::BuiltinScenario;
53pub use schedule::handle::ScenarioHandle;
54pub use schedule::launch::{launch_scenario, prepare_entries, validate_entry, PreparedEntry};
55pub use schedule::stats::{ScenarioState, ScenarioStats};
56
57#[cfg(feature = "config")]
58pub use compiler::prepare::PrepareError;
59
60#[cfg(feature = "config")]
61pub use compile::{compile_scenario_file, compile_scenario_file_compiled, CompileError};
62
63#[cfg(feature = "config")]
64mod compile;
65
66#[derive(Debug, thiserror::Error)]
79#[non_exhaustive]
80pub enum SondaError {
81 #[error("configuration error: {0}")]
83 Config(#[from] ConfigError),
84
85 #[error("encoder error: {0}")]
87 Encoder(#[from] EncoderError),
88
89 #[error("sink error: {0}")]
95 Sink(std::io::Error),
96
97 #[error("generator error: {0}")]
99 Generator(#[from] GeneratorError),
100
101 #[error("runtime error: {0}")]
108 Runtime(#[from] RuntimeError),
109}
110
111#[derive(Debug, thiserror::Error)]
117#[non_exhaustive]
118pub enum ConfigError {
119 #[error("{0}")]
124 InvalidValue(String),
125}
126
127impl ConfigError {
128 pub(crate) fn invalid(msg: impl Into<String>) -> Self {
130 ConfigError::InvalidValue(msg.into())
131 }
132}
133
134#[derive(Debug, thiserror::Error)]
142#[non_exhaustive]
143pub enum GeneratorError {
144 #[error("cannot read file {path:?}")]
150 FileRead {
151 path: String,
153 #[source]
155 source: std::io::Error,
156 },
157}
158
159impl GeneratorError {
160 pub fn source_io_kind(&self) -> Option<std::io::ErrorKind> {
166 match self {
167 GeneratorError::FileRead { source, .. } => Some(source.kind()),
168 }
169 }
170}
171
172#[derive(Debug, thiserror::Error)]
177#[non_exhaustive]
178pub enum EncoderError {
179 #[error("JSON serialization failed")]
184 SerializationFailed(#[source] serde_json::Error),
185
186 #[error("timestamp before Unix epoch")]
191 TimestampBeforeEpoch(#[source] std::time::SystemTimeError),
192
193 #[error("{0}")]
196 NotSupported(String),
197
198 #[error("{0}")]
204 Other(String),
205}
206
207#[derive(Debug, thiserror::Error)]
212#[non_exhaustive]
213pub enum RuntimeError {
214 #[error("failed to spawn scenario thread")]
220 SpawnFailed(#[source] std::io::Error),
221
222 #[error("scenario thread panicked")]
224 ThreadPanicked,
225
226 #[error("{0}")]
233 ScenariosFailed(String),
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 #[test]
243 fn io_error_does_not_auto_convert_to_sonda_error() {
244 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
247 let sonda_err = SondaError::Sink(io_err);
248 assert!(
249 matches!(sonda_err, SondaError::Sink(_)),
250 "explicit Sink construction must produce Sink variant"
251 );
252 }
253
254 #[test]
255 fn missing_replay_file_produces_generator_error_not_sink() {
256 let path = std::path::Path::new("/nonexistent/path/for/replay.log");
257 let result = generator::log_replay::LogReplayGenerator::from_file(path);
258 match result {
259 Err(ref err) => {
260 assert!(
261 matches!(err, SondaError::Generator(_)),
262 "missing replay file must produce Generator variant, got: {err:?}"
263 );
264 }
265 Ok(_) => panic!("missing file must return Err"),
266 }
267 }
268
269 #[test]
270 fn missing_csv_file_produces_generator_error_not_sink() {
271 let result = generator::csv_replay::CsvReplayGenerator::new(
272 "/nonexistent/path/for/data.csv",
273 0,
274 true,
275 );
276 match result {
277 Err(SondaError::Generator(GeneratorError::FileRead {
278 ref path,
279 ref source,
280 })) => {
281 assert_eq!(path, "/nonexistent/path/for/data.csv");
282 assert_eq!(source.kind(), std::io::ErrorKind::NotFound);
283 }
284 Err(ref err) => {
285 panic!("missing CSV file must produce Generator(FileRead) variant, got: {err:?}");
286 }
287 Ok(_) => panic!("missing CSV file must return Err"),
288 }
289 }
290
291 #[test]
292 fn log_replay_factory_missing_file_produces_generator_error() {
293 let config = generator::LogGeneratorConfig::Replay {
294 file: "/nonexistent/path/for/replay.log".to_string(),
295 };
296 let result = generator::create_log_generator(&config);
297 match result {
298 Err(ref err) => {
299 assert!(
300 matches!(err, SondaError::Generator(_)),
301 "factory with missing replay file must produce Generator variant, got: {err:?}"
302 );
303 }
304 Ok(_) => panic!("missing replay file must return Err"),
305 }
306 }
307
308 #[test]
309 fn sink_file_error_produces_sink_variant() {
310 let result = sink::file::FileSink::new(std::path::Path::new(
312 "/nonexistent/deeply/nested/path/output.txt",
313 ));
314 match result {
315 Err(ref err) => {
316 assert!(
317 matches!(err, SondaError::Sink(_)),
318 "file sink I/O error must produce Sink variant, got: {err:?}"
319 );
320 }
321 Ok(_) => panic!("invalid file path must return Err"),
322 }
323 }
324
325 #[test]
326 fn sonda_error_display_includes_context() {
327 let err = SondaError::Generator(GeneratorError::FileRead {
328 path: "/some/file".to_string(),
329 source: std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"),
330 });
331 let msg = format!("{err}");
332 assert!(
333 msg.contains("generator error"),
334 "Generator variant display must include 'generator error', got: {msg}"
335 );
336 assert!(
337 msg.contains("/some/file"),
338 "Generator variant display must include the file path, got: {msg}"
339 );
340 }
341
342 #[test]
345 fn config_error_converts_to_sonda_error_via_from() {
346 let config_err = ConfigError::invalid("rate must be positive");
347 let sonda_err: SondaError = config_err.into();
348 assert!(
349 matches!(sonda_err, SondaError::Config(_)),
350 "ConfigError must convert to SondaError::Config"
351 );
352 }
353
354 #[test]
355 fn generator_error_converts_to_sonda_error_via_from() {
356 let gen_err = GeneratorError::FileRead {
357 path: "/tmp/test.csv".to_string(),
358 source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
359 };
360 let sonda_err: SondaError = gen_err.into();
361 assert!(
362 matches!(sonda_err, SondaError::Generator(_)),
363 "GeneratorError must convert to SondaError::Generator"
364 );
365 }
366
367 #[test]
368 fn encoder_error_converts_to_sonda_error_via_from() {
369 let enc_err = EncoderError::NotSupported("log encoding not supported".into());
370 let sonda_err: SondaError = enc_err.into();
371 assert!(
372 matches!(sonda_err, SondaError::Encoder(_)),
373 "EncoderError must convert to SondaError::Encoder"
374 );
375 }
376
377 #[test]
378 fn runtime_error_converts_to_sonda_error_via_from() {
379 let rt_err = RuntimeError::ThreadPanicked;
380 let sonda_err: SondaError = rt_err.into();
381 assert!(
382 matches!(sonda_err, SondaError::Runtime(_)),
383 "RuntimeError must convert to SondaError::Runtime"
384 );
385 }
386
387 #[test]
390 fn generator_file_read_preserves_io_error_source() {
391 use std::error::Error;
392
393 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
394 let gen_err = GeneratorError::FileRead {
395 path: "/secret/file".to_string(),
396 source: io_err,
397 };
398
399 let source = gen_err.source().expect("source() must return Some");
401 let io_source = source
402 .downcast_ref::<std::io::Error>()
403 .expect("source must be std::io::Error");
404 assert_eq!(io_source.kind(), std::io::ErrorKind::PermissionDenied);
405 }
406
407 #[test]
408 fn generator_file_read_io_error_kind_is_inspectable() {
409 let gen_err = GeneratorError::FileRead {
410 path: "/missing/file".to_string(),
411 source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
412 };
413 assert_eq!(gen_err.source_io_kind(), Some(std::io::ErrorKind::NotFound));
415 }
416
417 #[test]
418 fn encoder_serialization_preserves_serde_json_source() {
419 use std::error::Error;
420
421 let json_err: serde_json::Error = serde_json::from_str::<serde_json::Value>("{{invalid}}")
423 .expect_err("invalid JSON must fail");
424 let enc_err = EncoderError::SerializationFailed(json_err);
425
426 let source = enc_err.source().expect("source() must return Some");
427 assert!(
428 source.downcast_ref::<serde_json::Error>().is_some(),
429 "source must be serde_json::Error"
430 );
431 }
432
433 #[test]
434 fn encoder_timestamp_preserves_system_time_source() {
435 use std::error::Error;
436
437 let pre_epoch = std::time::UNIX_EPOCH - std::time::Duration::from_secs(1);
438 let sys_err = pre_epoch.duration_since(std::time::UNIX_EPOCH).unwrap_err();
439 let enc_err = EncoderError::TimestampBeforeEpoch(sys_err);
440
441 let source = enc_err.source().expect("source() must return Some");
442 assert!(
443 source
444 .downcast_ref::<std::time::SystemTimeError>()
445 .is_some(),
446 "source must be SystemTimeError"
447 );
448 }
449
450 #[test]
453 fn spawn_failed_is_runtime_not_config() {
454 let io_err = std::io::Error::new(std::io::ErrorKind::Other, "resource limit");
455 let rt_err = RuntimeError::SpawnFailed(io_err);
456 let sonda_err: SondaError = rt_err.into();
457 assert!(
458 matches!(sonda_err, SondaError::Runtime(RuntimeError::SpawnFailed(_))),
459 "thread spawn failure must be Runtime::SpawnFailed, not Config"
460 );
461 }
462
463 #[test]
464 fn thread_panicked_is_runtime_not_config() {
465 let rt_err = RuntimeError::ThreadPanicked;
466 let sonda_err: SondaError = rt_err.into();
467 assert!(
468 matches!(sonda_err, SondaError::Runtime(RuntimeError::ThreadPanicked)),
469 "thread panic must be Runtime::ThreadPanicked, not Config"
470 );
471 }
472
473 #[test]
474 fn runtime_error_display_is_descriptive() {
475 let spawn_err = RuntimeError::SpawnFailed(std::io::Error::new(
476 std::io::ErrorKind::Other,
477 "too many threads",
478 ));
479 let msg = format!("{spawn_err}");
480 assert!(
481 msg.contains("failed to spawn scenario thread"),
482 "SpawnFailed display must describe the spawn failure, got: {msg}"
483 );
484
485 let panic_err = RuntimeError::ThreadPanicked;
486 let msg = format!("{panic_err}");
487 assert!(
488 msg.contains("panicked"),
489 "ThreadPanicked display must mention panic, got: {msg}"
490 );
491
492 let scenarios_err =
493 RuntimeError::ScenariosFailed("sink error: broken pipe; sink error: timeout".into());
494 let msg = format!("{scenarios_err}");
495 assert!(
496 msg.contains("sink error"),
497 "ScenariosFailed display must include the collected messages, got: {msg}"
498 );
499 }
500
501 #[test]
502 fn spawn_failed_preserves_io_error_source() {
503 use std::error::Error;
504
505 let io_err = std::io::Error::new(
506 std::io::ErrorKind::WouldBlock,
507 "resource temporarily unavailable",
508 );
509 let rt_err = RuntimeError::SpawnFailed(io_err);
510
511 let source = rt_err
512 .source()
513 .expect("SpawnFailed source() must return Some");
514 let io_source = source
515 .downcast_ref::<std::io::Error>()
516 .expect("source must be std::io::Error");
517 assert_eq!(io_source.kind(), std::io::ErrorKind::WouldBlock);
518 }
519
520 #[test]
521 fn spawn_failed_source_chain_traverses_through_sonda_error() {
522 use std::error::Error;
523
524 let io_err =
525 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "cannot create thread");
526 let sonda_err = SondaError::Runtime(RuntimeError::SpawnFailed(io_err));
527
528 let runtime_source = sonda_err
530 .source()
531 .expect("SondaError::Runtime source() must return Some");
532 let rt_err = runtime_source
533 .downcast_ref::<RuntimeError>()
534 .expect("first source must be RuntimeError");
535
536 let io_source = rt_err
538 .source()
539 .expect("SpawnFailed source() must return Some");
540 let io_inner = io_source
541 .downcast_ref::<std::io::Error>()
542 .expect("second source must be std::io::Error");
543 assert_eq!(io_inner.kind(), std::io::ErrorKind::PermissionDenied);
544 }
545
546 #[test]
547 fn scenarios_failed_is_runtime_not_config() {
548 let rt_err = RuntimeError::ScenariosFailed("thread failed".into());
549 let sonda_err: SondaError = rt_err.into();
550 assert!(
551 matches!(
552 sonda_err,
553 SondaError::Runtime(RuntimeError::ScenariosFailed(_))
554 ),
555 "multi-scenario failures must be Runtime::ScenariosFailed, not Config"
556 );
557 }
558
559 #[test]
560 fn scenarios_failed_converts_to_sonda_error_via_from() {
561 let rt_err = RuntimeError::ScenariosFailed("sink error: broken pipe".into());
562 let sonda_err: SondaError = rt_err.into();
563 assert!(
564 matches!(sonda_err, SondaError::Runtime(_)),
565 "ScenariosFailed must convert to SondaError::Runtime"
566 );
567 }
568
569 #[test]
574 fn config_types_constructible_without_yaml_parsing() {
575 use crate::config::{BaseScheduleConfig, ScenarioConfig};
576 use crate::encoder::EncoderConfig;
577 use crate::generator::GeneratorConfig;
578 use crate::sink::SinkConfig;
579
580 let _config = ScenarioConfig {
581 base: BaseScheduleConfig {
582 name: "test".to_string(),
583 rate: 10.0,
584 duration: None,
585 gaps: None,
586 bursts: None,
587 cardinality_spikes: None,
588 dynamic_labels: None,
589 labels: None,
590 sink: SinkConfig::Stdout,
591 phase_offset: None,
592 clock_group: None,
593 clock_group_is_auto: None,
594 jitter: None,
595 jitter_seed: None,
596 on_sink_error: crate::OnSinkError::Warn,
597 },
598 generator: GeneratorConfig::Constant { value: 1.0 },
599 encoder: EncoderConfig::PrometheusText { precision: None },
600 };
601 }
602
603 #[cfg(feature = "config")]
605 #[test]
606 fn config_feature_enables_yaml_deserialization() {
607 use crate::config::ScenarioConfig;
608
609 let yaml = r#"
610name: test
611rate: 10
612generator:
613 type: constant
614 value: 1.0
615"#;
616 let config: ScenarioConfig = serde_yaml_ng::from_str(yaml)
617 .expect("YAML deserialization must work with config feature");
618 assert_eq!(config.name, "test");
619 }
620
621 #[test]
624 fn factory_functions_work_without_deserialization() {
625 use crate::encoder::{create_encoder, EncoderConfig};
626 use crate::generator::{create_generator, GeneratorConfig};
627 use crate::sink::{create_sink, SinkConfig};
628
629 let gen_config = GeneratorConfig::Constant { value: 42.0 };
630 let gen = create_generator(&gen_config, 1.0).expect("generator factory must succeed");
631 assert_eq!(gen.value(0), 42.0);
632
633 let enc_config = EncoderConfig::PrometheusText { precision: None };
634 let _enc = create_encoder(&enc_config).expect("encoder factory must succeed");
635
636 let sink_config = SinkConfig::Stdout;
637 let _sink = create_sink(&sink_config, None).expect("sink factory must succeed");
638 }
639
640 #[test]
641 fn sonda_error_sink_display_includes_io_context() {
642 let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe broke");
643 let err = SondaError::Sink(io_err);
644 let msg = format!("{err}");
645 assert!(
646 msg.contains("sink error"),
647 "Sink variant display must include 'sink error', got: {msg}"
648 );
649 assert!(
650 msg.contains("pipe broke"),
651 "Sink variant display must include the I/O error message, got: {msg}"
652 );
653 }
654
655 #[test]
658 fn error_types_are_send_and_sync() {
659 fn assert_send_sync<T: Send + Sync>() {}
660 assert_send_sync::<SondaError>();
661 assert_send_sync::<ConfigError>();
662 assert_send_sync::<GeneratorError>();
663 assert_send_sync::<EncoderError>();
664 assert_send_sync::<RuntimeError>();
665 }
666}