use std::sync::{Arc, Mutex};
use ciborium::Value as CborValue;
use fake::Fake;
use fake::faker::address::en::{CityName, CountryName, StreetName};
use fake::faker::company::en::CompanyName;
use fake::faker::internet::en::{SafeEmail, Username};
use fake::faker::lorem::en::Word;
use fake::faker::name::en::{FirstName, LastName, Name};
use fake::faker::phone_number::en::PhoneNumber;
use fake::rand::rngs::StdRng;
use fake::rand::{RngExt as _, SeedableRng as _};
use vantage_types::Record;
use crate::FakerColumn;
const ANOMALIES: [&str; 4] = ["long", "blank", "duplicate", "unicode"];
pub(crate) fn entropy_rng() -> StdRng {
StdRng::seed_from_u64(fake::rand::random())
}
#[derive(Clone)]
pub struct ValueGen {
rng: Arc<Mutex<StdRng>>,
weirdness: f64,
}
impl Default for ValueGen {
fn default() -> Self {
Self::new()
}
}
impl ValueGen {
pub fn new() -> Self {
Self {
rng: Arc::new(Mutex::new(entropy_rng())),
weirdness: 0.0,
}
}
pub fn seeded(seed: u64) -> Self {
Self {
rng: Arc::new(Mutex::new(StdRng::seed_from_u64(seed))),
weirdness: 0.0,
}
}
pub fn with_weirdness(mut self, weirdness: f64) -> Self {
self.weirdness = weirdness.clamp(0.0, 1.0);
self
}
pub fn value_for(&self, col: &FakerColumn) -> CborValue {
let rng = &mut *self.rng.lock().unwrap();
Self::value_for_with(rng, col)
}
fn value_for_with(rng: &mut StdRng, col: &FakerColumn) -> CborValue {
let name = col.name.to_lowercase();
if name.contains("email") {
return CborValue::Text(SafeEmail().fake_with_rng(rng));
}
if name.contains("first") && name.contains("name") {
return CborValue::Text(FirstName().fake_with_rng(rng));
}
if name.contains("last") && name.contains("name") || name.contains("surname") {
return CborValue::Text(LastName().fake_with_rng(rng));
}
if name.contains("username") || name.contains("login") || name.contains("handle") {
return CborValue::Text(Username().fake_with_rng(rng));
}
if name.contains("name") {
return CborValue::Text(Name().fake_with_rng(rng));
}
if name.contains("phone") || name.contains("mobile") || name.contains("tel") {
return CborValue::Text(PhoneNumber().fake_with_rng(rng));
}
if name.contains("city") {
return CborValue::Text(CityName().fake_with_rng(rng));
}
if name.contains("country") {
return CborValue::Text(CountryName().fake_with_rng(rng));
}
if name.contains("street") || name.contains("address") {
return CborValue::Text(StreetName().fake_with_rng(rng));
}
if name.contains("company") || name.contains("employer") || name.contains("organization") {
return CborValue::Text(CompanyName().fake_with_rng(rng));
}
Self::value_by_type(rng, &col.ty)
}
fn value_by_type(rng: &mut StdRng, ty: &str) -> CborValue {
match ty.trim().to_lowercase().as_str() {
"int" | "integer" | "number" | "i64" | "bigint" => {
let n: i64 = (0..10_000).fake_with_rng(rng);
CborValue::Integer(n.into())
}
"decimal" | "float" | "double" | "money" | "amount" | "f64" => {
let cents: i64 = (0..1_000_000).fake_with_rng(rng);
CborValue::Float(cents as f64 / 100.0)
}
"bool" | "boolean" => CborValue::Bool(fake::Faker.fake_with_rng(rng)),
"datetime" | "date" | "timestamp" => {
let day: u8 = (1..=28).fake_with_rng(rng);
let hour: u8 = (0..24).fake_with_rng(rng);
CborValue::Text(format!("2026-01-{day:02}T{hour:02}:00:00Z"))
}
_ => CborValue::Text(Word().fake_with_rng(rng)),
}
}
fn anomaly(rng: &mut StdRng) -> CborValue {
let kind = ANOMALIES[rng.random_range(0..ANOMALIES.len())];
CborValue::Text(match kind {
"long" => {
let mut s = String::with_capacity(210);
while s.len() < 200 {
let w: String = Word().fake_with_rng(rng);
s.push_str(&w);
s.push(' ');
}
s
}
"blank" => String::new(),
"duplicate" => "John Smith".to_string(),
_ => "Žofia 🌸 Ōkami".to_string(),
})
}
pub fn record_for(
&self,
columns: &[FakerColumn],
id_column: &str,
id: &str,
) -> Record<CborValue> {
let rng = &mut *self.rng.lock().unwrap();
let mut rec = Record::new();
let mut wrote_id = false;
for col in columns {
if col.name == id_column {
rec.insert(col.name.clone(), CborValue::Text(id.to_string()));
wrote_id = true;
} else {
let mut value = Self::value_for_with(rng, col);
if self.weirdness > 0.0
&& matches!(value, CborValue::Text(_))
&& rng.random_range(0.0..1.0) < self.weirdness
{
value = Self::anomaly(rng);
}
rec.insert(col.name.clone(), value);
}
}
if !wrote_id {
rec.insert(id_column.to_string(), CborValue::Text(id.to_string()));
}
rec
}
}
#[cfg(test)]
mod tests {
use super::*;
fn col(name: &str, ty: &str) -> FakerColumn {
FakerColumn {
name: name.to_string(),
ty: ty.to_string(),
flags: vec![],
}
}
fn text(v: &CborValue) -> &str {
match v {
CborValue::Text(s) => s,
other => panic!("expected text, got {other:?}"),
}
}
#[test]
fn email_column_is_email_shaped() {
let g = ValueGen::new();
let v = g.value_for(&col("email", "string"));
assert!(text(&v).contains('@'), "expected an email, got {v:?}");
}
#[test]
fn name_column_is_nonempty_text() {
let g = ValueGen::new();
let v = g.value_for(&col("full_name", "string"));
assert!(!text(&v).is_empty());
}
#[test]
fn type_fallback_maps_scalars() {
let g = ValueGen::new();
assert!(matches!(
g.value_for(&col("qty", "int")),
CborValue::Integer(_)
));
assert!(matches!(
g.value_for(&col("balance", "decimal")),
CborValue::Float(_)
));
assert!(matches!(
g.value_for(&col("active", "bool")),
CborValue::Bool(_)
));
}
#[test]
fn record_sets_id_column_and_fills_rest() {
let g = ValueGen::new();
let cols = [
col("id", "string"),
col("email", "string"),
col("age", "int"),
];
let rec = g.record_for(&cols, "id", "abc");
assert_eq!(rec.get("id"), Some(&CborValue::Text("abc".into())));
assert!(text(rec.get("email").unwrap()).contains('@'));
assert!(matches!(rec.get("age"), Some(CborValue::Integer(_))));
}
#[test]
fn same_seed_replays_the_same_records() {
let cols = [
col("id", "string"),
col("name", "string"),
col("age", "int"),
];
let a: Vec<_> = {
let g = ValueGen::seeded(42);
(0..5)
.map(|i| g.record_for(&cols, "id", &i.to_string()))
.collect()
};
let b: Vec<_> = {
let g = ValueGen::seeded(42);
(0..5)
.map(|i| g.record_for(&cols, "id", &i.to_string()))
.collect()
};
assert_eq!(a, b, "a seed is a promise");
}
#[test]
fn weirdness_one_makes_every_string_cell_anomalous() {
let g = ValueGen::seeded(7).with_weirdness(1.0);
let cols = [col("id", "string"), col("name", "string")];
let anomalies: Vec<String> = (0..40)
.map(|i| {
text(
g.record_for(&cols, "id", &i.to_string())
.get("name")
.unwrap(),
)
.to_string()
})
.collect();
for v in &anomalies {
let from_pool = v.is_empty() || v == "John Smith" || v.contains('Ž') || v.len() >= 200;
assert!(from_pool, "unexpected non-anomalous value {v:?}");
}
assert!(anomalies.iter().any(|v| v.is_empty()));
assert!(anomalies.iter().any(|v| v.len() >= 200));
}
#[test]
fn weirdness_zero_never_draws_anomalies() {
let g = ValueGen::seeded(7);
let cols = [col("id", "string"), col("name", "string")];
for i in 0..40 {
let rec = g.record_for(&cols, "id", &i.to_string());
let v = text(rec.get("name").unwrap()).to_string();
assert!(
!v.is_empty() && v != "John Smith" && v.len() < 200,
"anomaly leaked: {v:?}"
);
}
}
}