Skip to main content

simvar_harness/
config.rs

1//! Simulation configuration and result types.
2//!
3//! This module defines the core data structures used to configure simulation runs
4//! and report their outcomes, including [`SimConfig`], [`SimProperties`],
5//! [`SimRunProperties`], and [`SimResult`].
6
7use std::{sync::LazyLock, time::Duration};
8
9use switchy::random::{rng, simulator::seed};
10
11use crate::{RUNS, formatting::TimeFormat as _};
12
13/// Configuration for a simulation run.
14///
15/// Controls various aspects of the simulation environment including randomness,
16/// failure rates, network properties, and timing.
17#[derive(Debug, Clone, Copy)]
18pub struct SimConfig {
19    /// Random seed for reproducible simulations.
20    pub seed: u64,
21    /// Probability (0.0 to 1.0) that a component will fail.
22    pub fail_rate: f64,
23    /// Probability (0.0 to 1.0) that a failed component will be repaired.
24    pub repair_rate: f64,
25    /// Maximum number of TCP messages in flight.
26    pub tcp_capacity: u64,
27    /// Maximum number of UDP messages in flight.
28    pub udp_capacity: u64,
29    /// Whether to randomize the order of actor execution.
30    pub enable_random_order: bool,
31    /// Minimum simulated network latency.
32    pub min_message_latency: Duration,
33    /// Maximum simulated network latency.
34    pub max_message_latency: Duration,
35    /// How long the simulation should run (`Duration::MAX` for unlimited).
36    pub duration: Duration,
37    /// Duration of each simulation tick.
38    pub tick_duration: Duration,
39    /// Offset from Unix epoch for simulated time.
40    #[cfg(feature = "time")]
41    pub epoch_offset: u64,
42    /// Time multiplier for simulation steps.
43    #[cfg(feature = "time")]
44    pub step_multiplier: u64,
45}
46
47impl Default for SimConfig {
48    fn default() -> Self {
49        Self::new()
50    }
51}
52
53impl SimConfig {
54    /// Creates a new `SimConfig` with default values.
55    ///
56    /// Returns a configuration with reasonable defaults for testing.
57    #[must_use]
58    pub const fn new() -> Self {
59        Self {
60            seed: 0,
61            fail_rate: 0.0,
62            repair_rate: 1.0,
63            tcp_capacity: 64,
64            udp_capacity: 64,
65            enable_random_order: false,
66            min_message_latency: Duration::from_millis(0),
67            max_message_latency: Duration::from_secs(1),
68            duration: Duration::MAX,
69            tick_duration: Duration::from_millis(1),
70            #[cfg(feature = "time")]
71            epoch_offset: 0,
72            #[cfg(feature = "time")]
73            step_multiplier: 1,
74        }
75    }
76
77    /// Creates a new `SimConfig` with randomized values.
78    ///
79    /// Uses the current RNG to generate configuration values suitable for
80    /// testing. The `SIMULATOR_DURATION` environment variable can be used
81    /// to override the duration.
82    ///
83    /// # Panics
84    ///
85    /// * If `SIMULATOR_DURATION` is set but cannot be parsed as a supported
86    ///   duration format.
87    ///
88    /// # Examples
89    ///
90    /// ```rust
91    /// use simvar_harness::SimConfig;
92    ///
93    /// let config = SimConfig::from_rng();
94    /// assert!(config.max_message_latency >= config.min_message_latency);
95    /// ```
96    #[must_use]
97    pub fn from_rng() -> Self {
98        static DURATION: LazyLock<Duration> = LazyLock::new(|| {
99            std::env::var("SIMULATOR_DURATION")
100                .ok()
101                .map_or(Duration::MAX, |x| {
102                    #[allow(clippy::option_if_let_else)]
103                    if let Some(x) = x.strip_suffix("µs") {
104                        Duration::from_micros(x.parse::<u64>().unwrap())
105                    } else if let Some(x) = x.strip_suffix("ns") {
106                        Duration::from_nanos(x.parse::<u64>().unwrap())
107                    } else if let Some(x) = x.strip_suffix("ms") {
108                        Duration::from_millis(x.parse::<u64>().unwrap())
109                    } else if let Some(x) = x.strip_suffix("s") {
110                        Duration::from_secs(x.parse::<u64>().unwrap())
111                    } else {
112                        Duration::from_millis(x.parse::<u64>().unwrap())
113                    }
114                })
115        });
116
117        let mut config = Self::new();
118        config.seed = seed();
119
120        let min_message_latency = rng().gen_range_dist(0..=1000, 1.0);
121
122        let config = config
123            .fail_rate(0.0)
124            .repair_rate(1.0)
125            .tcp_capacity(64)
126            .udp_capacity(64)
127            .enable_random_order(true)
128            .min_message_latency(Duration::from_millis(min_message_latency))
129            .max_message_latency(Duration::from_millis(
130                rng().gen_range(min_message_latency..2000),
131            ))
132            .duration(*DURATION);
133
134        #[cfg(feature = "time")]
135        {
136            config.epoch_offset = switchy::time::simulator::epoch_offset();
137            config.step_multiplier = switchy::time::simulator::step_multiplier();
138        }
139
140        #[cfg(feature = "time")]
141        let config = config.tick_duration(Duration::from_millis(
142            switchy::time::simulator::step_multiplier(),
143        ));
144
145        *config
146    }
147
148    /// Sets the failure rate (0.0 to 1.0) and returns a mutable reference to self.
149    #[must_use]
150    pub const fn fail_rate(&mut self, fail_rate: f64) -> &mut Self {
151        self.fail_rate = fail_rate;
152        self
153    }
154
155    /// Sets the repair rate (0.0 to 1.0) and returns a mutable reference to self.
156    #[must_use]
157    pub const fn repair_rate(&mut self, repair_rate: f64) -> &mut Self {
158        self.repair_rate = repair_rate;
159        self
160    }
161
162    /// Sets the TCP capacity and returns a mutable reference to self.
163    #[must_use]
164    pub const fn tcp_capacity(&mut self, tcp_capacity: u64) -> &mut Self {
165        self.tcp_capacity = tcp_capacity;
166        self
167    }
168
169    /// Sets the UDP capacity and returns a mutable reference to self.
170    #[must_use]
171    pub const fn udp_capacity(&mut self, udp_capacity: u64) -> &mut Self {
172        self.udp_capacity = udp_capacity;
173        self
174    }
175
176    /// Sets whether to enable random actor execution order and returns a mutable reference to self.
177    #[must_use]
178    pub const fn enable_random_order(&mut self, enable_random_order: bool) -> &mut Self {
179        self.enable_random_order = enable_random_order;
180        self
181    }
182
183    /// Sets the minimum message latency and returns a mutable reference to self.
184    #[must_use]
185    pub const fn min_message_latency(&mut self, min_message_latency: Duration) -> &mut Self {
186        self.min_message_latency = min_message_latency;
187        self
188    }
189
190    /// Sets the maximum message latency and returns a mutable reference to self.
191    #[must_use]
192    pub const fn max_message_latency(&mut self, max_message_latency: Duration) -> &mut Self {
193        self.max_message_latency = max_message_latency;
194        self
195    }
196
197    /// Sets the simulation duration and returns a mutable reference to self.
198    #[must_use]
199    pub const fn duration(&mut self, duration: Duration) -> &mut Self {
200        self.duration = duration;
201        self
202    }
203
204    /// Sets the tick duration and returns a mutable reference to self.
205    #[must_use]
206    pub const fn tick_duration(&mut self, tick_duration: Duration) -> &mut Self {
207        self.tick_duration = tick_duration;
208        self
209    }
210}
211
212/// Properties describing a simulation run.
213///
214/// Contains the configuration and metadata about a specific simulation run.
215#[derive(Debug, Clone)]
216pub struct SimProperties {
217    /// Configuration used for this simulation run.
218    pub config: SimConfig,
219    /// Run number (1-indexed).
220    pub run_number: u64,
221    /// Worker thread ID, if running in parallel mode.
222    pub thread_id: Option<u64>,
223    /// Additional custom properties from the bootstrap.
224    pub extra: Vec<(String, String)>,
225}
226
227/// Runtime metrics from a simulation run.
228///
229/// Captures timing and step count information after a simulation completes.
230#[derive(Debug, Clone)]
231pub struct SimRunProperties {
232    /// Number of simulation steps executed.
233    pub steps: u64,
234    /// Real-world time elapsed in milliseconds.
235    pub real_time_millis: u128,
236    /// Simulated time elapsed in milliseconds.
237    pub sim_time_millis: u128,
238}
239
240/// Result of a simulation run.
241///
242/// Indicates whether the simulation succeeded or failed, along with properties
243/// and runtime metrics.
244#[derive(Debug)]
245pub enum SimResult {
246    /// Simulation completed successfully.
247    Success {
248        /// Properties of the simulation run.
249        props: SimProperties,
250        /// Runtime metrics from the run.
251        run: SimRunProperties,
252    },
253    /// Simulation failed with an error or panic.
254    Fail {
255        /// Properties of the simulation run.
256        props: SimProperties,
257        /// Runtime metrics from the run.
258        run: SimRunProperties,
259        /// Error message, if the failure was due to a returned error.
260        error: Option<String>,
261        /// Panic message, if the failure was due to a panic.
262        panic: Option<String>,
263    },
264}
265
266impl SimResult {
267    /// Returns the simulation properties.
268    #[must_use]
269    pub const fn props(&self) -> &SimProperties {
270        match self {
271            Self::Success { props, .. } | Self::Fail { props, .. } => props,
272        }
273    }
274
275    /// Returns the simulation configuration.
276    #[must_use]
277    pub const fn config(&self) -> &SimConfig {
278        &self.props().config
279    }
280
281    /// Returns the runtime properties.
282    #[must_use]
283    pub const fn run(&self) -> &SimRunProperties {
284        match self {
285            Self::Success { run, .. } | Self::Fail { run, .. } => run,
286        }
287    }
288
289    /// Returns `true` if the simulation succeeded.
290    #[must_use]
291    pub const fn is_success(&self) -> bool {
292        matches!(self, Self::Success { .. })
293    }
294}
295
296impl std::fmt::Display for SimResult {
297    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
298        let props = self.props();
299        let config = &props.config;
300        let run = self.run();
301
302        let run_from_seed = if *RUNS == 1 && switchy::random::simulator::contains_fixed_seed() {
303            String::new()
304        } else {
305            #[cfg(feature = "time")]
306            let failed_epoch_offset = Some(config.epoch_offset);
307            #[cfg(not(feature = "time"))]
308            let failed_epoch_offset = None;
309
310            let cmd = get_run_command(
311                &[
312                    "SIMULATOR_SEED",
313                    "SIMULATOR_RUNS",
314                    "SIMULATOR_DURATION",
315                    "SIMULATOR_MAX_PARALLEL",
316                ],
317                config.seed,
318                failed_epoch_offset,
319            );
320            format!("\n\nTo run again with this seed: `{cmd}`")
321        };
322        let run_from_start = if !switchy::random::simulator::contains_fixed_seed() && *RUNS > 1 {
323            let cmd = get_run_command(
324                &["SIMULATOR_SEED"],
325                switchy::random::simulator::initial_seed(),
326                None,
327            );
328            format!("\nTo run entire simulation again from the first run: `{cmd}`")
329        } else {
330            String::new()
331        };
332
333        let (error, panic) = match self {
334            Self::Success { .. } => (String::new(), String::new()),
335            Self::Fail { error, panic, .. } => (
336                error
337                    .as_ref()
338                    .map_or_else(String::new, |x| format!("\n\nError:\n{x}")),
339                panic
340                    .as_ref()
341                    .map_or_else(String::new, |x| format!("\n\nPanic:\n{x}")),
342            ),
343        };
344
345        #[allow(clippy::cast_precision_loss)]
346        f.write_fmt(format_args!(
347            "\
348            =========================== FINISH ===========================\n\
349            Server simulator finished\n\n\
350            {run_info}\n\
351            steps={steps}\n\
352            real_time_elapsed={real_time}\n\
353            simulated_time_elapsed={simulated_time} ({simulated_time_x:.2}x)\n\n\
354            successful={successful}\
355            {error}{panic}{run_from_seed}{run_from_start}\n\
356            ==============================================================\
357            ",
358            successful = self.is_success(),
359            run_info = run_info(props),
360            steps = run.steps,
361            real_time = run.real_time_millis.into_formatted(),
362            simulated_time = run.sim_time_millis.into_formatted(),
363            simulated_time_x = run.sim_time_millis as f64 / run.real_time_millis as f64,
364        ))
365    }
366}
367
368/// Formats simulation properties as a human-readable string.
369///
370/// Used for logging and displaying simulation configuration details.
371#[must_use]
372pub fn run_info(props: &SimProperties) -> String {
373    use std::fmt::Write as _;
374
375    let config = &props.config;
376
377    let mut extra_top = String::new();
378    if let Some(thread_id) = props.thread_id {
379        write!(extra_top, "\nthread_id={thread_id}").unwrap();
380    }
381    #[cfg(feature = "time")]
382    write!(extra_top, "\nepoch_offset={}", config.epoch_offset).unwrap();
383    #[cfg(feature = "time")]
384    write!(extra_top, "\nstep_multiplier={}", config.step_multiplier).unwrap();
385
386    let mut extra_str = String::new();
387    for (k, v) in &props.extra {
388        write!(extra_str, "\n{k}={v}").unwrap();
389    }
390
391    let duration = if config.duration == Duration::MAX {
392        "forever".to_string()
393    } else {
394        config.duration.as_millis().to_string()
395    };
396
397    let run_number = props.run_number;
398    let runs = *RUNS;
399    let runs = if runs > 1 {
400        format!("{run_number}/{runs}")
401    } else {
402        runs.to_string()
403    };
404
405    format!(
406        "\
407        seed={seed}\n\
408        run={runs}{extra_top}\n\
409        tick_duration={tick_duration}\n\
410        fail_rate={fail_rate}\n\
411        repair_rate={repair_rate}\n\
412        tcp_capacity={tcp_capacity}\n\
413        udp_capacity={udp_capacity}\n\
414        enable_random_order={enable_random_order}\n\
415        min_message_latency={min_message_latency}\n\
416        max_message_latency={max_message_latency}\n\
417        duration={duration}{extra_str}\
418        ",
419        seed = config.seed,
420        tick_duration = config.tick_duration.as_millis(),
421        fail_rate = config.fail_rate,
422        repair_rate = config.repair_rate,
423        tcp_capacity = config.tcp_capacity,
424        udp_capacity = config.udp_capacity,
425        enable_random_order = config.enable_random_order,
426        min_message_latency = config.min_message_latency.as_millis(),
427        max_message_latency = config.max_message_latency.as_millis(),
428    )
429}
430
431fn get_cargoified_args() -> Vec<String> {
432    let mut args = std::env::args().collect::<Vec<_>>();
433
434    let Some(cmd) = args.first() else {
435        return args;
436    };
437
438    let mut components = cmd.split('/');
439
440    if matches!(components.next(), Some("target")) {
441        let Some(profile) = components.next() else {
442            return args;
443        };
444        let profile = profile.to_string();
445
446        let Some(binary_name) = components.next() else {
447            return args;
448        };
449        let binary_name = binary_name.to_string();
450
451        args.remove(0);
452        args.insert(0, binary_name);
453        args.insert(0, "-p".to_string());
454
455        if profile == "release" {
456            args.insert(0, "--release".to_string());
457        } else if profile != "debug" {
458            args.insert(0, profile);
459            args.insert(0, "--profile".to_string());
460        }
461
462        args.insert(0, "run".to_string());
463        args.insert(0, "cargo".to_string());
464    }
465
466    args
467}
468
469fn get_run_command(skip_env: &[&str], seed: u64, epoch_offset: Option<u64>) -> String {
470    let args = get_cargoified_args();
471    let quoted_args = args
472        .iter()
473        .map(|x| shell_words::quote(x.as_str()))
474        .collect::<Vec<_>>();
475    let cmd = quoted_args.join(" ");
476
477    let mut env_vars = String::new();
478
479    for (name, value) in std::env::vars() {
480        use std::fmt::Write as _;
481
482        if !name.starts_with("SIMULATOR_") && name != "RUST_LOG" {
483            continue;
484        }
485        if skip_env.iter().any(|x| *x == name) {
486            continue;
487        }
488
489        write!(env_vars, "{name}={} ", shell_words::quote(value.as_str())).unwrap();
490    }
491
492    let mut prefix = format!("SIMULATOR_SEED={seed} ");
493    if let Some(epoch_offset) = epoch_offset {
494        use std::fmt::Write as _;
495        write!(prefix, "SIMULATOR_EPOCH_OFFSET={epoch_offset} ").unwrap();
496    }
497
498    format!("{prefix}{env_vars}{cmd}")
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    #[cfg(feature = "time")]
506    struct EnvGuard {
507        name: &'static str,
508        value: Option<String>,
509    }
510
511    #[cfg(feature = "time")]
512    impl EnvGuard {
513        fn new(name: &'static str) -> Self {
514            Self {
515                name,
516                value: std::env::var(name).ok(),
517            }
518        }
519
520        fn remove(&self) {
521            unsafe {
522                std::env::remove_var(self.name);
523            }
524        }
525    }
526
527    #[cfg(feature = "time")]
528    impl Drop for EnvGuard {
529        fn drop(&mut self) {
530            match &self.value {
531                Some(value) => unsafe {
532                    std::env::set_var(self.name, value);
533                },
534                None => unsafe {
535                    std::env::remove_var(self.name);
536                },
537            }
538        }
539    }
540
541    #[test_log::test]
542    #[allow(clippy::float_cmp)]
543    fn test_simconfig_default() {
544        let config = SimConfig::default();
545        assert_eq!(config.seed, 0);
546        assert_eq!(config.fail_rate, 0.0);
547        assert_eq!(config.repair_rate, 1.0);
548        assert_eq!(config.tcp_capacity, 64);
549        assert_eq!(config.udp_capacity, 64);
550        assert!(!config.enable_random_order);
551        assert_eq!(config.min_message_latency, Duration::from_millis(0));
552        assert_eq!(config.max_message_latency, Duration::from_secs(1));
553        assert_eq!(config.duration, Duration::MAX);
554        assert_eq!(config.tick_duration, Duration::from_millis(1));
555    }
556
557    #[test_log::test]
558    #[allow(clippy::float_cmp)]
559    fn test_simconfig_new() {
560        let config = SimConfig::new();
561        assert_eq!(config.seed, 0);
562        assert_eq!(config.fail_rate, 0.0);
563        assert_eq!(config.repair_rate, 1.0);
564    }
565
566    #[test_log::test]
567    #[allow(clippy::float_cmp)]
568    fn test_simconfig_builder_methods() {
569        let mut config = SimConfig::new();
570
571        let _ = config
572            .fail_rate(0.5)
573            .repair_rate(0.8)
574            .tcp_capacity(128)
575            .udp_capacity(256)
576            .enable_random_order(true)
577            .min_message_latency(Duration::from_millis(10))
578            .max_message_latency(Duration::from_secs(2))
579            .duration(Duration::from_mins(1))
580            .tick_duration(Duration::from_millis(5));
581
582        assert_eq!(config.fail_rate, 0.5);
583        assert_eq!(config.repair_rate, 0.8);
584        assert_eq!(config.tcp_capacity, 128);
585        assert_eq!(config.udp_capacity, 256);
586        assert!(config.enable_random_order);
587        assert_eq!(config.min_message_latency, Duration::from_millis(10));
588        assert_eq!(config.max_message_latency, Duration::from_secs(2));
589        assert_eq!(config.duration, Duration::from_mins(1));
590        assert_eq!(config.tick_duration, Duration::from_millis(5));
591    }
592
593    #[test_log::test]
594    #[allow(clippy::float_cmp)]
595    fn test_simconfig_builder_method_chaining() {
596        let mut config = SimConfig::new();
597        let _ = config
598            .fail_rate(0.3)
599            .tcp_capacity(100)
600            .enable_random_order(true);
601
602        assert_eq!(config.fail_rate, 0.3);
603        assert_eq!(config.tcp_capacity, 100);
604        assert!(config.enable_random_order);
605    }
606
607    #[test_log::test]
608    fn test_simresult_is_success() {
609        let props = SimProperties {
610            config: SimConfig::new(),
611            run_number: 1,
612            thread_id: None,
613            extra: vec![],
614        };
615
616        let run = SimRunProperties {
617            steps: 100,
618            real_time_millis: 1000,
619            sim_time_millis: 5000,
620        };
621
622        let success = SimResult::Success {
623            props: props.clone(),
624            run: run.clone(),
625        };
626        assert!(success.is_success());
627
628        let fail = SimResult::Fail {
629            props,
630            run,
631            error: Some("test error".to_string()),
632            panic: None,
633        };
634        assert!(!fail.is_success());
635    }
636
637    #[test_log::test]
638    fn test_simresult_props() {
639        let props = SimProperties {
640            config: SimConfig::new(),
641            run_number: 42,
642            thread_id: Some(3),
643            extra: vec![("key".to_string(), "value".to_string())],
644        };
645
646        let run = SimRunProperties {
647            steps: 100,
648            real_time_millis: 1000,
649            sim_time_millis: 5000,
650        };
651
652        let result = SimResult::Success { props, run };
653
654        let result_props = result.props();
655        assert_eq!(result_props.run_number, 42);
656        assert_eq!(result_props.thread_id, Some(3));
657        assert_eq!(result_props.extra.len(), 1);
658    }
659
660    #[test_log::test]
661    fn test_simresult_config() {
662        let mut config = SimConfig::new();
663        let _ = config.tcp_capacity(256);
664
665        let props = SimProperties {
666            config,
667            run_number: 1,
668            thread_id: None,
669            extra: vec![],
670        };
671
672        let run = SimRunProperties {
673            steps: 100,
674            real_time_millis: 1000,
675            sim_time_millis: 5000,
676        };
677
678        let result = SimResult::Success { props, run };
679
680        assert_eq!(result.config().tcp_capacity, 256);
681    }
682
683    #[test_log::test]
684    fn test_simresult_run() {
685        let props = SimProperties {
686            config: SimConfig::new(),
687            run_number: 1,
688            thread_id: None,
689            extra: vec![],
690        };
691
692        let run = SimRunProperties {
693            steps: 12345,
694            real_time_millis: 9876,
695            sim_time_millis: 54321,
696        };
697
698        let result = SimResult::Success { props, run };
699
700        let result_run = result.run();
701        assert_eq!(result_run.steps, 12345);
702        assert_eq!(result_run.real_time_millis, 9876);
703        assert_eq!(result_run.sim_time_millis, 54321);
704    }
705
706    #[test_log::test]
707    fn test_get_cargoified_args_with_target_path() {
708        // Note: This test depends on the actual command line arguments,
709        // so we're just checking that it doesn't panic
710        let args = get_cargoified_args();
711        assert!(!args.is_empty());
712    }
713
714    #[test_log::test]
715    fn test_simresult_props_for_fail_variant() {
716        let props = SimProperties {
717            config: SimConfig::new(),
718            run_number: 10,
719            thread_id: Some(5),
720            extra: vec![("debug".to_string(), "true".to_string())],
721        };
722
723        let run = SimRunProperties {
724            steps: 500,
725            real_time_millis: 2000,
726            sim_time_millis: 10000,
727        };
728
729        let fail = SimResult::Fail {
730            props,
731            run,
732            error: Some("test error".to_string()),
733            panic: Some("test panic".to_string()),
734        };
735
736        let result_props = fail.props();
737        assert_eq!(result_props.run_number, 10);
738        assert_eq!(result_props.thread_id, Some(5));
739        assert_eq!(result_props.extra.len(), 1);
740    }
741
742    #[test_log::test]
743    fn test_simresult_run_for_fail_variant() {
744        let props = SimProperties {
745            config: SimConfig::new(),
746            run_number: 1,
747            thread_id: None,
748            extra: vec![],
749        };
750
751        let run = SimRunProperties {
752            steps: 999,
753            real_time_millis: 5555,
754            sim_time_millis: 8888,
755        };
756
757        let fail = SimResult::Fail {
758            props,
759            run,
760            error: Some("error".to_string()),
761            panic: None,
762        };
763
764        let result_run = fail.run();
765        assert_eq!(result_run.steps, 999);
766        assert_eq!(result_run.real_time_millis, 5555);
767        assert_eq!(result_run.sim_time_millis, 8888);
768    }
769
770    #[test_log::test]
771    fn test_simresult_config_for_fail_variant() {
772        let mut config = SimConfig::new();
773        let _ = config.udp_capacity(512);
774
775        let props = SimProperties {
776            config,
777            run_number: 1,
778            thread_id: None,
779            extra: vec![],
780        };
781
782        let run = SimRunProperties {
783            steps: 100,
784            real_time_millis: 1000,
785            sim_time_millis: 5000,
786        };
787
788        let fail = SimResult::Fail {
789            props,
790            run,
791            error: None,
792            panic: Some("panic message".to_string()),
793        };
794
795        assert_eq!(fail.config().udp_capacity, 512);
796    }
797
798    #[test_log::test]
799    fn test_run_info_contains_config_values() {
800        let mut config = SimConfig::new();
801        let _ = config
802            .fail_rate(0.5)
803            .tcp_capacity(128)
804            .enable_random_order(true);
805
806        let props = SimProperties {
807            config,
808            run_number: 1,
809            thread_id: None,
810            extra: vec![],
811        };
812
813        let info = run_info(&props);
814
815        assert!(info.contains("seed=0"));
816        assert!(info.contains("fail_rate=0.5"));
817        assert!(info.contains("tcp_capacity=128"));
818        assert!(info.contains("enable_random_order=true"));
819    }
820
821    #[test_log::test]
822    fn test_run_info_includes_thread_id_when_present() {
823        let props = SimProperties {
824            config: SimConfig::new(),
825            run_number: 1,
826            thread_id: Some(42),
827            extra: vec![],
828        };
829
830        let info = run_info(&props);
831
832        assert!(info.contains("thread_id=42"));
833    }
834
835    #[test_log::test]
836    fn test_run_info_excludes_thread_id_when_none() {
837        let props = SimProperties {
838            config: SimConfig::new(),
839            run_number: 1,
840            thread_id: None,
841            extra: vec![],
842        };
843
844        let info = run_info(&props);
845
846        assert!(!info.contains("thread_id="));
847    }
848
849    #[test_log::test]
850    fn test_run_info_includes_extra_properties() {
851        let props = SimProperties {
852            config: SimConfig::new(),
853            run_number: 1,
854            thread_id: None,
855            extra: vec![
856                ("custom_key".to_string(), "custom_value".to_string()),
857                ("another_key".to_string(), "another_value".to_string()),
858            ],
859        };
860
861        let info = run_info(&props);
862
863        assert!(info.contains("custom_key=custom_value"));
864        assert!(info.contains("another_key=another_value"));
865    }
866
867    #[test_log::test]
868    fn test_run_info_duration_forever_when_max() {
869        let props = SimProperties {
870            config: SimConfig::new(), // duration defaults to Duration::MAX
871            run_number: 1,
872            thread_id: None,
873            extra: vec![],
874        };
875
876        let info = run_info(&props);
877
878        assert!(info.contains("duration=forever"));
879    }
880
881    #[test_log::test]
882    fn test_run_info_duration_value_when_finite() {
883        let mut config = SimConfig::new();
884        let _ = config.duration(Duration::from_mins(2));
885
886        let props = SimProperties {
887            config,
888            run_number: 1,
889            thread_id: None,
890            extra: vec![],
891        };
892
893        let info = run_info(&props);
894
895        // 120 seconds = 120000 milliseconds
896        assert!(info.contains("duration=120000"));
897    }
898
899    #[cfg(feature = "time")]
900    #[test_log::test]
901    fn test_fail_output_includes_seed_and_epoch_offset_rerun_command() {
902        let seed_guard = EnvGuard::new("SIMULATOR_SEED");
903        let epoch_guard = EnvGuard::new("SIMULATOR_EPOCH_OFFSET");
904        seed_guard.remove();
905        epoch_guard.remove();
906
907        let mut config = SimConfig::new();
908        config.seed = 4242;
909        config.epoch_offset = 1_700_000_000_000;
910
911        let props = SimProperties {
912            config,
913            run_number: 1,
914            thread_id: None,
915            extra: vec![],
916        };
917
918        let run = SimRunProperties {
919            steps: 10,
920            real_time_millis: 100,
921            sim_time_millis: 200,
922        };
923
924        let result = SimResult::Fail {
925            props,
926            run,
927            error: Some("failure".to_string()),
928            panic: None,
929        };
930
931        let output = result.to_string();
932        assert!(output.contains("To run again with this seed:"));
933        assert!(output.contains("SIMULATOR_SEED=4242"));
934        assert!(output.contains("SIMULATOR_EPOCH_OFFSET=1700000000000"));
935    }
936}