Skip to main content

geam_stdlib/
run_state.rs

1use ecow::EcoString;
2use rand::rngs::{ChaCha12Rng, SysRng};
3use rand::{Rng, SeedableRng, TryRng};
4use std::fmt::{self, Display, Formatter};
5
6use super::IoOutput;
7
8/// Caller-owned mutable state used by the official Gleam standard library.
9pub struct GleamStdlibRunState<Io = Vec<IoOutput>> {
10    random: ChaCha12Rng,
11    io: Io,
12}
13
14/// Failure to initialize standard-library run state from system entropy.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct GleamStdlibRunStateError {
17    reason: EcoString,
18}
19
20impl GleamStdlibRunState {
21    /// Creates reproducible standard-library state from an explicit seed.
22    pub fn from_seed(seed: [u8; 32]) -> Self {
23        Self::from_seed_with_io(seed, Vec::new())
24    }
25
26    /// Creates standard-library state from the operating system random source.
27    pub fn try_from_entropy() -> Result<Self, GleamStdlibRunStateError> {
28        Self::try_from_entropy_with_io(Vec::new())
29    }
30
31    /// Returns the standard-library IO events collected by this run state.
32    pub fn io_outputs(&self) -> &[IoOutput] {
33        &self.io
34    }
35
36    /// Takes all collected standard-library IO events, leaving the state empty.
37    pub fn take_io_outputs(&mut self) -> Vec<IoOutput> {
38        std::mem::take(&mut self.io)
39    }
40}
41
42impl<Io> GleamStdlibRunState<Io> {
43    /// Creates reproducible standard-library state with a caller-owned IO sink.
44    pub fn from_seed_with_io(seed: [u8; 32], io: Io) -> Self {
45        Self {
46            random: ChaCha12Rng::from_seed(seed),
47            io,
48        }
49    }
50
51    /// Creates standard-library state from system entropy with a caller-owned IO sink.
52    pub fn try_from_entropy_with_io(io: Io) -> Result<Self, GleamStdlibRunStateError> {
53        Self::try_from_seed_source(io, |seed| SysRng.try_fill_bytes(seed))
54    }
55
56    pub(super) fn random_float(&mut self) -> f64 {
57        const SCALE: f64 = 1.0 / ((1u64 << 53) as f64);
58
59        ((self.random.next_u64() >> 11) as f64) * SCALE
60    }
61
62    pub(super) fn io_sink(&mut self) -> &mut Io {
63        &mut self.io
64    }
65
66    fn try_from_seed_source<Error>(
67        io: Io,
68        fill: impl FnOnce(&mut [u8; 32]) -> Result<(), Error>,
69    ) -> Result<Self, GleamStdlibRunStateError>
70    where
71        Error: Display,
72    {
73        let mut seed = [0; 32];
74        fill(&mut seed)
75            .map(|()| Self::from_seed_with_io(seed, io))
76            .map_err(|error| GleamStdlibRunStateError {
77                reason: error.to_string().into(),
78            })
79    }
80}
81
82impl GleamStdlibRunStateError {
83    /// Returns the owned entropy-source failure reason.
84    pub fn reason(&self) -> &str {
85        &self.reason
86    }
87}
88
89impl Display for GleamStdlibRunStateError {
90    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
91        write!(
92            formatter,
93            "could not initialize Gleam standard-library random state: {}",
94            self.reason
95        )
96    }
97}
98
99impl std::error::Error for GleamStdlibRunStateError {}
100
101#[cfg(test)]
102mod tests {
103    use super::{GleamStdlibRunState, GleamStdlibRunStateError};
104    use crate::{IoOutput, IoSink, IoStream};
105    use std::fmt::{self, Display, Formatter};
106
107    #[derive(Debug)]
108    struct RejectedEntropyError;
109
110    impl Display for RejectedEntropyError {
111        fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
112            formatter.write_str("entropy unavailable")
113        }
114    }
115
116    impl std::error::Error for RejectedEntropyError {}
117
118    #[test]
119    fn explicit_seeds_are_reproducible_and_advance_independently() {
120        let mut first = GleamStdlibRunState::from_seed([7; 32]);
121        let mut second = GleamStdlibRunState::from_seed([7; 32]);
122
123        let first_value = first.random_float();
124        assert_eq!(first_value, second.random_float());
125        let first_next = first.random_float();
126        assert_ne!(first_next, first_value);
127        assert_eq!(first_next, second.random_float());
128        assert!((0.0..1.0).contains(&first_value));
129        assert!(first.io_outputs().is_empty());
130        assert!(second.io_outputs().is_empty());
131    }
132
133    #[test]
134    fn io_outputs_accumulate_and_can_be_taken() {
135        let mut state = GleamStdlibRunState::from_seed([8; 32]);
136        state
137            .io_sink()
138            .emit(IoOutput::new(IoStream::Stdout, "first".into()));
139        state
140            .io_sink()
141            .emit(IoOutput::new(IoStream::Stderr, "second".into()));
142
143        assert_eq!(
144            state
145                .io_outputs()
146                .iter()
147                .map(|output| (output.stream(), output.text().as_str()))
148                .collect::<Vec<_>>(),
149            [(IoStream::Stdout, "first"), (IoStream::Stderr, "second")],
150        );
151        assert_eq!(state.take_io_outputs().len(), 2);
152        assert!(state.io_outputs().is_empty());
153    }
154
155    #[test]
156    fn entropy_failure_preserves_an_owned_reason() {
157        let error = GleamStdlibRunState::try_from_seed_source(Vec::<IoOutput>::new(), |_| {
158            Err(RejectedEntropyError)
159        })
160        .err()
161        .expect("rejected entropy should fail");
162
163        assert_eq!(error.reason(), "entropy unavailable");
164        assert_eq!(
165            error,
166            GleamStdlibRunStateError {
167                reason: "entropy unavailable".into(),
168            },
169        );
170        assert_eq!(
171            error.to_string(),
172            "could not initialize Gleam standard-library random state: entropy unavailable",
173        );
174    }
175
176    #[test]
177    fn system_entropy_constructs_an_independent_state() {
178        let mut state = GleamStdlibRunState::try_from_entropy()
179            .expect("system entropy should be available in the test environment");
180
181        assert!((0.0..1.0).contains(&state.random_float()));
182        assert!(state.io_outputs().is_empty());
183    }
184
185    #[test]
186    fn caller_owned_io_is_preserved_by_seeded_and_entropy_construction() {
187        let state = GleamStdlibRunState::from_seed_with_io([9; 32], String::from("sink"));
188        assert_eq!(state.io, "sink");
189
190        let state = GleamStdlibRunState::try_from_entropy_with_io(String::from("entropy sink"))
191            .expect("system entropy should initialize caller-owned IO state");
192        assert_eq!(state.io, "entropy sink");
193    }
194}