Skip to main content

proef_core/
fake.rs

1//! Deterministic synthetic data for `${fake:*}` (ADR-0005).
2//!
3//! Determinism contract: values derive from the injected run id (FNV-1a →
4//! `SplitMix64`) plus the generator name and its occurrence index within one
5//! resolution — the same run id reproduces the same values (pin a run id to
6//! reproduce a flaky run), distinct run ids produce independent data. The
7//! module is dependency-free so the sequence is stable across crate versions.
8//!
9//! Unknown generator names are rejected statically at pack load (the validator
10//! consults [`is_known_generator`]) — a typo never survives to run time.
11
12/// Steele/Vigna `SplitMix64` — small, fast, deterministic.
13struct SplitMix64 {
14    state: u64,
15}
16
17impl SplitMix64 {
18    fn new(seed: u64) -> Self {
19        Self { state: seed }
20    }
21
22    fn next_u64(&mut self) -> u64 {
23        self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
24        let mut z = self.state;
25        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
26        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
27        z ^ (z >> 31)
28    }
29
30    fn below(&mut self, n: u64) -> u64 {
31        self.next_u64() % n
32    }
33
34    fn pick<'a>(&mut self, items: &[&'a str]) -> &'a str {
35        items[usize::try_from(self.below(items.len() as u64)).unwrap_or(0)]
36    }
37}
38
39/// 64-bit FNV-1a, turning strings into seeds.
40fn fnv1a(bytes: &[u8]) -> u64 {
41    let mut hash = 0xcbf2_9ce4_8422_2325_u64;
42    for &b in bytes {
43        hash ^= u64::from(b);
44        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
45    }
46    hash
47}
48
49const FIRST_NAMES: &[&str] = &[
50    "Alice", "Bob", "Carol", "David", "Emma", "Finn", "Sofie", "Lars", "Noor", "Sem",
51];
52const LAST_NAMES: &[&str] = &[
53    "Jansen", "Vries", "Bakker", "Visser", "Smit", "Meijer", "Smith", "Brown", "Jones", "Mulder",
54];
55const CITIES: &[&str] = &[
56    "Amsterdam",
57    "Rotterdam",
58    "Utrecht",
59    "Den Haag",
60    "Eindhoven",
61    "Groningen",
62];
63const STREETS: &[&str] = &[
64    "Kerkstraat",
65    "Schoolstraat",
66    "Dorpsstraat",
67    "Molenweg",
68    "Hoofdstraat",
69    "Stationsweg",
70];
71const WORDS: &[&str] = &[
72    "lorem",
73    "ipsum",
74    "dolor",
75    "sit",
76    "amet",
77    "consectetur",
78    "adipiscing",
79    "elit",
80];
81
82/// Every generator name `${fake:*}` understands (fixed vocabulary;
83/// kept in sync with [`generate`] by a test).
84pub const GENERATORS: &[&str] = &[
85    "firstName",
86    "lastName",
87    "name",
88    "fullName",
89    "email",
90    "username",
91    "phoneNL",
92    "postCode",
93    "city",
94    "street",
95    "int",
96    "number",
97    "digits4",
98    "digits8",
99    "bool",
100    "word",
101    "uuid",
102];
103
104/// Whether `name` is a known generator (static pack validation).
105pub fn is_known_generator(name: &str) -> bool {
106    GENERATORS.contains(&name)
107}
108
109/// One deterministic value for `generator`, derived from `(run_id, occurrence)`.
110/// `None` for unknown generators (callers reject those statically).
111pub fn generate(run_id: &str, occurrence: usize, generator: &str) -> Option<String> {
112    let seed = fnv1a(run_id.as_bytes())
113        ^ fnv1a(generator.as_bytes()).rotate_left(17)
114        ^ (occurrence as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15);
115    let mut rng = SplitMix64::new(seed);
116    let value = match generator {
117        "firstName" => rng.pick(FIRST_NAMES).to_owned(),
118        "lastName" => rng.pick(LAST_NAMES).to_owned(),
119        "name" | "fullName" => format!("{} {}", rng.pick(FIRST_NAMES), rng.pick(LAST_NAMES)),
120        "email" => format!(
121            "{}.{}@example.com",
122            rng.pick(FIRST_NAMES).to_lowercase(),
123            rng.pick(LAST_NAMES).to_lowercase()
124        ),
125        "username" => format!(
126            "{}{}",
127            rng.pick(FIRST_NAMES).to_lowercase(),
128            rng.below(1000)
129        ),
130        "phoneNL" => format!("06{:08}", rng.below(100_000_000)),
131        "postCode" => format!(
132            "{:04} {}{}",
133            1000 + rng.below(9000),
134            upper_letter(&mut rng),
135            upper_letter(&mut rng)
136        ),
137        "city" => rng.pick(CITIES).to_owned(),
138        "street" => format!("{} {}", rng.pick(STREETS), 1 + rng.below(200)),
139        "int" | "number" => rng.below(10_000).to_string(),
140        "digits4" => format!("{:04}", rng.below(10_000)),
141        "digits8" => format!("{:08}", rng.below(100_000_000)),
142        "bool" => (rng.next_u64() & 1 == 1).to_string(),
143        "word" => rng.pick(WORDS).to_owned(),
144        "uuid" => {
145            let (a, b) = (rng.next_u64(), rng.next_u64());
146            format!(
147                "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
148                a >> 32,
149                (a >> 16) & 0xFFFF,
150                a & 0xFFF,
151                0x8000 | ((b >> 48) & 0x3FFF),
152                b & 0xFFFF_FFFF_FFFF
153            )
154        }
155        _ => return None,
156    };
157    Some(value)
158}
159
160fn upper_letter(rng: &mut SplitMix64) -> char {
161    char::from(b'A' + u8::try_from(rng.below(26)).unwrap_or(0))
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn every_listed_generator_generates() {
170        for generator in GENERATORS {
171            assert!(
172                generate("run-1", 0, generator).is_some(),
173                "generator `{generator}` listed but not implemented"
174            );
175        }
176        assert!(generate("run-1", 0, "nope").is_none());
177    }
178
179    #[test]
180    fn deterministic_per_run_id_and_occurrence() {
181        let a = generate("run-1", 0, "lastName");
182        let b = generate("run-1", 0, "lastName");
183        assert_eq!(a, b, "same run id + occurrence → same value");
184        let c = generate("run-2", 0, "lastName");
185        let d = generate("run-1", 1, "lastName");
186        // Distinct streams (probabilistically distinct for these seeds).
187        assert!(a != c || a != d, "distinct run/occurrence should vary");
188    }
189
190    #[test]
191    fn uuid_shape_is_v4ish() {
192        let uuid = generate("run-1", 0, "uuid").unwrap_or_default();
193        assert_eq!(uuid.len(), 36, "{uuid}");
194        assert_eq!(uuid.as_bytes()[14], b'4', "{uuid}");
195    }
196}