use jzon::{array, object, JsonValue}; use lazy_static::lazy_static; use std::collections::HashMap; use std::sync::Mutex; use include_dir::{include_dir, Dir}; use crate::include_file; static MASTERDATA_JP: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/router/databases/csv"); static MASTERDATA_EN: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/router/databases/csv-en"); #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Region { Jp, En, } lazy_static! { static ref TABLE_CACHE: Mutex> = Mutex::new(HashMap::new()); // This also needs to be packed into the client - this never changes static ref SCHEMAS: JsonValue = jzon::parse( &include_file!("src/router/databases/schemas.json") ).expect("schemas.json is malformed"); } fn dir_for(region: Region) -> &'static Dir<'static> { match region { Region::Jp => &MASTERDATA_JP, Region::En => &MASTERDATA_EN, } } fn region_subdir(region: Region) -> &'static str { match region { Region::Jp => "csv", Region::En => "csv-en", } } pub fn get_all(region: Region) -> JsonValue { let mut rv = object!{}; for file in dir_for(region).files() { let table_name = file.path() .file_stem() .and_then(|s| s.to_str()) .unwrap_or_default(); if table_name.is_empty() { continue; } if let Some(bytes) = csv_bytes(region, table_name) { rv[table_name] = String::from_utf8(bytes).unwrap_or_default().into(); } } rv } pub fn csv_bytes(region: Region, name: &str) -> Option> { let rel = format!("{}/{}.csv", region_subdir(region), name); if let Some(bytes) = crate::runtime::read_masterdata_file(&rel) { return Some(bytes); } dir_for(region) .get_file(format!("{name}.csv")) .map(|f| f.contents().to_vec()) } pub fn table(region: Region, name: &str) -> JsonValue { let key = (region, name.to_owned()); if let Some(cached) = TABLE_CACHE.lock().unwrap().get(&key) { return cached.clone(); } let bytes = csv_bytes(region, name).unwrap_or_else(|| { panic!("masterdata CSV not bundled: {name}.csv ({region:?})") }); let parsed = parse_csv(name, &bytes); TABLE_CACHE.lock().unwrap().insert(key, parsed.clone()); parsed } fn field_types(table_name: &str) -> HashMap { let mut out = HashMap::new(); let table = &SCHEMAS["tables"][table_name]; for f in table["fields"].members() { out.insert(f["name"].to_string(), f["type"].to_string()); } out } fn parse_csv(table_name: &str, bytes: &[u8]) -> JsonValue { let types = field_types(table_name); let mut rdr = csv::ReaderBuilder::new() .has_headers(true) .flexible(true) .from_reader(bytes); let raw_headers: Vec = rdr .headers() .expect("malformed CSV header") .iter() .enumerate() .map(|(i, h)| { if i == 0 { h.trim_start_matches('\u{feff}').to_owned() } else { h.to_owned() } }) .collect(); let json_keys: Vec = raw_headers .iter() .map(|h| h.strip_prefix('_').unwrap_or(h).to_owned()) .collect(); // Default missing schema columns to "string" — safer than guessing. let column_types: Vec<&str> = raw_headers .iter() .map(|h| types.get(h.as_str()).map(String::as_str).unwrap_or("string")) .collect(); let mut out = array![]; for record in rdr.records() { let record = record.expect("malformed CSV row"); let mut row = object! {}; for (i, raw) in record.iter().enumerate() { let key = match json_keys.get(i) { Some(k) => k.as_str(), None => continue, // extra trailing columns — ignore }; row[key] = coerce(raw, column_types[i]); } out.push(row).expect("array push"); } out } fn coerce(raw: &str, type_token: &str) -> JsonValue { if let Some(elem) = type_token.strip_suffix("[]") { if raw.is_empty() { return array![]; } let mut out = array![]; for part in raw.split(',') { out.push(coerce(part, elem)).expect("array push"); } return out; } match type_token { "string" => JsonValue::String(raw.to_owned()), "bool" => JsonValue::Boolean(matches!(raw, "1" | "true" | "True" | "TRUE")), "float" | "double" => { if raw.is_empty() { JsonValue::from(0.0) } else { raw.parse::() .map(JsonValue::from) .unwrap_or_else(|_| JsonValue::String(raw.to_owned())) } } _ => { if raw.is_empty() { JsonValue::from(0) } else { raw.parse::() .map(JsonValue::from) .unwrap_or_else(|_| JsonValue::String(raw.to_owned())) } } } }