//! Project automation: `cargo xtask ci | layers | assets | licences | wasm | web | site | source-audit | libraries`. mod audit; mod ctan; mod libraries; mod package; mod site; mod xfig_compare; use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode}; /// Workspace crates and their layers. `figbase` and `fig2dev` live in /// https://github.com/FabianHassler/fig2dev (git dependencies) and sit below every /// RustFig layer, so they are not listed. const LAYERS: &[(&str, &str, i32)] = &[ ("rustfig-geom", "crates/geom", 0), ("rustfig-model", "crates/model", 1), ("rustfig-text", "crates/text", 1), ("rustfig-format", "crates/format", 2), ("rustfig-render", "crates/render", 3), ("rustfig-engine", "crates/engine", 4), ("rustfig-mcp", "crates/mcp", 5), ("rustfig-os", "crates/os", 5), ("rustfig-ui-egui", "crates/ui-egui", 6), ("rustfig", "apps/rustfig", 7), ("rustfig-cli", "apps/cli", 7), ]; /// Dependencies allowed only from layer 6 up. const UI_ONLY: &[&str] = &[ "egui", "eframe", "egui-winit", "egui_glow", "egui-wgpu", "winit", "rfd", "epaint", ]; /// Asset file extensions that need an ASSETS.md row. const ASSET_EXTS: &[&str] = &[ "png", "jpg", "jpeg", "gif", "svg", "ico", "icns", "bmp", "webp", "ttf", "otf", "woff", "woff2", "wav", "mp3", "ogg", "xpm", "xbm", "ps", "eps", ]; const FONT_EXTS: &[&str] = &["ttf", "otf", "woff", "woff2", "ttc", "pfb", "afm"]; // `.claude` holds agent worktrees: copies of the repository, not part of it. const SKIP_DIRS: &[&str] = &[ "target", ".git", ".cargo-home", ".claude", "old", "plan", "dist", "node_modules", ]; /// The workspace root, found at run time: the nearest directory at or above /// the current one whose Cargo.toml declares `[workspace]`. A path fixed at /// compile time would go stale when the checkout moves and the xtask binary /// isn't rebuilt. fn root() -> PathBuf { let is_root = |d: &Path| { std::fs::read_to_string(d.join("Cargo.toml")).is_ok_and(|m| m.lines().any(|l| l.trim() == "[workspace]")) }; if let Ok(cwd) = std::env::current_dir() { if let Some(d) = cwd.ancestors().find(|d| is_root(d)) { return d.to_path_buf(); } } Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .map(Path::to_path_buf) .unwrap_or_default() } fn run(cmd: &mut Command) -> Result<(), String> { eprintln!("$ {cmd:?}"); let status = cmd.current_dir(root()).status().map_err(|e| e.to_string())?; if status.success() { Ok(()) } else { Err(format!("command failed: {cmd:?}")) } } /// Cargo of the running toolchain, using that toolchain's rustc too /// (unless RUSTC is set), so `cargo +toolchain xtask …` stays on it. fn cargo() -> Command { let mut c = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into())); if std::env::var_os("RUSTC").is_none() { if let Some(r) = sibling_rustc() { c.env("RUSTC", r); } } c } /// Names of the dependencies declared in a Cargo.toml (all dependency tables). fn dependencies(manifest: &str) -> Vec { let mut deps = Vec::new(); let mut in_deps = false; for line in manifest.lines() { let l = line.trim(); if l.starts_with('[') { in_deps = l.contains("dependencies"); continue; } if !in_deps || l.is_empty() || l.starts_with('#') { continue; } if let Some((name, _)) = l.split_once('=') { let name = name.trim().split('.').next().unwrap_or("").trim().trim_matches('"'); if !name.is_empty() { deps.push(name.to_string()); } } } deps } fn layer_name(layer: i32) -> String { if layer < 0 { "base".to_string() } else { format!("L{layer}") } } fn layers() -> Result<(), String> { let mut errors = Vec::new(); for (name, dir, layer) in LAYERS { let manifest = std::fs::read_to_string(root().join(dir).join("Cargo.toml")).map_err(|e| format!("{dir}: {e}"))?; for dep in dependencies(&manifest) { if let Some((_, _, dl)) = LAYERS.iter().find(|(n, _, _)| *n == dep) { if dl >= layer && dep != *name { errors.push(format!( "{name} ({}) depends on {dep} ({})", layer_name(*layer), layer_name(*dl) )); } } if *layer < 6 && UI_ONLY.contains(&dep.as_str()) { errors.push(format!("{name} ({}) depends on UI crate {dep}", layer_name(*layer))); } } } if errors.is_empty() { println!("layers: ok"); Ok(()) } else { Err(format!("layer violations:\n {}", errors.join("\n "))) } } fn walk(dir: &Path, out: &mut Vec) { let Ok(rd) = std::fs::read_dir(dir) else { return }; for e in rd.flatten() { let p = e.path(); let name = e.file_name().to_string_lossy().to_string(); if p.is_dir() { // `wt-*`: git worktrees of parallel agents (excluded from git). if !SKIP_DIRS.contains(&name.as_str()) && !name.starts_with("target") && !name.starts_with("wt-") { walk(&p, out); } } else { out.push(p); } } } /// The one font file the repository ships (decided 2026-10-22): the key-symbol subset /// (SIL OFL 1.1, `assets/fonts/OFL.txt`). Matched by path and a size cap, so any other /// font, or a bigger one under that name, still fails. const ALLOWED_FONT: &str = "assets/fonts/rustfig-key-symbols.ttf"; const ALLOWED_FONT_MAX_BYTES: u64 = 16 * 1024; fn is_allowed_font(rel: &str, path: &Path) -> bool { allowed_font(rel, std::fs::metadata(path).map(|m| m.len()).unwrap_or(u64::MAX)) } fn allowed_font(rel: &str, len: u64) -> bool { rel == ALLOWED_FONT && len <= ALLOWED_FONT_MAX_BYTES } #[cfg(test)] mod font_tests { use super::allowed_font; #[test] fn only_the_key_symbol_font_is_allowed() { assert!(allowed_font("assets/fonts/rustfig-key-symbols.ttf", 2000)); assert!(!allowed_font("assets/fonts/rustfig-key-symbols.ttf", 600_000)); assert!(!allowed_font("assets/fonts/other.ttf", 2000)); assert!(!allowed_font("crates/x/rustfig-key-symbols.ttf", 2000)); } } fn assets() -> Result<(), String> { let root = root(); let table = std::fs::read_to_string(root.join("ASSETS.md")).map_err(|e| format!("ASSETS.md: {e}"))?; let mut files = Vec::new(); walk(&root, &mut files); let mut errors = Vec::new(); let mut count = 0; for f in files { let ext = f .extension() .and_then(|e| e.to_str()) .unwrap_or("") .to_ascii_lowercase(); let rel = f.strip_prefix(&root).unwrap_or(&f).to_string_lossy().replace('\\', "/"); if FONT_EXTS.contains(&ext.as_str()) && !is_allowed_font(&rel, &f) { errors.push(format!( "{rel}: font files do not belong in this repository (only the key-symbol subset, listed in ASSETS.md)" )); continue; } // xfig's figure libraries (D6): a `.fig` file under `libraries/` is an asset // covered by the row of its library folder, `libraries//`. if ext == "fig" { if let Some(top) = rel .strip_prefix("libraries/") .and_then(|r| r.split_once('/')) .map(|(t, _)| t) { count += 1; let key = format!("`libraries/{top}/`"); let listed = table .lines() .any(|l| l.trim_start().starts_with('|') && l.contains(&key)); if !listed { errors.push(format!("{rel}: no row for libraries/{top}/ in ASSETS.md")); } } continue; } if ASSET_EXTS.contains(&ext.as_str()) { count += 1; let listed = table .lines() .any(|l| l.trim_start().starts_with('|') && l.contains(&format!("`{rel}`"))); if !listed { errors.push(format!("{rel}: no row in ASSETS.md")); } } } if errors.is_empty() { println!("assets: ok ({count} attributed)"); Ok(()) } else { Err(format!("asset problems:\n {}", errors.join("\n "))) } } /// Every Rust file with code ported from xfig or fig2dev names its sources /// and points to LICENSE-xfig, and LICENSE-xfig lists exactly those files. fn licences() -> Result<(), String> { let root = root(); let notice = std::fs::read_to_string(root.join("LICENSE-xfig")).map_err(|e| format!("LICENSE-xfig: {e}"))?; let listed: Vec = notice .lines() .filter_map(|l| l.strip_prefix("- ")) .filter_map(|l| l.split_once(':').map(|(p, _)| p.trim().to_string())) .collect(); let mut files = Vec::new(); walk(&root, &mut files); let mut errors = Vec::new(); let mut cited = Vec::new(); for f in files.iter().filter(|f| f.extension().is_some_and(|e| e == "rs")) { let rel = f.strip_prefix(&root).unwrap_or(f).to_string_lossy().replace('\\', "/"); if rel.starts_with("xtask/") { continue; } let Ok(text) = std::fs::read_to_string(f) else { continue }; let ported = text.contains("Ported from xfig") || text.contains("Ported from fig2dev"); let cites = text.contains("LICENSE-xfig"); if ported && !cites { errors.push(format!( "{rel}: ported from xfig or fig2dev but doesn't point to LICENSE-xfig" )); } if cites { if !listed.contains(&rel) { errors.push(format!("{rel}: cites LICENSE-xfig but isn't listed there")); } cited.push(rel); } } for l in &listed { if !cited.contains(l) { errors.push(format!( "LICENSE-xfig lists {l}, which doesn't exist or doesn't cite it" )); } } if errors.is_empty() { println!("licences: ok ({} files derived from xfig and fig2dev)", cited.len()); Ok(()) } else { Err(format!("licence problems:\n {}", errors.join("\n "))) } } /// The rustc of the toolchain running this cargo (its sibling binary), so a /// `cargo +toolchain xtask` checks that toolchain, not whichever rustc comes /// first on PATH. fn sibling_rustc() -> Option { std::env::var_os("CARGO") .map(PathBuf::from) .and_then(|c| { c.parent() .map(|d| d.join(if cfg!(windows) { "rustc.exe" } else { "rustc" })) }) .filter(|r| r.is_file()) } fn rustc() -> Command { Command::new(sibling_rustc().unwrap_or_else(|| PathBuf::from("rustc"))) } fn wasm_target_installed() -> bool { let out = rustc().args(["--print", "sysroot"]).output(); let Ok(out) = out else { return false }; let sysroot = String::from_utf8_lossy(&out.stdout).trim().to_string(); Path::new(&sysroot).join("lib/rustlib/wasm32-unknown-unknown").is_dir() } fn wasm() -> Result<(), String> { // CI runs the wasm check once (on Linux); the other desktops opt out explicitly. if std::env::var_os("RUSTFIG_SKIP_WASM").is_some_and(|v| !v.is_empty() && v != "0") { eprintln!("wasm check skipped (RUSTFIG_SKIP_WASM is set)"); return Ok(()); } if !wasm_target_installed() { if std::env::var_os("RUSTFIG_REQUIRE_WASM").is_some() { return Err("wasm32-unknown-unknown is not installed (rustup target add wasm32-unknown-unknown)".into()); } eprintln!("warning: wasm32-unknown-unknown target not installed; wasm check SKIPPED"); eprintln!(" install it (rustup target add wasm32-unknown-unknown) or set RUSTFIG_REQUIRE_WASM=1 in CI"); return Ok(()); } run(cargo().args(["check", "--target", "wasm32-unknown-unknown", "-p", "rustfig"])) } /// `trunk` on `PATH`, or in cargo's bin folder (where `cargo install` puts it). fn find_trunk() -> Option { let exe = if cfg!(windows) { "trunk.exe" } else { "trunk" }; let mut dirs: Vec = std::env::var_os("PATH") .map(|p| std::env::split_paths(&p).collect()) .unwrap_or_default(); if let Some(h) = std::env::var_os("CARGO_HOME") { dirs.push(Path::new(&h).join("bin")); } if let Some(h) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) { dirs.push(Path::new(&h).join(".cargo/bin")); } dirs.into_iter().map(|d| d.join(exe)).find(|p| p.is_file()) } /// JSON string with the escapes a file name can need. fn json_str(s: &str) -> String { let mut o = String::from("\""); for c in s.chars() { match c { '"' => o.push_str("\\\""), '\\' => o.push_str("\\\\"), c if (c as u32) < 0x20 => o.push_str(&format!("\\u{:04x}", c as u32)), c => o.push(c), } } o.push('"'); o } /// The index of the bundled figure libraries for the web build (C2): every /// folder under `root` with `.fig` files is a library named `Outer / Inner` /// (as the engine's scan names them), with its figures' names. The browser /// fetches `libraries.json` and then the figures it draws, lazily. fn libraries_index(root: &Path) -> Result { fn walk(dir: &Path, rel: &[String], out: &mut Vec) -> Result<(), String> { let mut figs: Vec = Vec::new(); let mut subs: Vec<(String, PathBuf)> = Vec::new(); for e in std::fs::read_dir(dir) .map_err(|e| format!("{}: {e}", dir.display()))? .flatten() { let name = e.file_name().to_string_lossy().into_owned(); if name.starts_with('.') { continue; } if e.path().is_dir() { subs.push((name, e.path())); } else if name.len() > 4 && name.to_ascii_lowercase().ends_with(".fig") { figs.push(name[..name.len() - 4].to_string()); } } // (figures directly in `libraries/` itself are not part of the bundle) if !figs.is_empty() && !rel.is_empty() { figs.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()).then(a.cmp(b))); let list: Vec = figs.iter().map(|f| json_str(f)).collect(); out.push(format!( "{{\"name\":{},\"dir\":{},\"figures\":[{}]}}", json_str(&rel.join(" / ")), json_str(&rel.join("/")), list.join(",") )); } subs.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()).then(a.0.cmp(&b.0))); for (name, path) in subs { let mut r = rel.to_vec(); r.push(name); walk(&path, &r, out)?; } Ok(()) } let mut libs = Vec::new(); walk(root, &[], &mut libs)?; Ok(format!("{{\"libraries\":[{}]}}\n", libs.join(","))) } /// `cargo xtask web [--debug] [--serve]`: the web app (C1), built by trunk /// from `web/index.html` into `dist/` (index.html, the wasm, its JS glue). /// `--serve` runs trunk's dev server instead (http://127.0.0.1:8080). fn web() -> Result<(), String> { let args: Vec = std::env::args().skip(2).collect(); let Some(trunk) = find_trunk() else { return Err( "trunk is not installed. Install it once with:\n cargo install --locked trunk\n\ (the build also needs the wasm target: rustup target add wasm32-unknown-unknown; \ trunk downloads wasm-bindgen itself on the first build)" .into(), ); }; if !wasm_target_installed() { return Err("wasm32-unknown-unknown is not installed (rustup target add wasm32-unknown-unknown)".into()); } // The bundled libraries' index, which `web/index.html` copies into dist/ // beside the `libraries/` folder (the Library mode in the browser, C2). std::fs::write( root().join("web/libraries.json"), libraries_index(&root().join("libraries"))?, ) .map_err(|e| format!("web/libraries.json: {e}"))?; let (html, dist) = (root().join("web/index.html"), root().join("dist")); let mut cmd = Command::new(trunk); if args.iter().any(|a| a == "--serve") { cmd.arg("serve"); } else { cmd.arg("build"); } cmd.arg(&html).arg("--dist").arg(&dist); // (after the path: `--release` takes an optional value) if !args.iter().any(|a| a == "--debug" || a == "--serve") { cmd.arg("--release"); } // Where the site is served from, e.g. `/rustfig/` on GitHub Pages. if let Some(url) = args .iter() .position(|a| a == "--public-url") .and_then(|i| args.get(i + 1)) { cmd.arg("--public-url").arg(url); } if std::env::var_os("RUSTC").is_none() { if let Some(r) = sibling_rustc() { cmd.env("RUSTC", r); } } // Trunk reads the package it builds from the Cargo.toml of its working // directory, which must name a package (the workspace root is virtual). cmd.current_dir(root().join("apps/rustfig")); eprintln!("$ {cmd:?}"); let status = cmd.status().map_err(|e| e.to_string())?; if !status.success() { return Err(format!("trunk failed: {status}")); } let wasm = std::fs::read_dir(&dist) .map_err(|e| format!("dist/: {e}"))? .filter_map(Result::ok) .any(|e| e.path().extension().is_some_and(|x| x == "wasm")); if !dist.join("index.html").is_file() || !wasm { return Err("trunk finished, but dist/ has no index.html and .wasm file".into()); } println!("web: built dist/ (serve it with `cargo xtask web --serve`, or any static server)"); Ok(()) } fn ci() -> Result<(), String> { run(cargo().args(["fmt", "--all", "--check"]))?; run(cargo().args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]))?; // Tests must not depend on the fonts installed on the machine (F1). run(cargo() .env("FIG2DEV_BUILTIN_FONTS", "1") .args(["test", "--workspace", "--no-fail-fast"]))?; layers()?; assets()?; licences()?; wasm()?; println!("ci: all gates passed"); Ok(()) } fn main() -> ExitCode { let task = std::env::args().nth(1).unwrap_or_default(); let r = match task.as_str() { "ci" => ci(), "layers" => layers(), "assets" => assets(), "licences" => licences(), "wasm" => wasm(), "web" => web(), "package" => package::run(&root(), cargo()), "source-audit" => audit::run(&root()), "xfig-compare" => xfig_compare::run(&root(), cargo()), "libraries" => libraries::run(&root()), "site" => site::run(&root()), "ctan" => ctan::run(&root(), &std::env::args().nth(2).unwrap_or_else(|| "HEAD".into())), _ => Err( "usage: cargo xtask ci | layers | assets | licences | wasm | web | package | source-audit | xfig-compare | libraries | site | ctan [REF]" .into(), ), }; match r { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("{e}"); ExitCode::FAILURE } } } #[cfg(test)] mod tests { use super::*; #[test] fn libraries_index_lists_folders_with_figures() { let t = std::env::temp_dir().join(format!("rustfig-libidx-{}", std::process::id())); let _ = std::fs::remove_dir_all(&t); std::fs::create_dir_all(t.join("Outer/In ner")).unwrap(); std::fs::create_dir_all(t.join("Empty")).unwrap(); std::fs::write(t.join("Outer/b.fig"), "x").unwrap(); std::fs::write(t.join("Outer/A.fig"), "x").unwrap(); // Windows forbids `"` in file names; elsewhere it tests the escaping. let (quoted, escaped) = if cfg!(windows) { ("q'x", "q'x") } else { ("q\"x", "q\\\"x") }; std::fs::write(t.join(format!("Outer/In ner/{quoted}.fig")), "x").unwrap(); std::fs::write(t.join("Outer/README"), "x").unwrap(); std::fs::write(t.join("top.fig"), "x").unwrap(); let s = libraries_index(&t).unwrap(); let _ = std::fs::remove_dir_all(&t); assert_eq!( s, format!( "{{\"libraries\":[{{\"name\":\"Outer\",\"dir\":\"Outer\",\"figures\":[\"A\",\"b\"]}},\ {{\"name\":\"Outer / In ner\",\"dir\":\"Outer/In ner\",\"figures\":[\"{escaped}\"]}}]}}\n" ) ); } #[test] fn parses_dependencies() { let m = "[package]\nname = \"x\"\n[dependencies]\nfoo.workspace = true\nbar = \"1\"\n[target.'cfg(x)'.dependencies]\nrfd = { workspace = true }\n"; assert_eq!(dependencies(m), vec!["foo", "bar", "rfd"]); } } .