domi-egui 0.1.0

DOMicile 15-primitive widget library for egui (Phase 3c).
use std::env;
use std::fs;
use std::path::{Path, PathBuf};

use serde_json::Value;
use sha2::{Digest, Sha256};

fn main() {
    // Locate the workspace root by walking up from CARGO_MANIFEST_DIR so
    // the tokens path works regardless of how deeply the crate is nested.
    let manifest_dir = PathBuf::from(
        env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by cargo for build scripts"),
    );
    let workspace_root = find_workspace_root(&manifest_dir).unwrap_or_else(|| {
        panic!(
            "could not locate the DOMicile workspace root by walking up from {}",
            manifest_dir.display()
        )
    });
    println!("cargo:rerun-if-changed={}", workspace_root.join("Cargo.toml").display());

    let tokens_json_path = workspace_root.join("tokens").join("tokens.json");
    println!("cargo:rerun-if-changed={}", tokens_json_path.display());

    let tokens_bytes = fs::read(&tokens_json_path)
        .unwrap_or_else(|e| panic!("read tokens/tokens.json ({:?}): {}", tokens_json_path, e));
    let tokens: Value = serde_json::from_slice(&tokens_bytes)
        .expect("tokens/tokens.json parses as JSON");

    let mut out = String::new();
    out.push_str("// Generated by build.rs from tokens/tokens.json -- do not edit by hand.\n\n");

    if let Some(color) = tokens.get("color") {
        if let Some(primary) = color.get("primary") {
            if let Some(gradient) = primary.get("gradient").and_then(|v| v.as_array()) {
                let stops: Vec<String> = gradient.iter()
                    .filter_map(|v| v.as_str().map(String::from))
                    .collect();
                let stop_count = stops.len();
                out.push_str(&format!(
                    "pub const COLOR_PRIMARY_GRADIENT: &[&str; {stop_count}] = &[{}];\n",
                    stops.iter().map(|s| format!("\"{s}\"")).collect::<Vec<_>>().join(", ")
                ));
            }
            if let Some(angle) = primary.get("angle").and_then(|v| v.as_str()) {
                out.push_str(&format!("pub const COLOR_PRIMARY_ANGLE: &str = \"{angle}\";\n"));
            }
        }
        if let Some(secondary) = color.get("secondary") {
            for (name, hex) in secondary.as_object().into_iter().flatten() {
                if let Some(hex_str) = hex.as_str() {
                    let ident = to_const_ident(&format!("secondary_{name}"));
                    out.push_str(&format!("pub const {ident}: &str = \"{hex_str}\";\n"));
                }
            }
        }
        if let Some(accent) = color.get("accent") {
            for (name, hex) in accent.as_object().into_iter().flatten() {
                if let Some(hex_str) = hex.as_str() {
                    let ident = to_const_ident(&format!("accent_{name}"));
                    out.push_str(&format!("pub const {ident}: &str = \"{hex_str}\";\n"));
                }
            }
        }
        if let Some(text) = color.get("text") {
            for (name, hex) in text.as_object().into_iter().flatten() {
                if let Some(hex_str) = hex.as_str() {
                    let ident = to_const_ident(&format!("text_{name}"));
                    out.push_str(&format!("pub const {ident}: &str = \"{hex_str}\";\n"));
                }
            }
        }
        if let Some(surface) = color.get("surface") {
            for (name, hex) in surface.as_object().into_iter().flatten() {
                if let Some(hex_str) = hex.as_str() {
                    let ident = to_const_ident(&format!("surface_{name}"));
                    out.push_str(&format!("pub const {ident}: &str = \"{hex_str}\";\n"));
                }
            }
        }
    }

    if let Some(radius) = tokens.get("radius") {
        emit_px_consts(&mut out, "radius", radius);
    }
    if let Some(space) = tokens.get("space") {
        emit_px_consts(&mut out, "space", space);
    }
    if let Some(border) = tokens.get("border") {
        if let Some(obj) = border.as_object() {
            for (name, val) in obj {
                let ident = to_const_ident(&format!("border_{name}"));
                if val.is_string() {
                    out.push_str(&format!("pub const {ident}: &str = \"{}\";\n", val.as_str().unwrap()));
                }
            }
        }
    }
    if let Some(shadow) = tokens.get("shadow") {
        if let Some(obj) = shadow.as_object() {
            for (name, val) in obj {
                let ident = to_const_ident(&format!("shadow_{name}"));
                if val.is_string() {
                    out.push_str(&format!("pub const {ident}: &str = \"{}\";\n", val.as_str().unwrap()));
                }
            }
        }
    }

    let mut hasher = Sha256::new();
    hasher.update(&tokens_bytes);
    let sha = format!("{:x}", hasher.finalize());
    out.push_str(&format!("pub const TOKENS_JSON_SHA256: &str = \"{sha}\";\n"));

    let out_dir: PathBuf = env::var_os("OUT_DIR").expect("OUT_DIR set").into();
    let gen_dir = out_dir.join("generated");
    fs::create_dir_all(&gen_dir).expect("mkdir OUT_DIR/generated");
    fs::write(gen_dir.join("tokens.rs"), out).expect("write tokens.rs");
}

fn emit_px_consts(out: &mut String, group: &str, value: &Value) {
    if let Some(obj) = value.as_object() {
        for (name, val) in obj {
            let parsed: Option<f32> = val.as_f64().map(|n| n as f32)
                .or_else(|| val.as_str().and_then(parse_px_str));
            if let Some(px) = parsed {
                let ident = to_const_ident(&format!("{group}_{name}"));
                out.push_str(&format!("pub const {ident}: f32 = {px:.1};\n"));
            }
        }
    }
}

fn parse_px_str(s: &str) -> Option<f32> {
    let trimmed = s.trim();
    let numeric = trimmed.trim_end_matches("px").trim();
    numeric.parse::<f32>().ok()
}

fn to_const_ident(input: &str) -> String {
    let mut out = String::with_capacity(input.len());
    for ch in input.chars() {
        if ch.is_ascii_lowercase() || ch.is_ascii_uppercase() || ch.is_ascii_digit() {
            out.push(ch.to_ascii_uppercase());
        } else if ch == '_' || ch == '-' {
            out.push('_');
        }
    }
    out
}

/// Walk up from `start`'s parent until we find a directory whose
/// `Cargo.toml` declares a `[workspace]` table.
fn find_workspace_root(start: &Path) -> Option<PathBuf> {
    let mut current = start.parent().map(PathBuf::from);
    while let Some(dir) = current {
        let manifest = dir.join("Cargo.toml");
        if manifest.is_file() && has_workspace_table(&manifest) {
            return Some(dir);
        }
        current = dir.parent().map(PathBuf::from);
    }
    None
}

fn has_workspace_table(manifest: &Path) -> bool {
    fs::read_to_string(manifest)
        .map(|s| s.contains("[workspace]"))
        .unwrap_or(false)
}