Skip to main content

readable_code_core/
lib.rs

1//! Language-agnostic core for readable share codes.
2//!
3//! Mirrors the `@readable-code/core` TypeScript package conceptually: a small
4//! chainable string composer plus numeric and random helpers. Language-specific
5//! generation lives in the `readable-code-english` / `readable-code-korean`
6//! crates, never here.
7
8use std::time::{SystemTime, UNIX_EPOCH};
9
10/// A source of bounded random integers in `0..max_exclusive`.
11///
12/// Implement this for deterministic test doubles, mirroring the injectable
13/// `RandomSource` of the TypeScript packages.
14pub trait RandomSource {
15    /// Return a value in `0..max_exclusive`. `max_exclusive` must be positive.
16    fn gen_below(&mut self, max_exclusive: u32) -> u32;
17}
18
19/// SplitMix64 PRNG for seeded, deterministic generation.
20///
21/// This is fast and reproducible but **not** cryptographically secure, so it is
22/// not the default. Use it when you need a fixed seed (e.g. tests via
23/// `word_with` / `hangul_with`); the default `word()` / `hangul()` entry points
24/// use [`OsRandom`].
25pub struct SplitMix64 {
26    state: u64,
27}
28
29impl SplitMix64 {
30    /// Create a PRNG from an explicit seed (deterministic).
31    pub fn new(seed: u64) -> Self {
32        Self { state: seed }
33    }
34
35    /// Seed from the system clock. Non-deterministic, non-cryptographic.
36    pub fn from_entropy() -> Self {
37        let nanos = SystemTime::now()
38            .duration_since(UNIX_EPOCH)
39            .map(|d| d.as_nanos() as u64)
40            .unwrap_or(0x9E37_79B9_7F4A_7C15);
41        Self::new(nanos ^ 0x9E37_79B9_7F4A_7C15)
42    }
43
44    /// Advance the generator and return the next raw 64-bit value.
45    ///
46    /// Useful for deriving independent child seeds: `SplitMix64::new(master.next_u64())`.
47    pub fn next_u64(&mut self) -> u64 {
48        self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
49        let mut z = self.state;
50        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
51        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
52        z ^ (z >> 31)
53    }
54}
55
56impl RandomSource for SplitMix64 {
57    fn gen_below(&mut self, max_exclusive: u32) -> u32 {
58        assert!(max_exclusive > 0, "max_exclusive must be positive");
59        let m = u64::from(max_exclusive);
60        // Rejection sampling removes modulo bias.
61        let limit = (u64::MAX / m) * m;
62        loop {
63            let value = self.next_u64();
64            if value < limit {
65                return (value % m) as u32;
66            }
67        }
68    }
69}
70
71/// Cryptographically secure default random source, backed by the operating
72/// system CSPRNG via the `getrandom` crate.
73///
74/// This is the default behind the language crates' `word()` / `hangul()` entry
75/// points, mirroring the TypeScript default of `crypto.getRandomValues`. Use
76/// [`SplitMix64`] only for seeded, deterministic generation.
77#[derive(Debug, Default, Clone, Copy)]
78pub struct OsRandom;
79
80impl OsRandom {
81    /// Create a secure random source.
82    pub fn new() -> Self {
83        Self
84    }
85}
86
87impl RandomSource for OsRandom {
88    fn gen_below(&mut self, max_exclusive: u32) -> u32 {
89        assert!(max_exclusive > 0, "max_exclusive must be positive");
90        let m = u64::from(max_exclusive);
91        // Rejection sampling removes modulo bias.
92        let limit = (u64::MAX / m) * m;
93        loop {
94            let mut buf = [0u8; 8];
95            getrandom::getrandom(&mut buf).expect("OS CSPRNG (getrandom) failed");
96            let value = u64::from_le_bytes(buf);
97            if value < limit {
98                return (value % m) as u32;
99            }
100        }
101    }
102}
103
104/// Pick an element from a slice using the given random source.
105///
106/// Panics if `items` is empty.
107pub fn pick<'a, T, R: RandomSource>(items: &'a [T], rng: &mut R) -> &'a T {
108    assert!(!items.is_empty(), "cannot pick from an empty slice");
109    let index = rng.gen_below(items.len() as u32) as usize;
110    &items[index]
111}
112
113/// Generate a string of `length` random decimal digits.
114pub fn digits<R: RandomSource>(length: usize, rng: &mut R) -> String {
115    let mut out = String::with_capacity(length);
116    for _ in 0..length {
117        let digit = rng.gen_below(10) as u8;
118        out.push((b'0' + digit) as char);
119    }
120    out
121}
122
123/// Chainable string composer. It only joins string fragments; it has no
124/// uniqueness, retry, persistence, or denylist policy.
125pub struct CodeBuilder<R: RandomSource> {
126    parts: Vec<String>,
127    rng: R,
128}
129
130impl<R: RandomSource> CodeBuilder<R> {
131    /// Start a builder with the given random source.
132    pub fn new(rng: R) -> Self {
133        Self {
134            parts: Vec::new(),
135            rng,
136        }
137    }
138
139    /// Append an arbitrary string fragment.
140    pub fn add(mut self, value: impl Into<String>) -> Self {
141        self.parts.push(value.into());
142        self
143    }
144
145    /// Append a fragment produced from the builder's own random source.
146    ///
147    /// Language crates use this to add generated words without owning a second
148    /// random source.
149    pub fn add_with<F: FnOnce(&mut R) -> String>(mut self, f: F) -> Self {
150        let value = f(&mut self.rng);
151        self.parts.push(value);
152        self
153    }
154
155    /// Append a `-` separator.
156    pub fn dash(self) -> Self {
157        self.add("-")
158    }
159
160    /// Append `length` random decimal digits.
161    pub fn digits(self, length: usize) -> Self {
162        self.add_with(|rng| digits(length, rng))
163    }
164
165    /// Alias for [`CodeBuilder::digits`].
166    pub fn nums(self, length: usize) -> Self {
167        self.digits(length)
168    }
169
170    /// Finalize and return the joined code string.
171    pub fn build(self) -> String {
172        self.parts.concat()
173    }
174}
175
176/// Start a low-level builder with the given random source.
177pub fn code<R: RandomSource>(rng: R) -> CodeBuilder<R> {
178    CodeBuilder::new(rng)
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    /// Deterministic source: always returns the same in-range index.
186    struct Fixed(u32);
187    impl RandomSource for Fixed {
188        fn gen_below(&mut self, max_exclusive: u32) -> u32 {
189            self.0 % max_exclusive
190        }
191    }
192
193    /// Deterministic source cycling through a fixed sequence.
194    struct Seq {
195        values: Vec<u32>,
196        index: usize,
197    }
198    impl RandomSource for Seq {
199        fn gen_below(&mut self, max_exclusive: u32) -> u32 {
200            let value = self.values[self.index % self.values.len()];
201            self.index += 1;
202            value % max_exclusive
203        }
204    }
205
206    #[test]
207    fn digits_are_deterministic() {
208        assert_eq!(digits(4, &mut Fixed(7)), "7777");
209        assert_eq!(
210            digits(
211                4,
212                &mut Seq {
213                    values: vec![1, 2, 3, 4],
214                    index: 0
215                }
216            ),
217            "1234"
218        );
219    }
220
221    #[test]
222    fn dash_appends_single_hyphen() {
223        assert_eq!(code(Fixed(0)).add("ab").dash().add("cd").build(), "ab-cd");
224    }
225
226    #[test]
227    fn nums_is_alias_for_digits() {
228        assert_eq!(code(Fixed(5)).digits(3).build(), code(Fixed(5)).nums(3).build());
229    }
230
231    #[test]
232    fn composes_full_code() {
233        assert_eq!(code(Fixed(0)).add("teva").dash().digits(4).build(), "teva-0000");
234    }
235
236    #[test]
237    fn seeded_default_stays_in_range() {
238        let mut rng = SplitMix64::new(42);
239        for _ in 0..1000 {
240            assert!(rng.gen_below(10) < 10);
241        }
242    }
243
244    #[test]
245    fn os_random_stays_in_range() {
246        let mut rng = OsRandom::new();
247        for _ in 0..1000 {
248            assert!(rng.gen_below(10) < 10);
249        }
250    }
251
252    #[test]
253    fn digits_empty_for_zero_length() {
254        assert_eq!(digits(0, &mut Fixed(7)), "");
255    }
256
257    #[test]
258    fn digits_maps_every_index_to_its_decimal() {
259        assert_eq!(
260            digits(
261                10,
262                &mut Seq {
263                    values: vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
264                    index: 0
265                }
266            ),
267            "0123456789"
268        );
269    }
270
271    #[test]
272    fn empty_builder_builds_empty_string() {
273        assert_eq!(code(Fixed(0)).build(), "");
274    }
275
276    #[test]
277    fn builder_preserves_empty_fragments() {
278        assert_eq!(code(Fixed(0)).add("a").add("").add("b").build(), "ab");
279    }
280
281    #[test]
282    fn builder_preserves_fragment_order() {
283        assert_eq!(
284            code(Fixed(0)).add("a").dash().add("b").dash().add("c").build(),
285            "a-b-c"
286        );
287    }
288
289    #[test]
290    fn pick_returns_singleton_element() {
291        assert_eq!(*pick(&["solo"], &mut Fixed(0)), "solo");
292    }
293
294    #[test]
295    fn pick_uses_index_from_source() {
296        assert_eq!(*pick(&["a", "b", "c", "d"], &mut Fixed(2)), "c");
297    }
298
299    #[test]
300    #[should_panic(expected = "cannot pick from an empty slice")]
301    fn pick_panics_on_empty_slice() {
302        let empty: [u8; 0] = [];
303        pick(&empty, &mut Fixed(0));
304    }
305
306    #[test]
307    #[should_panic(expected = "max_exclusive must be positive")]
308    fn splitmix_panics_on_zero_bound() {
309        SplitMix64::new(1).gen_below(0);
310    }
311
312    #[test]
313    #[should_panic(expected = "max_exclusive must be positive")]
314    fn os_random_panics_on_zero_bound() {
315        OsRandom::new().gen_below(0);
316    }
317
318    #[test]
319    fn gen_below_one_is_always_zero() {
320        let mut rng = SplitMix64::new(123);
321        for _ in 0..100 {
322            assert_eq!(rng.gen_below(1), 0);
323        }
324    }
325
326    #[test]
327    fn splitmix_is_reproducible_for_a_seed() {
328        let draw = |seed: u64| {
329            let mut rng = SplitMix64::new(seed);
330            (0..16).map(|_| rng.gen_below(1000)).collect::<Vec<_>>()
331        };
332        assert_eq!(draw(42), draw(42));
333        assert_ne!(draw(42), draw(43));
334    }
335
336    #[test]
337    fn splitmix_next_u64_is_deterministic() {
338        let mut a = SplitMix64::new(0);
339        let mut b = SplitMix64::new(0);
340        for _ in 0..8 {
341            assert_eq!(a.next_u64(), b.next_u64());
342        }
343    }
344
345    #[test]
346    fn gen_below_covers_full_small_range() {
347        let mut rng = SplitMix64::new(7);
348        let mut seen = [false; 4];
349        for _ in 0..2000 {
350            seen[rng.gen_below(4) as usize] = true;
351        }
352        assert!(seen.iter().all(|&hit| hit));
353    }
354}