readable-code-core 0.1.0

Core builder, separators, and random helpers for readable share codes.
Documentation
//! Language-agnostic core for readable share codes.
//!
//! Mirrors the `@readable-code/core` TypeScript package conceptually: a small
//! chainable string composer plus numeric and random helpers. Language-specific
//! generation lives in the `readable-code-english` / `readable-code-korean`
//! crates, never here.

use std::time::{SystemTime, UNIX_EPOCH};

/// A source of bounded random integers in `0..max_exclusive`.
///
/// Implement this for deterministic test doubles, mirroring the injectable
/// `RandomSource` of the TypeScript packages.
pub trait RandomSource {
    /// Return a value in `0..max_exclusive`. `max_exclusive` must be positive.
    fn gen_below(&mut self, max_exclusive: u32) -> u32;
}

/// SplitMix64 PRNG for seeded, deterministic generation.
///
/// This is fast and reproducible but **not** cryptographically secure, so it is
/// not the default. Use it when you need a fixed seed (e.g. tests via
/// `word_with` / `hangul_with`); the default `word()` / `hangul()` entry points
/// use [`OsRandom`].
pub struct SplitMix64 {
    state: u64,
}

impl SplitMix64 {
    /// Create a PRNG from an explicit seed (deterministic).
    pub fn new(seed: u64) -> Self {
        Self { state: seed }
    }

    /// Seed from the system clock. Non-deterministic, non-cryptographic.
    pub fn from_entropy() -> Self {
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_nanos() as u64)
            .unwrap_or(0x9E37_79B9_7F4A_7C15);
        Self::new(nanos ^ 0x9E37_79B9_7F4A_7C15)
    }

    /// Advance the generator and return the next raw 64-bit value.
    ///
    /// Useful for deriving independent child seeds: `SplitMix64::new(master.next_u64())`.
    pub fn next_u64(&mut self) -> u64 {
        self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
        let mut z = self.state;
        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
        z ^ (z >> 31)
    }
}

impl RandomSource for SplitMix64 {
    fn gen_below(&mut self, max_exclusive: u32) -> u32 {
        assert!(max_exclusive > 0, "max_exclusive must be positive");
        let m = u64::from(max_exclusive);
        // Rejection sampling removes modulo bias.
        let limit = (u64::MAX / m) * m;
        loop {
            let value = self.next_u64();
            if value < limit {
                return (value % m) as u32;
            }
        }
    }
}

/// Cryptographically secure default random source, backed by the operating
/// system CSPRNG via the `getrandom` crate.
///
/// This is the default behind the language crates' `word()` / `hangul()` entry
/// points, mirroring the TypeScript default of `crypto.getRandomValues`. Use
/// [`SplitMix64`] only for seeded, deterministic generation.
#[derive(Debug, Default, Clone, Copy)]
pub struct OsRandom;

impl OsRandom {
    /// Create a secure random source.
    pub fn new() -> Self {
        Self
    }
}

impl RandomSource for OsRandom {
    fn gen_below(&mut self, max_exclusive: u32) -> u32 {
        assert!(max_exclusive > 0, "max_exclusive must be positive");
        let m = u64::from(max_exclusive);
        // Rejection sampling removes modulo bias.
        let limit = (u64::MAX / m) * m;
        loop {
            let mut buf = [0u8; 8];
            getrandom::getrandom(&mut buf).expect("OS CSPRNG (getrandom) failed");
            let value = u64::from_le_bytes(buf);
            if value < limit {
                return (value % m) as u32;
            }
        }
    }
}

/// Pick an element from a slice using the given random source.
///
/// Panics if `items` is empty.
pub fn pick<'a, T, R: RandomSource>(items: &'a [T], rng: &mut R) -> &'a T {
    assert!(!items.is_empty(), "cannot pick from an empty slice");
    let index = rng.gen_below(items.len() as u32) as usize;
    &items[index]
}

/// Generate a string of `length` random decimal digits.
pub fn digits<R: RandomSource>(length: usize, rng: &mut R) -> String {
    let mut out = String::with_capacity(length);
    for _ in 0..length {
        let digit = rng.gen_below(10) as u8;
        out.push((b'0' + digit) as char);
    }
    out
}

/// Chainable string composer. It only joins string fragments; it has no
/// uniqueness, retry, persistence, or denylist policy.
pub struct CodeBuilder<R: RandomSource> {
    parts: Vec<String>,
    rng: R,
}

impl<R: RandomSource> CodeBuilder<R> {
    /// Start a builder with the given random source.
    pub fn new(rng: R) -> Self {
        Self {
            parts: Vec::new(),
            rng,
        }
    }

    /// Append an arbitrary string fragment.
    pub fn add(mut self, value: impl Into<String>) -> Self {
        self.parts.push(value.into());
        self
    }

    /// Append a fragment produced from the builder's own random source.
    ///
    /// Language crates use this to add generated words without owning a second
    /// random source.
    pub fn add_with<F: FnOnce(&mut R) -> String>(mut self, f: F) -> Self {
        let value = f(&mut self.rng);
        self.parts.push(value);
        self
    }

    /// Append a `-` separator.
    pub fn dash(self) -> Self {
        self.add("-")
    }

    /// Append `length` random decimal digits.
    pub fn digits(self, length: usize) -> Self {
        self.add_with(|rng| digits(length, rng))
    }

    /// Alias for [`CodeBuilder::digits`].
    pub fn nums(self, length: usize) -> Self {
        self.digits(length)
    }

    /// Finalize and return the joined code string.
    pub fn build(self) -> String {
        self.parts.concat()
    }
}

/// Start a low-level builder with the given random source.
pub fn code<R: RandomSource>(rng: R) -> CodeBuilder<R> {
    CodeBuilder::new(rng)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Deterministic source: always returns the same in-range index.
    struct Fixed(u32);
    impl RandomSource for Fixed {
        fn gen_below(&mut self, max_exclusive: u32) -> u32 {
            self.0 % max_exclusive
        }
    }

    /// Deterministic source cycling through a fixed sequence.
    struct Seq {
        values: Vec<u32>,
        index: usize,
    }
    impl RandomSource for Seq {
        fn gen_below(&mut self, max_exclusive: u32) -> u32 {
            let value = self.values[self.index % self.values.len()];
            self.index += 1;
            value % max_exclusive
        }
    }

    #[test]
    fn digits_are_deterministic() {
        assert_eq!(digits(4, &mut Fixed(7)), "7777");
        assert_eq!(
            digits(
                4,
                &mut Seq {
                    values: vec![1, 2, 3, 4],
                    index: 0
                }
            ),
            "1234"
        );
    }

    #[test]
    fn dash_appends_single_hyphen() {
        assert_eq!(code(Fixed(0)).add("ab").dash().add("cd").build(), "ab-cd");
    }

    #[test]
    fn nums_is_alias_for_digits() {
        assert_eq!(code(Fixed(5)).digits(3).build(), code(Fixed(5)).nums(3).build());
    }

    #[test]
    fn composes_full_code() {
        assert_eq!(code(Fixed(0)).add("teva").dash().digits(4).build(), "teva-0000");
    }

    #[test]
    fn seeded_default_stays_in_range() {
        let mut rng = SplitMix64::new(42);
        for _ in 0..1000 {
            assert!(rng.gen_below(10) < 10);
        }
    }

    #[test]
    fn os_random_stays_in_range() {
        let mut rng = OsRandom::new();
        for _ in 0..1000 {
            assert!(rng.gen_below(10) < 10);
        }
    }

    #[test]
    fn digits_empty_for_zero_length() {
        assert_eq!(digits(0, &mut Fixed(7)), "");
    }

    #[test]
    fn digits_maps_every_index_to_its_decimal() {
        assert_eq!(
            digits(
                10,
                &mut Seq {
                    values: vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
                    index: 0
                }
            ),
            "0123456789"
        );
    }

    #[test]
    fn empty_builder_builds_empty_string() {
        assert_eq!(code(Fixed(0)).build(), "");
    }

    #[test]
    fn builder_preserves_empty_fragments() {
        assert_eq!(code(Fixed(0)).add("a").add("").add("b").build(), "ab");
    }

    #[test]
    fn builder_preserves_fragment_order() {
        assert_eq!(
            code(Fixed(0)).add("a").dash().add("b").dash().add("c").build(),
            "a-b-c"
        );
    }

    #[test]
    fn pick_returns_singleton_element() {
        assert_eq!(*pick(&["solo"], &mut Fixed(0)), "solo");
    }

    #[test]
    fn pick_uses_index_from_source() {
        assert_eq!(*pick(&["a", "b", "c", "d"], &mut Fixed(2)), "c");
    }

    #[test]
    #[should_panic(expected = "cannot pick from an empty slice")]
    fn pick_panics_on_empty_slice() {
        let empty: [u8; 0] = [];
        pick(&empty, &mut Fixed(0));
    }

    #[test]
    #[should_panic(expected = "max_exclusive must be positive")]
    fn splitmix_panics_on_zero_bound() {
        SplitMix64::new(1).gen_below(0);
    }

    #[test]
    #[should_panic(expected = "max_exclusive must be positive")]
    fn os_random_panics_on_zero_bound() {
        OsRandom::new().gen_below(0);
    }

    #[test]
    fn gen_below_one_is_always_zero() {
        let mut rng = SplitMix64::new(123);
        for _ in 0..100 {
            assert_eq!(rng.gen_below(1), 0);
        }
    }

    #[test]
    fn splitmix_is_reproducible_for_a_seed() {
        let draw = |seed: u64| {
            let mut rng = SplitMix64::new(seed);
            (0..16).map(|_| rng.gen_below(1000)).collect::<Vec<_>>()
        };
        assert_eq!(draw(42), draw(42));
        assert_ne!(draw(42), draw(43));
    }

    #[test]
    fn splitmix_next_u64_is_deterministic() {
        let mut a = SplitMix64::new(0);
        let mut b = SplitMix64::new(0);
        for _ in 0..8 {
            assert_eq!(a.next_u64(), b.next_u64());
        }
    }

    #[test]
    fn gen_below_covers_full_small_range() {
        let mut rng = SplitMix64::new(7);
        let mut seen = [false; 4];
        for _ in 0..2000 {
            seen[rng.gen_below(4) as usize] = true;
        }
        assert!(seen.iter().all(|&hit| hit));
    }
}