Skip to content
//! 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'], " ")
}