//! Build information for Help › About: the git commit and the build date (UTC). Both fall back to //! "unknown" when git is missing (a source tarball). Also one test per UI scenario file //! (`tests/scenarios/*.json`), so adding a scenario needs no Rust edit. use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; fn git(args: &[&str]) -> Option { let out = Command::new("git").args(args).output().ok()?; out.status.success().then(|| String::from_utf8_lossy(&out.stdout).trim().to_string()).filter(|s| !s.is_empty()) } /// Days since 1970-01-01 to a civil date (proleptic Gregorian). fn civil(days: i64) -> (i64, i64, i64) { let z = days + 719_468; let era = z.div_euclid(146_097); let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; (yoe + era * 400 + i64::from(m <= 2), m, d) } fn main() { let commit = git(&["rev-parse", "--short", "HEAD"]).unwrap_or_else(|| "unknown".into()); let dirty = git(&["status", "--porcelain", "--untracked-files=no"]).is_some(); let secs = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); let (y, m, d) = civil(secs.div_euclid(86_400)); println!("cargo:rustc-env=SOLIDCRAFT_COMMIT={commit}{}", if dirty { "+" } else { "" }); println!("cargo:rustc-env=SOLIDCRAFT_BUILD_DATE={y:04}-{m:02}-{d:02}"); // A new commit in this checkout (or worktree) restamps the build. if let Some(dir) = git(&["rev-parse", "--git-dir"]) { println!("cargo:rerun-if-changed={dir}/logs/HEAD"); } println!("cargo:rerun-if-changed=build.rs"); scenario_tests(); if let Err(e) = icon_data() { eprintln!("icon data: {e}"); std::process::exit(1); } } /// `$OUT_DIR/scenario_tests.rs`: a `#[test]` per scenario file, named after it; a scenario with /// a `{"pending": "why"}` step is `#[ignore]`d with that reason (it waits for a fix). fn scenario_tests() { let dir = std::path::Path::new("tests/scenarios"); println!("cargo:rerun-if-changed=tests/scenarios"); let mut files: Vec = std::fs::read_dir(dir) .map(|rd| rd.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.extension().is_some_and(|x| x == "json")).collect()) .unwrap_or_default(); files.sort(); let mut out = String::new(); for f in files { let Some(stem) = f.file_stem().and_then(|s| s.to_str()) else { continue }; let mut name: String = stem.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' }).collect(); if !name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') { name.insert_str(0, "s_"); } let text = std::fs::read_to_string(&f).unwrap_or_default(); let pending = serde_json::from_str::(&text) .ok() .and_then(|v| v.as_array().and_then(|a| a.iter().find_map(|s| s.get("pending").map(|p| p.as_str().unwrap_or("pending").to_string())))); if let Some(why) = pending { out += &format!("#[test]\n#[ignore = {why:?}]\nfn {name}() {{\n run({stem:?});\n}}\n\n"); } else { out += &format!("#[test]\nfn {name}() {{\n run({stem:?});\n}}\n\n"); } } let path = std::path::Path::new(&std::env::var("OUT_DIR").unwrap_or_else(|_| ".".into())).join("scenario_tests.rs"); let _ = std::fs::write(path, out); } /// `assets/icons/ui/design`: the SVG masters, `palette.toml` (every colour role in light and dark, /// and the accents) and `aliases.toml`, compiled into `$OUT_DIR/icon_data.rs` for `icon_svg.rs`. /// A missing hex, a role an accent does not know or an alias to a missing master fails the build. fn icon_data() -> Result<(), String> { use std::fmt::Write as _; let manifest = std::env::var("CARGO_MANIFEST_DIR").map_err(|e| e.to_string())?; let root = std::path::Path::new(&manifest).join("../../assets/icons/ui/design"); let read = |name: &str| std::fs::read_to_string(root.join(name)).map_err(|e| format!("{name}: {e}")); let table = |name: &str| read(name)?.parse::().map_err(|e| format!("{name}: {e}")); let hex = |v: Option<&toml::Value>, what: &str| -> Result { let s = v.and_then(toml::Value::as_str).ok_or_else(|| format!("{what}: missing hex"))?; let ok = s.len() == 7 && s.starts_with('#') && s[1..].bytes().all(|b| b.is_ascii_hexdigit()); if ok { Ok(s.to_ascii_lowercase()) } else { Err(format!("{what}: `{s}` is not #rrggbb")) } }; let rgb = |h: &str| format!("[0x{}, 0x{}, 0x{}]", &h[1..3], &h[3..5], &h[5..7]); let palette = table("palette.toml")?; let roles = palette.get("roles").and_then(toml::Value::as_table).ok_or("palette.toml: no [roles]")?; let names: Vec<&String> = roles.keys().collect(); let mut out = String::new(); let _ = writeln!(out, "pub(crate) const ROLE_NAMES: &[&str] = &{:?};", names); let (mut lights, mut darks, mut light_hex) = (Vec::new(), Vec::new(), Vec::new()); for (name, role) in roles { let l = hex(role.get("light"), &format!("role {name} light"))?; let d = hex(role.get("dark"), &format!("role {name} dark"))?; lights.push(rgb(&l)); darks.push(rgb(&d)); light_hex.push(hex(role.get("source").or_else(|| role.get("light")), &format!("role {name} source"))?); } let _ = writeln!(out, "pub(crate) const ROLE_SOURCE_HEX: &[&str] = &{:?};", light_hex); let _ = writeln!(out, "pub(crate) const PALETTE: [&[[u8; 3]]; 2] = [&[{}], &[{}]];", lights.join(", "), darks.join(", ")); let mut accent_names = Vec::new(); let mut accents = Vec::new(); if let Some(table) = palette.get("accents").and_then(toml::Value::as_table) { for (acc, roles_of) in table { let roles_of = roles_of.as_table().ok_or_else(|| format!("accent {acc}: not a table"))?; let mut items = Vec::new(); for (role, v) in roles_of { let idx = names.iter().position(|n| *n == role).ok_or_else(|| format!("accent {acc}: unknown role {role}"))?; let v = if let Some(link) = v.get("use").and_then(toml::Value::as_str) { roles_of.get(link).ok_or_else(|| format!("accent {acc}: unknown linked role {link}"))? } else { v }; let l = hex(v.get("light"), &format!("accent {acc} {role} light"))?; let d = hex(v.get("dark"), &format!("accent {acc} {role} dark"))?; items.push(format!("({idx}, {}, {})", rgb(&l), rgb(&d))); } accent_names.push(acc.clone()); accents.push(format!("&[{}]", items.join(", "))); } } let _ = writeln!(out, "pub(crate) const ACCENT_NAMES: &[&str] = &{:?};", accent_names); let _ = writeln!(out, "pub(crate) const ACCENTS: &[&[(usize, [u8; 3], [u8; 3])]] = &[{}];", accents.join(", ")); // UI tokens and SVGs share the same primary strokes, including accessibility overrides. for (constant, value) in [ ("SKETCH_PRIMARY", palette.get("accents").and_then(|v| v.get("sketch"))), ("CONSTRAINT_PRIMARY", palette.get("accents").and_then(|v| v.get("constraint"))), ("CONSTRUCTION_PRIMARY", palette.get("accents").and_then(|v| v.get("construction"))), ("SKETCH_RED_GREEN", palette.get("variants").and_then(|v| v.get("red-green")).and_then(|v| v.get("sketch"))), ("CONSTRUCTION_RED_GREEN", palette.get("variants").and_then(|v| v.get("red-green")).and_then(|v| v.get("construction"))), ("CONSTRUCTION_BLUE_YELLOW", palette.get("variants").and_then(|v| v.get("blue-yellow")).and_then(|v| v.get("construction"))), ("CONSTRAINT_BLUE_YELLOW", palette.get("variants").and_then(|v| v.get("blue-yellow")).and_then(|v| v.get("constraint"))), ] { let role = value.and_then(|v| v.get("active-3")); let l = hex(role.and_then(|v| v.get("light")), constant)?; let d = hex(role.and_then(|v| v.get("dark")), constant)?; let _ = writeln!(out, "pub(crate) const {constant}: [[u8; 3]; 2] = [{}, {}];", rgb(&l), rgb(&d)); } let mut variants = Vec::new(); if let Some(groups) = palette.get("variants").and_then(toml::Value::as_table) { for (vision, group) in groups { if !matches!(vision.as_str(), "red-green" | "blue-yellow") { return Err(format!("unknown icon colour-vision group {vision}")); } for (accent, overrides) in group.as_table().ok_or("invalid variant group")? { let a = accent_names.iter().position(|n| n == accent).ok_or_else(|| format!("variant: unknown accent {accent}"))?; for (role, value) in overrides.as_table().ok_or("invalid variant roles")? { let r = names.iter().position(|n| *n == role).ok_or_else(|| format!("variant: unknown role {role}"))?; let l = hex(value.get("light"), "variant light")?; let d = hex(value.get("dark"), "variant dark")?; variants.push(format!("({vision:?}, {a}, {r}, {}, {})", rgb(&l), rgb(&d))); } } } } let _ = writeln!(out, "pub(crate) const ACCENT_VARIANTS: &[(&str, usize, usize, [u8; 3], [u8; 3])] = &[{}];", variants.join(", ")); // The masters, by family. let mut svgs = Vec::new(); let mut ids = std::collections::BTreeSet::new(); let svg_dir = root.join("svg"); println!("cargo:rerun-if-changed={}", root.display()); for fam in ["model", "small", "flat", "workspace"] { let dir = svg_dir.join(fam); let mut files: Vec<_> = std::fs::read_dir(&dir).map_err(|e| format!("{}: {e}", dir.display()))?.filter_map(Result::ok).map(|e| e.path()).collect(); files.sort(); for f in files.iter().filter(|f| f.extension().is_some_and(|e| e == "svg")) { let id = f.file_stem().and_then(|s| s.to_str()).ok_or("bad file name")?; if fam != "small" { ids.insert(id.to_owned()); } let abs = f.canonicalize().map_err(|e| e.to_string())?; println!("cargo:rerun-if-changed={}", abs.display()); svgs.push(format!("({id:?}, {fam:?}, include_str!({:?}))", abs.display().to_string())); } } let _ = writeln!(out, "pub(crate) static SVGS: &[(&str, &str, &str)] = &[{}];", svgs.join(", ")); let aliases = table("aliases.toml")?; let mut al = Vec::new(); if let Some(t) = aliases.get("alias").and_then(toml::Value::as_table) { for (alias, v) in t { let (master, accent) = match v { toml::Value::String(m) => (m.clone(), String::new()), toml::Value::Table(t) => ( t.get("master").and_then(toml::Value::as_str).ok_or_else(|| format!("alias {alias}: no master"))?.to_string(), t.get("accent").and_then(toml::Value::as_str).unwrap_or("").to_string(), ), _ => return Err(format!("alias {alias}: expected a string or a table")), }; if !accent.is_empty() && !accent_names.contains(&accent) { return Err(format!("alias {alias}: unknown accent {accent}")); } if !ids.contains(&master) { return Err(format!("alias {alias}: unknown master {master}")); } al.push(format!("({alias:?}, {master:?}, {accent:?})")); } } let _ = writeln!(out, "pub(crate) const ALIASES: &[(&str, &str, &str)] = &[{}];", al.join(", ")); if let Some(t) = aliases.get("alias").and_then(toml::Value::as_table) { ids.extend(t.keys().cloned()); } let mut defaults = std::collections::BTreeMap::new(); if let Some(groups) = palette.get("icon-accents").and_then(toml::Value::as_table) { for (accent, icons) in groups { if !accent_names.contains(accent) { return Err(format!("icon-accents: unknown accent {accent}")); } for icon in icons.as_array().ok_or("icon-accents: expected icon array")? { let icon = icon.as_str().ok_or("icon-accents: expected icon id")?; if !ids.contains(icon) || defaults.insert(icon, accent).is_some() { return Err(format!("icon-accents: unknown or repeated icon {icon}")); } } } } let defaults: Vec<_> = defaults.iter().map(|(id, accent)| format!("({id:?}, {accent:?})")).collect(); let _ = writeln!(out, "pub(crate) const ICON_ACCENTS: &[(&str, &str)] = &[{}];", defaults.join(", ")); let dest = std::path::Path::new(&std::env::var("OUT_DIR").map_err(|e| e.to_string())?).join("icon_data.rs"); std::fs::write(dest, out).map_err(|e| e.to_string()) }