//! Per-table config files: a readable, tab-separated text format the user can
//! save and reload. Deliberately not JSON — keeping zero serde dependency — but
//! still human-editable, which suits a debugger.
//!
//! ```text
//! # hecatomb config v1
//! grid example-grid
//! rowModel clientSide
//! rows 100
//! col make make enum Tesla Ford Toyota
//! col price price int 15774 64950
//! col electric electric bool
//! col launched launched date 1262304000000 1704067200000
//! col vin VIN str 11 17 1HGCM 2T1BU
//! ```
use crate::wasm::model::{ColKind, Column, RowModel, Table};
const HEADER: &str = "# hecatomb config v1";
/// Serialize a table's schema + generation count to config text.
pub fn to_text(table: &Table) -> String {
let mut out = String::new();
out.push_str(HEADER);
out.push('\n');
out.push_str(&format!("grid\t{}\n", clean(&table.id)));
out.push_str(&format!("rowModel\t{}\n", table.row_model.as_str()));
out.push_str(&format!("rows\t{}\n", table.gen_count));
for col in &table.columns {
out.push_str("col\t");
out.push_str(&clean(&col.field));
out.push('\t');
out.push_str(&clean(&col.header));
out.push('\t');
match &col.kind {
ColKind::Bool => out.push_str("bool"),
ColKind::Int { min, max } => out.push_str(&format!("int\t{}\t{}", min, max)),
ColKind::Float { min, max } => out.push_str(&format!("float\t{}\t{}", min, max)),
ColKind::Date { min, max } => out.push_str(&format!("date\t{}\t{}", min, max)),
ColKind::Enum { values } => {
out.push_str("enum");
for v in values {
out.push('\t');
out.push_str(&clean(v));
}
}
ColKind::Str {
pool,
min_len,
max_len,
} => {
out.push_str(&format!("str\t{}\t{}", min_len, max_len));
for v in pool {
out.push('\t');
out.push_str(&clean(v));
}
}
}
out.push('\n');
}
out
}
/// Parse config text back into a `Table`.
pub fn from_text(text: &str) -> Table {
let mut id = String::new();
let mut row_model = RowModel::ClientSide;
let mut gen_count = 100usize;
let mut columns = Vec::new();
for line in text.lines() {
let line = line.trim_end_matches('\r');
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut parts = line.split('\t');
match parts.next().unwrap_or("") {
"grid" => id = parts.next().unwrap_or("").to_string(),
"rowModel" => row_model = RowModel::from_str(parts.next().unwrap_or("")),
"rows" => gen_count = parts.next().unwrap_or("100").parse().unwrap_or(100),
"col" => {
if let Some(col) = parse_col(&mut parts) {
columns.push(col);
}
}
_ => {}
}
}
let gen_count = gen_count.max(1);
Table {
id,
row_model,
row_count: gen_count,
gen_count,
columns,
}
}
fn parse_col<'a>(parts: &mut impl Iterator<Item = &'a str>) -> Option<Column> {
let field = parts.next()?.to_string();
if field.is_empty() {
return None;
}
let header = parts.next().unwrap_or(&field).to_string();
let kind = match parts.next().unwrap_or("str") {
"bool" => ColKind::Bool,
"int" => ColKind::Int {
min: int(parts.next(), 0),
max: int(parts.next(), 1000),
},
"float" => ColKind::Float {
min: float(parts.next(), 0.0),
max: float(parts.next(), 1000.0),
},
"date" => ColKind::Date {
min: int(parts.next(), 1_577_836_800_000),
max: int(parts.next(), 1_704_067_200_000),
},
"enum" => {
let values: Vec<String> = parts.map(|s| s.to_string()).collect();
ColKind::Enum { values }
}
"str" => {
let min_len = int(parts.next(), 3) as usize;
let max_len = int(parts.next(), 10) as usize;
let pool: Vec<String> = parts.map(|s| s.to_string()).collect();
ColKind::Str {
pool,
min_len,
max_len,
}
}
other => {
// Unknown kind: treat the remainder as an enum pool starting with it.
let mut values = vec![other.to_string()];
values.extend(parts.map(|s| s.to_string()));
ColKind::Enum { values }
}
};
Some(Column {
field,
header,
kind,
})
}
fn int(s: Option<&str>, default: i64) -> i64 {
s.and_then(|v| v.parse().ok()).unwrap_or(default)
}
fn float(s: Option<&str>, default: f64) -> f64 {
s.and_then(|v| v.parse().ok()).unwrap_or(default)
}
/// Strip tabs/newlines so values stay on one field of one line.
fn clean(s: &str) -> String {
s.replace(['\t', '\n', '\r'], " ")
}