264 lines
9.9 KiB
Rust
264 lines
9.9 KiB
Rust
//! Project tasks: `cargo xtask <task>`.
|
|
//!
|
|
//! - `ci` fmt check, clippy -D warnings, tests, layers, assets, wasm check
|
|
//! - `layers` crate dependency layering check
|
|
//! - `assets` every file under assets/ and docs/ has a row in ATTRIBUTION.md
|
|
//! - `parity` menu-catalog coverage → docs/parity-checklist.md
|
|
//! - `version` print the version, or `version set X.Y.Z`
|
|
//! - `icon` regenerate the app icon PNGs/ICO from code (original artwork)
|
|
//! - `wasm` check the core crates and web app for wasm32, and build the plugin SDK and examples
|
|
//! - `plugins` format-check, lint, test and build the plugin SDK and example plugins (`plugins/`, a
|
|
//! workspace of its own) for wasm32: `<target>/plugins/dist/<example>/` holds each manifest and
|
|
//! module, ready for Plugins ▸ Development ▸ Import plugin from manifest…
|
|
//! - `bench` release-build timings at 1k/10k/50k layers (`bench 1000,10000` for other sizes) and
|
|
//! with a synthetic design system used by 2,000 instances
|
|
|
|
mod icon;
|
|
mod layers;
|
|
|
|
use std::process::{Command, ExitCode};
|
|
|
|
fn run(cmd: &mut Command) -> bool {
|
|
eprintln!("$ {cmd:?}");
|
|
cmd.status().map(|s| s.success()).unwrap_or(false)
|
|
}
|
|
|
|
fn cargo() -> Command {
|
|
Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()))
|
|
}
|
|
|
|
fn root() -> std::path::PathBuf {
|
|
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent().map(|p| p.to_path_buf()).unwrap_or_default()
|
|
}
|
|
|
|
fn main() -> ExitCode {
|
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
|
let task = args.first().map(String::as_str).unwrap_or("help");
|
|
let ok = match task {
|
|
"ci" => ci(),
|
|
"layers" => layers::check(&root()),
|
|
"assets" => assets(),
|
|
"parity" => parity(),
|
|
"version" => version(args.get(1).map(String::as_str), args.get(2).map(String::as_str)),
|
|
"icon" => icon::generate(&root()),
|
|
"wasm" => wasm(),
|
|
"plugins" => plugins(),
|
|
"bench" => bench(args.get(1).map_or("1000,10000,50000", String::as_str)),
|
|
_ => {
|
|
eprintln!("tasks: ci, layers, assets, parity, version [set X.Y.Z], icon, wasm, plugins, bench [SIZES]");
|
|
task == "help"
|
|
}
|
|
};
|
|
if ok { ExitCode::SUCCESS } else { ExitCode::FAILURE }
|
|
}
|
|
|
|
fn ci() -> bool {
|
|
run(cargo().args(["fmt", "--all", "--check"]))
|
|
&& run(cargo().args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]))
|
|
&& run(cargo().args(["test", "--workspace"]))
|
|
&& layers::check(&root())
|
|
&& assets()
|
|
&& wasm()
|
|
}
|
|
|
|
fn wasm() -> bool {
|
|
run(cargo().args([
|
|
"check",
|
|
"--target",
|
|
"wasm32-unknown-unknown",
|
|
"-p",
|
|
"figcraft-collab",
|
|
"-p",
|
|
"figcraft-collab-net",
|
|
"-p",
|
|
"figcraft-engine",
|
|
"-p",
|
|
"figcraft-ui-egui",
|
|
"-p",
|
|
"figcraft-web",
|
|
])) && plugins()
|
|
}
|
|
|
|
/// The example plugins in `plugins/examples` (each folder's name is its package; its module is
|
|
/// `<name with _>.wasm`).
|
|
const EXAMPLE_PLUGINS: &[&str] = &["tidy-names", "swatch-grid", "counter"];
|
|
|
|
/// The plugin workspace: fmt, clippy and build for wasm32, host tests, then each example's manifest
|
|
/// and module side by side in `<target>/plugins/dist/<example>/`.
|
|
fn plugins() -> bool {
|
|
let root = root();
|
|
let manifest = root.join("plugins").join("Cargo.toml");
|
|
let target = std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| root.join("target")).join("plugins");
|
|
let ws = |args: &[&str]| {
|
|
let mut c = cargo();
|
|
c.args(args).arg("--manifest-path").arg(&manifest);
|
|
c
|
|
};
|
|
let wasm = ["--target", "wasm32-unknown-unknown", "--locked"];
|
|
let ok = run(&mut ws(&["fmt", "--all", "--check"]))
|
|
&& run(ws(&["clippy", "--workspace", "--release"]).args(wasm).arg("--target-dir").arg(&target).args(["--", "-D", "warnings"]))
|
|
&& run(ws(&["test", "--workspace", "--locked"]).arg("--target-dir").arg(&target))
|
|
&& run(ws(&["build", "--workspace", "--release"]).args(wasm).arg("--target-dir").arg(&target));
|
|
if !ok {
|
|
return false;
|
|
}
|
|
for name in EXAMPLE_PLUGINS {
|
|
let module = format!("{}.wasm", name.replace('-', "_"));
|
|
let dist = target.join("dist").join(name);
|
|
let copied = std::fs::create_dir_all(&dist)
|
|
.and_then(|()| std::fs::copy(root.join("plugins/examples").join(name).join("figcraft-plugin.json"), dist.join("figcraft-plugin.json")))
|
|
.and_then(|_| std::fs::copy(target.join("wasm32-unknown-unknown/release").join(&module), dist.join(&module)));
|
|
match copied {
|
|
Ok(bytes) => eprintln!("plugins: {} ({} KB)", dist.display(), bytes / 1024),
|
|
Err(e) => {
|
|
eprintln!("plugins: {name}: {e}");
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
/// Engine/render timings (`figcraft-cli perf`) and UI frame timings (an ignored test driving the
|
|
/// whole UI headless), release builds, for each comma-separated document size in layers.
|
|
fn bench(sizes: &str) -> bool {
|
|
run(cargo().args(["run", "--release", "-p", "figcraft-cli", "--", "perf", "--layers", sizes]))
|
|
&& run(cargo().env("FIGCRAFT_BENCH_LAYERS", sizes).args([
|
|
"test",
|
|
"--release",
|
|
"-p",
|
|
"figcraft-ui-egui",
|
|
"--lib",
|
|
"perf_ui_frames",
|
|
"--",
|
|
"--ignored",
|
|
"--nocapture",
|
|
]))
|
|
}
|
|
|
|
/// Every asset file must be listed in ATTRIBUTION.md.
|
|
fn assets() -> bool {
|
|
let root = root();
|
|
let attribution = std::fs::read_to_string(root.join("ATTRIBUTION.md")).unwrap_or_default();
|
|
let mut missing = Vec::new();
|
|
for dir in ["assets", "docs/brand", "docs/images"] {
|
|
walk(&root.join(dir), &mut |p| {
|
|
let rel = p.strip_prefix(&root).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_default();
|
|
let name = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default();
|
|
if name.starts_with('.') || name.ends_with(".md") || name.ends_with(".txt") {
|
|
return;
|
|
}
|
|
// Listed by path, or by any ancestor directory written as `dir/`.
|
|
let mut listed = attribution.contains(&format!("`{rel}`"));
|
|
let mut cur = rel.as_str();
|
|
while let Some((parent, _)) = cur.rsplit_once('/') {
|
|
if attribution.contains(&format!("`{parent}/`")) {
|
|
listed = true;
|
|
break;
|
|
}
|
|
cur = parent;
|
|
}
|
|
if !listed {
|
|
missing.push(rel);
|
|
}
|
|
});
|
|
}
|
|
if !missing.is_empty() {
|
|
eprintln!("assets without an ATTRIBUTION.md row:\n {}", missing.join("\n "));
|
|
return false;
|
|
}
|
|
eprintln!("assets: ok");
|
|
true
|
|
}
|
|
|
|
fn walk(dir: &std::path::Path, f: &mut dyn FnMut(&std::path::Path)) {
|
|
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
|
for e in rd.flatten() {
|
|
let p = e.path();
|
|
if p.is_dir() {
|
|
walk(&p, f);
|
|
} else {
|
|
f(&p);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parity() -> bool {
|
|
let extra = [
|
|
"file.open",
|
|
"file.import",
|
|
"file.placeImage",
|
|
"file.save",
|
|
"file.saveAs",
|
|
"file.export",
|
|
"file.exportSvg",
|
|
"edit.copyAsText",
|
|
"edit.copyAsCss",
|
|
"edit.copyAsSvg",
|
|
"edit.copyAsPng",
|
|
"edit.copyLink",
|
|
];
|
|
let (done, total, missing) = figcraft_engine::catalog::parity(&extra);
|
|
let pct = done as f64 * 100.0 / total.max(1) as f64;
|
|
// The generation date: the date of the last commit (no clock dependency in xtask).
|
|
let date = Command::new("git")
|
|
.args(["log", "-1", "--format=%cs"])
|
|
.current_dir(root())
|
|
.output()
|
|
.ok()
|
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
|
.map(|d| d.trim().to_string())
|
|
.unwrap_or_default();
|
|
let mut md = format!(
|
|
"# Parity checklist\n\n> **Generated:** {date} by `cargo xtask parity`. Do not edit by hand.\n\nFigCraft commands registered for the incumbent's main menu and toolbar ({} commands in the engine).\n\n**{done} / {total} menu items ({pct:.1}%)**\n\n",
|
|
figcraft_engine::all_commands().len()
|
|
);
|
|
if missing.is_empty() {
|
|
md.push_str("Every catalog item has a command.\n");
|
|
} else {
|
|
md.push_str("## Missing\n\n");
|
|
for m in &missing {
|
|
md.push_str(&format!("- `{m}`\n"));
|
|
}
|
|
}
|
|
md.push_str("\nMenu coverage measures breadth only; see docs/target-app-parity.md for depth and fidelity.\n");
|
|
let path = root().join("docs/parity-checklist.md");
|
|
if let Err(e) = std::fs::write(&path, md) {
|
|
eprintln!("{}: {e}", path.display());
|
|
return false;
|
|
}
|
|
eprintln!("parity: {done}/{total} ({pct:.1}%) → docs/parity-checklist.md");
|
|
true
|
|
}
|
|
|
|
fn version(sub: Option<&str>, value: Option<&str>) -> bool {
|
|
let path = root().join("Cargo.toml");
|
|
let Ok(text) = std::fs::read_to_string(&path) else { return false };
|
|
let cur = text
|
|
.lines()
|
|
.skip_while(|l| !l.starts_with("[workspace.package]"))
|
|
.find_map(|l| l.strip_prefix("version = \"").and_then(|r| r.strip_suffix('"')))
|
|
.unwrap_or("0.0.0")
|
|
.to_string();
|
|
match (sub, value) {
|
|
(Some("set"), Some(v)) => {
|
|
let valid =
|
|
v.split(['-', '+']).next().is_some_and(|core| core.split('.').count() == 3 && core.split('.').all(|p| p.parse::<u64>().is_ok()));
|
|
if !valid {
|
|
eprintln!("version must look like 1.2.3 or 1.2.3-rc.1");
|
|
return false;
|
|
}
|
|
let new = text.replacen(&format!("version = \"{cur}\""), &format!("version = \"{v}\""), 1);
|
|
if std::fs::write(&path, new).is_err() {
|
|
return false;
|
|
}
|
|
eprintln!("version {cur} → {v}");
|
|
run(cargo().args(["update", "--workspace"]))
|
|
}
|
|
_ => {
|
|
println!("{cur}");
|
|
true
|
|
}
|
|
}
|
|
}
|