Skip to main content

dev_fixtures/
mock.rs

1//! Deterministic mock data generators.
2//!
3//! Generators are seeded by a `u64`. Same seed + same configuration
4//! produces byte-identical output across runs and machines. No
5//! external dependencies; deterministic via splitmix64.
6//!
7//! ## Generators
8//!
9//! - [`csv`] — CSV with a configurable schema and row count.
10//! - [`json_array`] — JSON array of records with a shape template.
11//! - [`bytes`] — raw byte streams: random, zeroed, patterned.
12
13/// A deterministic pseudo-random number generator.
14///
15/// Internally splitmix64. Cheap to construct, no allocation.
16///
17/// # Example
18///
19/// ```
20/// use dev_fixtures::mock::Rng;
21/// let mut a = Rng::seeded(42);
22/// let mut b = Rng::seeded(42);
23/// assert_eq!(a.next_u64(), b.next_u64());
24/// ```
25pub struct Rng {
26    state: u64,
27}
28
29impl Rng {
30    /// Build a new RNG from a seed.
31    pub fn seeded(seed: u64) -> Self {
32        Self { state: seed }
33    }
34
35    /// Step and return the next 64-bit value.
36    pub fn next_u64(&mut self) -> u64 {
37        self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
38        let mut z = self.state;
39        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
40        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
41        z ^= z >> 31;
42        z
43    }
44
45    /// Return a value uniformly in `[0, n)`. For small `n` this is
46    /// biased; the bias is bounded by `1 / 2^32` for `n` up to
47    /// `2^31`, which is fine for fixture purposes.
48    pub fn range(&mut self, n: u64) -> u64 {
49        if n == 0 {
50            return 0;
51        }
52        self.next_u64() % n
53    }
54}
55
56/// Module containing CSV generation.
57pub mod csv {
58    use super::Rng;
59
60    /// Generate a CSV string with `rows` rows, one per `Vec<String>`
61    /// produced by `row_factory`. The first line is the header.
62    ///
63    /// Field values containing `,`, `"`, `\n`, or `\r` are escaped per
64    /// [RFC 4180](https://datatracker.ietf.org/doc/html/rfc4180):
65    /// the value is wrapped in double quotes and any internal `"` is
66    /// doubled. Values without those characters pass through verbatim.
67    ///
68    /// Header values are escaped the same way.
69    ///
70    /// # Example
71    ///
72    /// ```
73    /// use dev_fixtures::mock::csv::generate;
74    /// let csv = generate(
75    ///     &["id", "name"],
76    ///     3,
77    ///     42,
78    ///     |rng| vec![rng.range(1000).to_string(), format!("name_{}", rng.range(100))],
79    /// );
80    /// assert!(csv.starts_with("id,name\n"));
81    /// assert_eq!(csv.lines().count(), 4); // 1 header + 3 rows
82    /// ```
83    ///
84    /// # Escaping example
85    ///
86    /// ```
87    /// use dev_fixtures::mock::csv::generate;
88    /// let csv = generate(&["a", "b"], 1, 0, |_rng| {
89    ///     vec![r#"contains, comma"#.into(), r#"has "quotes""#.into()]
90    /// });
91    /// // Both fields are wrapped; the quote inside is doubled.
92    /// assert!(csv.contains("\"contains, comma\""));
93    /// assert!(csv.contains("\"has \"\"quotes\"\"\""));
94    /// ```
95    pub fn generate<F>(headers: &[&str], rows: usize, seed: u64, mut row_factory: F) -> String
96    where
97        F: FnMut(&mut Rng) -> Vec<String>,
98    {
99        let mut rng = Rng::seeded(seed);
100        let mut out = String::new();
101        let header_row: Vec<String> = headers.iter().map(|h| escape_field(h)).collect();
102        out.push_str(&header_row.join(","));
103        out.push('\n');
104        for _ in 0..rows {
105            let row = row_factory(&mut rng);
106            let escaped: Vec<String> = row.iter().map(|f| escape_field(f)).collect();
107            out.push_str(&escaped.join(","));
108            out.push('\n');
109        }
110        out
111    }
112
113    /// Escape a single CSV field per RFC 4180.
114    ///
115    /// Returns the field unchanged when no special characters are
116    /// present; otherwise returns the field wrapped in double quotes
117    /// with any internal `"` doubled.
118    pub fn escape_field(value: &str) -> String {
119        if value.contains(',')
120            || value.contains('"')
121            || value.contains('\n')
122            || value.contains('\r')
123        {
124            let escaped = value.replace('"', "\"\"");
125            format!("\"{}\"", escaped)
126        } else {
127            value.to_string()
128        }
129    }
130}
131
132/// Module containing JSON array generation.
133pub mod json_array {
134    use super::Rng;
135
136    /// Generate a JSON array of `count` elements. Each element is
137    /// produced by `element_factory(rng)` and embedded verbatim in the
138    /// array (the factory MUST return valid JSON).
139    ///
140    /// # Example
141    ///
142    /// ```
143    /// use dev_fixtures::mock::{json_array::generate, Rng};
144    /// let json = generate(3, 7, |rng| {
145    ///     format!("{{\"id\": {}}}", rng.range(1000))
146    /// });
147    /// assert!(json.starts_with("["));
148    /// assert!(json.ends_with("]"));
149    /// ```
150    pub fn generate<F>(count: usize, seed: u64, mut element_factory: F) -> String
151    where
152        F: FnMut(&mut Rng) -> String,
153    {
154        let mut rng = Rng::seeded(seed);
155        let mut out = String::new();
156        out.push('[');
157        for i in 0..count {
158            if i > 0 {
159                out.push(',');
160            }
161            out.push_str(&element_factory(&mut rng));
162        }
163        out.push(']');
164        out
165    }
166}
167
168/// Module containing raw-byte generation.
169pub mod bytes {
170    use super::Rng;
171
172    /// `n` bytes of zeros.
173    pub fn zeros(n: usize) -> Vec<u8> {
174        vec![0u8; n]
175    }
176
177    /// `n` bytes of a repeating pattern.
178    ///
179    /// # Example
180    ///
181    /// ```
182    /// use dev_fixtures::mock::bytes::patterned;
183    /// let bytes = patterned(7, &[0xAB, 0xCD]);
184    /// assert_eq!(bytes, vec![0xAB, 0xCD, 0xAB, 0xCD, 0xAB, 0xCD, 0xAB]);
185    /// ```
186    pub fn patterned(n: usize, pattern: &[u8]) -> Vec<u8> {
187        if pattern.is_empty() {
188            return zeros(n);
189        }
190        let mut out = Vec::with_capacity(n);
191        while out.len() < n {
192            out.push(pattern[out.len() % pattern.len()]);
193        }
194        out
195    }
196
197    /// `n` deterministic random bytes from `seed`.
198    pub fn random(n: usize, seed: u64) -> Vec<u8> {
199        let mut rng = Rng::seeded(seed);
200        let mut out = Vec::with_capacity(n);
201        while out.len() < n {
202            let v = rng.next_u64();
203            for b in v.to_le_bytes() {
204                if out.len() < n {
205                    out.push(b);
206                }
207            }
208        }
209        out
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn rng_is_deterministic() {
219        let mut a = Rng::seeded(42);
220        let mut b = Rng::seeded(42);
221        for _ in 0..16 {
222            assert_eq!(a.next_u64(), b.next_u64());
223        }
224    }
225
226    #[test]
227    fn rng_differs_with_seed() {
228        let mut a = Rng::seeded(1);
229        let mut b = Rng::seeded(2);
230        assert_ne!(a.next_u64(), b.next_u64());
231    }
232
233    #[test]
234    fn rng_range_bounds() {
235        let mut r = Rng::seeded(7);
236        for _ in 0..1000 {
237            let v = r.range(10);
238            assert!(v < 10);
239        }
240        assert_eq!(Rng::seeded(0).range(0), 0);
241    }
242
243    #[test]
244    fn csv_generate_is_deterministic() {
245        let g = |seed| {
246            csv::generate(&["a", "b"], 5, seed, |rng| {
247                vec![rng.range(100).to_string(), rng.range(100).to_string()]
248            })
249        };
250        assert_eq!(g(42), g(42));
251        assert_ne!(g(42), g(43));
252    }
253
254    #[test]
255    fn csv_has_header_and_row_count() {
256        let csv = csv::generate(&["x", "y"], 3, 0, |rng| {
257            vec![rng.range(10).to_string(), rng.range(10).to_string()]
258        });
259        assert!(csv.starts_with("x,y\n"));
260        assert_eq!(csv.lines().count(), 4);
261    }
262
263    #[test]
264    fn csv_escapes_commas_quotes_and_newlines() {
265        let csv = csv::generate(&["a", "b"], 1, 0, |_rng| {
266            vec![
267                "value, with comma".into(),
268                "value with \"quote\" and\nnewline".into(),
269            ]
270        });
271        assert!(csv.contains("\"value, with comma\""));
272        assert!(csv.contains("\"value with \"\"quote\"\" and\nnewline\""));
273    }
274
275    #[test]
276    fn csv_escapes_in_headers_too() {
277        let csv = csv::generate(&["plain", "with, comma"], 0, 0, |_rng| vec![]);
278        assert_eq!(csv.trim(), "plain,\"with, comma\"");
279    }
280
281    #[test]
282    fn csv_unescaped_when_no_special_chars() {
283        let csv = csv::generate(&["a", "b"], 1, 0, |_rng| {
284            vec!["plain".into(), "also plain".into()]
285        });
286        assert!(csv.contains("plain,also plain"));
287        // No quotes added.
288        assert!(!csv.contains("\""));
289    }
290
291    #[test]
292    fn json_array_round_trip_shape() {
293        let json = json_array::generate(3, 0, |rng| format!("{{\"id\":{}}}", rng.range(100)));
294        assert!(json.starts_with("["));
295        assert!(json.ends_with("]"));
296        // 3 elements -> 2 commas at top level.
297        assert_eq!(json.matches(',').count(), 2);
298    }
299
300    #[test]
301    fn bytes_zeros_and_patterned() {
302        assert_eq!(bytes::zeros(4), vec![0, 0, 0, 0]);
303        assert_eq!(bytes::patterned(5, &[1, 2]), vec![1, 2, 1, 2, 1]);
304        assert_eq!(bytes::patterned(3, &[]), vec![0, 0, 0]);
305    }
306
307    #[test]
308    fn bytes_random_is_deterministic() {
309        assert_eq!(bytes::random(64, 7), bytes::random(64, 7));
310        assert_ne!(bytes::random(64, 7), bytes::random(64, 8));
311    }
312}