Files
effectcraft/xtask/src/main.rs
T
13470ca161 Add affected CI and replayable crash repros (#1224)
* AG4: xtask ci --affected

cargo xtask ci --affected [--base REV] [--dry-run] runs fmt, clippy, tests (nextest + doctests when available), layers, assets (only on asset changes) and wasm (only for affected L0-L4/web crates) for the packages a change can affect: changed files map to the owning package and to packages whose sources read them via ../ paths, expanded to reverse dependents. Workspace-wide inputs (root Cargo.toml, Cargo.lock, .cargo/, xtask/, toolchain, clippy.toml) fall back to full CI. Documented in docs/testing.md, AGENTS.md and CLAUDE.md.

(cherry picked from commit f3ca84fc2ee3948582cbc94e4c99ee510e829540)

(cherry picked from commit 8a2cdcadf447eafa57ab14016beae4420f002af0)
(cherry picked from commit d8a4301bc1)

(cherry picked from commit 36402c61ac2f7e29ecd76a6cfc89365cc2572e0a)

* AO1: fast CI profile, looser timing tests

- [profile.ci] (release without LTO, 16 codegen units); `xtask ci` runs clippy
  and tests with --profile ci instead of --release.
- Wall-clock asserts in engine memory_tick, gpu OpenGL decline and script
  socket tests keep their intent with bounds that survive a loaded machine.
- docs/testing.md and CONTRIBUTING.md mention the profile.

(cherry picked from commit b45c8a379351ebdfda0d7076ad4b740baf6f2087)

(cherry picked from commit fa1ea6a107c4c1936cde89b518a05adc73f2b4b0)
(cherry picked from commit e5d5ed80e9)

(cherry picked from commit d2c67660c70764f9c56880a642ad7f0d004a7f92)

* AG6: journal replay repros

Bug reports become replay scripts and crash regression tests become data files:
- engine::replay: a JSON-lines repro format (start line, command steps with
  expect_error, expect queries with JSON pointer + equals, expect_no_error),
  a never-panicking runner that reports per-step ok/error and the first failure,
  and journal export (from the last project open; saves left out; size-capped).
- journal.export {path?} and help.exportRepro {path?} (not journaled).
- Crash capture: when the last-resort guard catches a panic in the outermost
  command, the journal plus that command is saved to Repros/crash-*.jsonl in the
  settings store (newest 20 kept) and the error names the file.
- Commands run by a journaled command (script steps) are no longer journaled twice.
- effectcraft-cli replay SCRIPT.jsonl [--json] [--keep-going]: exits 1 on a failed
  step, unmet expectation or panic.
- crates/host/tests/repros.rs replays every tests/repros/*.jsonl; three samples
  from past crash fixes (zero frame rate, Layer Settings with no selection,
  New Comp from Selection with hostile overlap).
- Docs: docs/agents.md "Repro scripts"; AGENTS.md: crash fix regression test =
  a .jsonl in tests/repros.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

(cherry picked from commit c38509d7dafe507856c48c78db65e00b94b0cebc)

(cherry picked from commit e55b56dbf76716adfcbfe4cc773df19a74edc419)
(cherry picked from commit 96a63855a9)

(cherry picked from commit c59351ab3bd697f0868e41230dd883960d94a540)

* xtask: ci and ci --affected run cargo test and nextest with --no-fail-fast

One run now lists every failing crate instead of stopping at the first.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qXL3GPNnY9BSFF2XoU2Yu

(cherry picked from commit e46c7debe738eca62b35bf7392ab77637f6a33ae)

* Agents: confinement write-checks journal.export, help.exportRepro and file.exportTimeline

Review on #859 (split into this PR): their output paths were only read-checked (relative ones
not at all). Test: confinement_covers_every_command_that_writes_a_file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014d65YdNxQmjRkbXRtP2NwH

* #1224: handle bin-only CI targets and BOM repros without weakening timing tests

* #1224: run the full gate when nextest policy changes

---------

Co-authored-by: Elias <hakifx91@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-10 14:57:32 +01:00

654 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Workspace automation: `cargo xtask <layers|assets|pdf-deps|lock-patches|aep-corpus|wasm|web|ico|ci>`.
//!
//! - `layers`: enforces the dependency layering of `docs/architecture.md` §1 (downward-only edges,
//! listed same-layer edges, no UI/OS crates below L5).
//! - `wasm`: `cargo check --target wasm32-unknown-unknown` for every crate in L0–L4, the egui UI
//! and the web app.
//! - `web [--dev] [--serve PORT]`: build the web app (`apps/effectcraft-web`) into
//! `<target>/web/dist` with `wasm-bindgen` (docs/web.md); `--serve` serves it on localhost.
//! - `lock-patches`: `Cargo.lock` records no unused `[patch]` entry (the Nix build cannot vendor one).
//! - `assets`: every asset file (image, icon, font, LUT, audio, video…) has a complete
//! `<file>.attribution` sidecar and an entry in `ATTRIBUTION.md` (AGENTS.md §1).
//! - `aep-corpus [--dir DIR]`: fetch the After Effects test corpus at the commit pinned in
//! `crates/aep/tests/fixtures/corpus.lock`, verify every file's SHA-256, install into `corpus/aep`.
//! - `ico <out.ico> <in.png>…`: pack PNGs into a Windows `.ico` (used by `packaging/icons.sh`).
//! - `ci`: fmt check, clippy -D warnings, tests (`cargo nextest run` when installed) and doctests,
//! PDF dependency policy, lock patches, layers, assets, wasm. Everything builds in the dev profile.
//! - `ci --affected [--base REV] [--dry-run]`: the same gates for only the crates the change since
//! REV (default: the merge-base with `main`) can affect; see `affected.rs` and docs/testing.md.
//! - `bench [--bin CLI] [--quick] [--compare OLD.jsonl] …`: render benchmark of generated scenarios
//! (memory, CPU, wall time) into `target/bench/results.jsonl` (`bench.rs`, docs/testing.md).
mod aep_corpus;
mod affected;
mod bench;
mod ico;
mod lock_patches;
mod pdf_deps;
mod version;
use std::process::{Command, ExitCode};
use serde_json::Value;
/// (crate name without the `effectcraft-` prefix, layer). See `docs/architecture.md` §1.
const LAYERS: &[(&str, u8)] = &[
("time", 0),
("geom", 0),
("color", 0),
("vp9enc", 0),
("testkit", 0),
("opusenc", 0),
("hevcenc", 0),
("av1enc", 0),
("raster", 1),
("keyframe", 1),
("path", 1),
("psd", 1),
("aep", 3),
("segment", 1),
("project", 2),
("text", 2),
("effects", 2),
("track", 2),
("svg", 2),
("pdf", 2),
("model", 2),
("render", 3),
("media", 3),
("expr", 3),
("export", 3),
("gpu", 3),
("sam2", 3),
("lottie", 3),
("format", 3),
("plugin", 3),
("helper-host", 3),
("interchange", 3),
("engine", 4),
("script", 4),
("host", 4),
("ui-egui", 5),
("automation", 5),
("effectcraft", 6),
("cli", 6),
("web", 6),
];
/// Allowed same-layer edges (from, to).
const SAME_LAYER: &[(&str, &str)] = &[
("path", "keyframe"),
("path", "raster"),
("text", "path"),
("pdf", "svg"),
("pdf", "text"),
("effects", "project"),
("effects", "text"),
("effects", "path"),
("effects", "track"),
("media", "render"),
("expr", "render"),
("export", "render"),
("export", "media"),
("gpu", "render"),
("lottie", "format"),
("host", "engine"),
("script", "engine"),
("host", "script"),
("cli", "effectcraft"),
];
/// Crates that must not appear below L5 (UI toolkits, windowing, OS audio/menus).
const UI_ONLY: &[&str] = &["craft-ui", "egui", "eframe", "egui-wgpu", "winit", "rfd", "cpal", "muda"];
fn short(name: &str) -> &str {
name.strip_prefix("effectcraft-").unwrap_or(name)
}
fn layer_of(name: &str) -> Option<u8> {
LAYERS.iter().find(|(n, _)| *n == short(name)).map(|(_, l)| *l)
}
fn metadata() -> Result<Value, String> {
let out = Command::new(env!("CARGO")).args(["metadata", "--format-version", "1", "--no-deps"]).output().map_err(|e| e.to_string())?;
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).into());
}
serde_json::from_slice(&out.stdout).map_err(|e| e.to_string())
}
fn workspace_crates(md: &Value) -> Vec<(String, Vec<String>)> {
md["packages"]
.as_array()
.into_iter()
.flatten()
.filter(|p| p["name"] != "xtask")
.map(|p| {
let deps = p["dependencies"]
.as_array()
.into_iter()
.flatten()
.filter(|d| d["kind"].is_null() || d["kind"] == "build")
.filter_map(|d| d["name"].as_str().map(str::to_string))
.collect();
(p["name"].as_str().unwrap_or_default().to_string(), deps)
})
.collect()
}
fn layers() -> Result<(), String> {
let md = metadata()?;
let mut errors = Vec::new();
for (name, deps) in workspace_crates(&md) {
let Some(l) = layer_of(&name) else {
errors.push(format!("{name}: not assigned a layer (add it to xtask LAYERS and docs/architecture.md §1)"));
continue;
};
for d in &deps {
if l < 5 && UI_ONLY.contains(&d.as_str()) {
errors.push(format!("{name} (L{l}) depends on UI/OS crate `{d}`"));
}
if !d.starts_with("effectcraft-") {
continue;
}
let Some(dl) = layer_of(d) else { continue };
let (a, b) = (short(&name), short(d));
if dl > l {
errors.push(format!("{a} (L{l}) depends upward on {b} (L{dl})"));
} else if dl == l && l > 0 && !SAME_LAYER.contains(&(a, b)) {
errors.push(format!("{a} → {b}: same-layer edge (L{l}) not in the allowed list"));
}
}
}
if errors.is_empty() {
println!("layers: ok");
Ok(())
} else {
Err(errors.join("\n"))
}
}
/// File extensions that count as assets (AGENTS.md §1).
const ASSET_EXT: &[&str] = &[
"png", "jpg", "jpeg", "gif", "webp", "svg", "ico", "icns", "bmp", "tif", "tiff", "heic", "avif", "exr", "ttf", "otf", "ttc", "woff", "woff2", "cube",
"3dl", "lut", "look", "wav", "mp3", "aac", "flac", "ogg", "opus", "m4a", "aif", "aiff", "mp4", "mov", "m4v", "mkv", "webm", "avi", "mxf", "psd", "ai",
"eps", "pdf", "prproj", "ffx", "prfpset", "mogrt", "aep", "aepx",
];
/// Sidecar fields that must be present and non-empty.
const REQUIRED_FIELDS: &[&str] = &["asset", "title", "author", "source", "license", "added"];
fn repo_files() -> Result<Vec<String>, String> {
let out = Command::new("git").args(["ls-files", "--cached", "--others", "--exclude-standard"]).output().map_err(|e| e.to_string())?;
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).into());
}
Ok(String::from_utf8_lossy(&out.stdout).lines().map(str::to_string).filter(|f| std::path::Path::new(f).exists()).collect())
}
fn is_asset(path: &str) -> bool {
std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| ASSET_EXT.contains(&e.to_ascii_lowercase().as_str()))
}
fn assets() -> Result<(), String> {
let files = repo_files()?;
let index = std::fs::read_to_string("ATTRIBUTION.md").map_err(|e| format!("ATTRIBUTION.md: {e}"))?;
let mut errors = Vec::new();
let mut n = 0;
for f in files.iter().filter(|f| is_asset(f)) {
n += 1;
let lower = f.to_ascii_lowercase();
if lower.contains("adobe") || lower.contains("aftereffects") || lower.contains("after-effects") {
errors.push(format!("{f}: asset paths must not reference Adobe/After Effects (AGENTS.md §1)"));
}
let side = format!("{f}.attribution");
match std::fs::read_to_string(&side) {
Err(_) => errors.push(format!("{f}: missing attribution sidecar {side}")),
Ok(text) => {
for field in REQUIRED_FIELDS {
let ok = text.lines().any(|l| l.split_once(':').is_some_and(|(k, v)| k.trim() == *field && !v.trim().is_empty()));
if !ok {
errors.push(format!("{side}: field `{field}` missing or empty"));
}
}
let lic = text.lines().find_map(|l| l.split_once(':').filter(|(k, _)| k.trim() == "license").map(|(_, v)| v.trim().to_ascii_lowercase()));
if lic.is_some_and(|l| l.contains("-nc") || l.contains("-nd") || l.contains("adobe") || l.contains("proprietary")) {
errors.push(format!("{side}: licence not allowed (no NC/ND, Adobe or proprietary licences)"));
}
}
}
if !index.contains(&format!("`{f}`")) {
errors.push(format!("{f}: not listed in ATTRIBUTION.md"));
}
}
for f in files.iter().filter(|f| f.ends_with(".attribution")) {
let asset = f.trim_end_matches(".attribution");
if !std::path::Path::new(asset).exists() {
errors.push(format!("{f}: sidecar for a file that does not exist"));
}
}
if errors.is_empty() {
println!("assets: ok ({n} assets attributed)");
Ok(())
} else {
Err(errors.join("\n"))
}
}
fn run(cmd: &mut Command) -> Result<(), String> {
eprintln!("$ {cmd:?}");
let st = cmd.status().map_err(|e| e.to_string())?;
if st.success() { Ok(()) } else { Err(format!("failed: {cmd:?}")) }
}
/// Crates above L4 that must also build for the web.
const WEB_CRATES: &[&str] = &["effectcraft-ui-egui", "effectcraft-web"];
fn wasm() -> Result<(), String> {
let md = metadata()?;
let mut cmd = Command::new(env!("CARGO"));
cmd.args(["check", "--target", "wasm32-unknown-unknown", "--keep-going"]);
let mut n = 0;
for (name, _) in workspace_crates(&md) {
if layer_of(&name).is_some_and(|l| l <= 4) || WEB_CRATES.contains(&name.as_str()) {
cmd.args(["-p", &name]);
n += 1;
}
}
if n == 0 {
return Ok(());
}
run(&mut cmd)?;
println!("wasm: ok ({n} crates)");
Ok(())
}
/// The wasm-bindgen CLI must match the `wasm-bindgen` crate version exactly.
const WASM_BINDGEN: &str = "0.2.129";
fn target_dir() -> std::path::PathBuf {
std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| std::path::PathBuf::from("target"))
}
/// Build the web app into `<target>/web/dist` (see docs/web.md).
fn web(args: &[String]) -> Result<(), String> {
let dev = args.iter().any(|a| a == "--dev");
let serve = args.iter().position(|a| a == "--serve").map(|i| args.get(i + 1).and_then(|p| p.parse::<u16>().ok()).unwrap_or(8765));
let profile = if dev { "dev" } else { "release" };
let mut build = Command::new(env!("CARGO"));
build.args(["build", "--target", "wasm32-unknown-unknown", "-p", "effectcraft-web", "--profile", profile]);
run(&mut build)?;
let out = Command::new("wasm-bindgen")
.arg("--version")
.output()
.map_err(|_| format!("wasm-bindgen CLI not found: cargo install wasm-bindgen-cli --version {WASM_BINDGEN} --locked"))?;
let v = String::from_utf8_lossy(&out.stdout);
if !v.contains(WASM_BINDGEN) {
return Err(format!(
"wasm-bindgen CLI is {} but the crate is {WASM_BINDGEN}: cargo install wasm-bindgen-cli --version {WASM_BINDGEN} --locked",
v.trim()
));
}
let dir = if dev { "debug" } else { "release" };
let wasm = target_dir().join("wasm32-unknown-unknown").join(dir).join("effectcraft_web.wasm");
let dist = target_dir().join("web").join("dist");
let _ = std::fs::remove_dir_all(&dist);
std::fs::create_dir_all(&dist).map_err(|e| e.to_string())?;
run(Command::new("wasm-bindgen").args(["--target", "web", "--no-typescript", "--out-dir"]).arg(&dist).arg(&wasm))?;
let bg = dist.join("effectcraft_web_bg.wasm");
if !dev && Command::new("wasm-opt").arg("--version").output().is_ok() {
// optional: smaller and faster (binaryen); skipped when not installed
let opt = dist.join("effectcraft_web_opt.wasm");
run(Command::new("wasm-opt")
.args(["-O2", "--enable-bulk-memory", "--enable-nontrapping-float-to-int", "--enable-sign-ext", "--enable-mutable-globals"])
.arg(&bg)
.arg("-o")
.arg(&opt))?;
std::fs::rename(&opt, &bg).map_err(|e| e.to_string())?;
}
let web = std::path::Path::new("apps/effectcraft-web/web");
for e in std::fs::read_dir(web).map_err(|e| e.to_string())?.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if !name.ends_with(".attribution") {
std::fs::copy(e.path(), dist.join(&name)).map_err(|e| format!("{name}: {e}"))?;
}
}
// PWA icons: the app icon (assets/app-icon, attributed there).
for n in [256, 512] {
let src = format!("assets/app-icon/hicolor/{n}x{n}/apps/ai.storyteller.effectcraft.png");
std::fs::copy(&src, dist.join(format!("icon-{n}.png"))).map_err(|e| format!("{src}: {e}"))?;
}
service_worker(&dist)?;
let size = |p: &std::path::Path| std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
let total: u64 = dist_files(&dist).iter().map(|f| size(&dist.join(f))).sum();
println!("web: {} ({:.1} MB wasm, {:.1} MB total)", dist.display(), size(&bg) as f64 / 1e6, total as f64 / 1e6);
if let Some(port) = serve {
serve_dir(&dist, port)?;
}
Ok(())
}
/// Every file under `dist`, as `/`-separated relative paths, sorted.
fn dist_files(dist: &std::path::Path) -> Vec<String> {
fn walk(dir: &std::path::Path, rel: &str, out: &mut Vec<String>) {
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let name = e.file_name().to_string_lossy().to_string();
let r = if rel.is_empty() { name } else { format!("{rel}/{name}") };
if e.path().is_dir() {
walk(&e.path(), &r, out);
} else {
out.push(r);
}
}
}
let mut v = vec![];
walk(dist, "", &mut v);
v.sort();
v
}
/// Fill the service worker's precache list and version (a hash of the build's files).
fn service_worker(dist: &std::path::Path) -> Result<(), String> {
let sw = dist.join("sw.js");
let text = std::fs::read_to_string(&sw).map_err(|e| format!("sw.js: {e}"))?;
let files: Vec<String> = dist_files(dist).into_iter().filter(|f| f != "sw.js").collect();
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for f in &files {
for b in f.bytes().chain(std::fs::read(dist.join(f)).map_err(|e| format!("{f}: {e}"))?) {
h ^= b as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
}
let list: Vec<String> = std::iter::once("./".to_string()).chain(files.iter().map(|f| format!("./{f}"))).map(|f| format!("{f:?}")).collect();
let text = text.replace("__EC_VERSION__", &format!("{h:016x}")).replace("__EC_FILES__", &format!("[{}]", list.join(", ")));
std::fs::write(&sw, text).map_err(|e| format!("sw.js: {e}"))
}
/// A tiny static file server for the web build (localhost only). Sends the cross-origin
/// isolation headers so `crossOriginIsolated` is true, like a production deployment would.
fn serve_dir(dir: &std::path::Path, port: u16) -> Result<(), String> {
use std::io::{BufRead, BufReader, Write};
let listener = std::net::TcpListener::bind(("127.0.0.1", port)).map_err(|e| format!("127.0.0.1:{port}: {e}"))?;
println!("serving {} on http://127.0.0.1:{port}/", dir.display());
for stream in listener.incoming().flatten() {
let dir = dir.to_path_buf();
std::thread::spawn(move || {
let mut stream = stream;
let mut line = String::new();
let mut reader = BufReader::new(match stream.try_clone() {
Ok(s) => s,
Err(_) => return,
});
if reader.read_line(&mut line).is_err() {
return;
}
loop {
let mut h = String::new();
if reader.read_line(&mut h).map(|n| n == 0).unwrap_or(true) || h.trim().is_empty() {
break;
}
}
let path = line.split_whitespace().nth(1).unwrap_or("/").split(['?', '#']).next().unwrap_or("/").to_string();
let rel = if path == "/" { "index.html".to_string() } else { path.trim_start_matches('/').replace("..", "") };
let file = dir.join(&rel);
let (status, body) = match std::fs::read(&file) {
Ok(b) => ("200 OK", b),
Err(_) => ("404 Not Found", b"not found".to_vec()),
};
let mime = match file.extension().and_then(std::ffi::OsStr::to_str).unwrap_or("") {
"html" => "text/html; charset=utf-8",
"js" => "text/javascript",
"wasm" => "application/wasm",
"svg" => "image/svg+xml",
"json" | "webmanifest" => "application/json",
"png" => "image/png",
"gif" => "image/gif",
"ecproj" => "application/json",
"mp4" => "video/mp4",
"webm" => "video/webm",
_ => "application/octet-stream",
};
let head = format!(
"HTTP/1.1 {status}\r\nContent-Type: {mime}\r\nContent-Length: {}\r\nCross-Origin-Opener-Policy: same-origin\r\nCross-Origin-Embedder-Policy: require-corp\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = stream.write_all(head.as_bytes()).and_then(|_| stream.write_all(&body));
});
}
Ok(())
}
/// The guidance printed when the ALSA pkg-config package is missing. Split out so the tests can
/// check the package names without needing a machine that is actually missing them.
#[cfg(target_os = "linux")]
fn audio_backend_missing_hint(pkg_config_stderr: Option<&str>) -> String {
let mut hint = String::from(
"Linux audio needs the ALSA development package, and pkg-config cannot find it.\n\
The desktop app plays preview sound through cpal, whose alsa-sys dependency asks\n\
pkg-config for `alsa`; without the package the build dies minutes later with an\n\
unrelated-looking linker error.\n\
\n\
Fedora: sudo dnf install alsa-lib-devel\n\
Debian/Ubuntu: sudo apt install libasound2-dev\n\
Arch: sudo pacman -S alsa-lib\n\
\n\
Install one of those and run `cargo xtask ci` again. If you cannot install it, point\n\
PKG_CONFIG_PATH at a directory holding an `alsa.pc` whose Libs line names your system's\n\
libasound (usually `libasound.so.2`); every CI run will need that variable set.",
);
if let Some(stderr) = pkg_config_stderr {
hint.push_str("\n\npkg-config said: ");
hint.push_str(stderr);
}
hint
}
/// `cargo xtask ci` builds the desktop app on the host, so on Linux it needs a usable ALSA
/// pkg-config package before the build dies minutes later at link time. The probe goes through
/// the same `pkg-config` crate alsa-sys's build script uses, so the executable and search-path
/// lookup match by construction: `PKG_CONFIG` and its target-scoped variants, then `pkg-config`
/// on `PATH` with a `pkgconf` fallback, plus the scoped `PKG_CONFIG_PATH`/`LIBDIR`/`SYSROOT_DIR`.
/// A probe that ran and said no is "ALSA missing"; one that could not run is reported separately,
/// so a machine with only `pkgconf` does not fail a build that would succeed.
#[cfg(target_os = "linux")]
fn audio_backend() -> Result<(), String> {
// cargo_metadata off: xtask is not a build script, so the crate's rerun-if-env lines would
// only clutter the ci log.
match pkg_config::Config::new().cargo_metadata(false).probe("alsa") {
Ok(_) => Ok(()),
Err(pkg_config::Error::ProbeFailure { output, .. }) | Err(pkg_config::Error::Failure { output, .. }) => {
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr = stderr.trim();
Err(audio_backend_missing_hint((!stderr.is_empty()).then_some(stderr)))
}
Err(e) => Err(format!(
"The pkg-config probe for `alsa` could not be run: {e}. Install pkg-config (or pkgconf), \
or point PKG_CONFIG at a working one — it is the same lookup alsa-sys's pkg-config \
dependency performs."
)),
}
}
#[cfg(not(target_os = "linux"))]
fn audio_backend() -> Result<(), String> {
Ok(())
}
/// Lib, bin and integration tests (examples aren't linked; clippy checked them), then doctests,
/// which nextest doesn't run.
///
/// `effectcraft-gpu`'s tests share one device and serialise on a lock inside their process (several
/// devices at once crash software Vulkan and GL), so they run in one `cargo test` process instead of
/// nextest's process per test.
///
/// Each run goes ahead when an earlier one fails; the error names every run that failed.
fn tests() -> Result<(), String> {
let nextest = Command::new(env!("CARGO")).args(["nextest", "--version"]).output().is_ok_and(|o| o.status.success());
tests_for(&[], nextest, true, true)
}
/// Keep the full and affected gates on the same test runner, including GPU isolation and the
/// translation report. An empty package list selects the whole workspace.
fn tests_for(packages: &[String], nextest: bool, has_lib: bool, doctests: bool) -> Result<(), String> {
let cargo = env!("CARGO");
let selection = test_selection(packages);
let includes = |name: &str| packages.is_empty() || packages.iter().any(|p| p == name);
if !nextest {
println!("cargo-nextest not found, running cargo test (install: cargo install cargo-nextest --locked)");
}
let mut runs = vec![run(&mut test_command(packages, nextest, has_lib))];
if nextest && includes("effectcraft-gpu") {
// GPU tests share one device, so reuse the selected build in one libtest process.
runs.push(lib_test_binary("effectcraft-gpu", &selection).and_then(|mut c| run(&mut c)));
}
// The test runs above hide a passing test's output, so the always-passing report runs again
// with --nocapture, from the binary already built.
if includes("effectcraft-ui-egui") {
runs.push(lib_test_binary("effectcraft-ui-egui", &selection).and_then(|mut c| run(c.args(["missing_translations_report", "--nocapture"]))));
}
if doctests {
runs.push(run(Command::new(cargo).arg("test").args(&selection).args(["--doc", "--no-fail-fast"])));
}
let failed: Vec<String> = runs.into_iter().filter_map(Result::err).collect();
if failed.is_empty() { Ok(()) } else { Err(failed.join("; ")) }
}
/// Select library tests only when the selected packages contain a library; bin-only package
/// sets otherwise fail before running any tests. The full workspace always has library targets.
fn test_command(packages: &[String], nextest: bool, has_lib: bool) -> Command {
let mut c = Command::new(env!("CARGO"));
if nextest {
c.args(["nextest", "run"]);
// A selected package can have no tests, or only GPU tests that run separately.
if !packages.is_empty() {
c.arg("--no-tests=pass");
}
} else {
c.arg("test");
}
c.args(test_selection(packages));
if packages.is_empty() || has_lib {
c.arg("--lib");
}
c.args(["--bins", "--tests", "--no-fail-fast"]);
if nextest {
c.args(["-E", "not package(effectcraft-gpu)"]);
}
c
}
fn test_selection(packages: &[String]) -> Vec<String> {
if packages.is_empty() { vec!["--workspace".into()] } else { packages.iter().flat_map(|p| ["-p".into(), p.clone()]).collect() }
}
/// A command running `package`'s lib test binary from the test build above, the way `cargo test`
/// runs it (from the package directory), found with `--no-run` and the same package selection
/// so nothing rebuilds: `cargo test -p <package>` resolves features for that package alone and
/// recompiles much of the workspace.
fn lib_test_binary(package: &str, selection: &[String]) -> Result<Command, String> {
let out = Command::new(env!("CARGO"))
.arg("test")
.args(selection)
.args(["--lib", "--bins", "--tests", "--no-run", "--message-format=json-render-diagnostics"])
.stderr(std::process::Stdio::inherit())
.output()
.map_err(|e| format!("cargo test --no-run: {e}"))?;
if !out.status.success() {
return Err(format!("cargo test --no-run: exited with {}", out.status));
}
let lib = package.replace('-', "_");
String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.find(|m| {
m["reason"] == "compiler-artifact"
&& m["profile"]["test"] == true
&& m["target"]["name"] == lib.as_str()
&& m["target"]["kind"].as_array().is_some_and(|k| k.iter().any(|k| k == "lib"))
})
.and_then(|m| {
let mut c = Command::new(m["executable"].as_str()?);
c.current_dir(std::path::Path::new(m["manifest_path"].as_str()?).parent()?);
Some(c)
})
.ok_or_else(|| format!("no {package} lib test binary in the test build"))
}
type Stage = fn() -> Result<(), String>;
/// Every stage runs even when an earlier one fails, so one run reports all the problems.
fn ci() -> Result<(), String> {
let stages: [(&str, Stage); 9] = [
("audio backend", audio_backend),
("fmt", || run(Command::new(env!("CARGO")).args(["fmt", "--check"]))),
("clippy", || run(Command::new(env!("CARGO")).args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]))),
("tests", tests),
("pdf-deps", || pdf_deps::run(&root())),
("lock-patches", || lock_patches::run(&root())),
("layers", layers),
("assets", assets),
("wasm", wasm),
];
let failed: Vec<&str> = stages.iter().filter_map(|(name, stage)| stage().map_err(|e| eprintln!("ci: {name} failed: {e}")).err().map(|()| *name)).collect();
if failed.is_empty() { Ok(()) } else { Err(format!("ci failed: {}", failed.join(", "))) }
}
fn main() -> ExitCode {
let task = std::env::args().nth(1).unwrap_or_default();
let r = match task.as_str() {
"layers" => layers(),
"wasm" => wasm(),
"assets" => assets(),
"pdf-deps" => pdf_deps::run(&root()),
"lock-patches" => lock_patches::run(&root()),
"aep-corpus" => aep_corpus::run(&root(), &std::env::args().skip(2).collect::<Vec<_>>()),
"ci" => {
let rest: Vec<String> = std::env::args().skip(2).collect();
if rest.iter().any(|a| a == "--affected") { affected::run(&root(), &rest) } else { ci() }
}
"audio-backend" => audio_backend(),
"web" => web(&std::env::args().skip(2).collect::<Vec<_>>()),
"bench" => bench::run(&root(), &std::env::args().skip(2).collect::<Vec<_>>()),
"ico" => {
let rest: Vec<String> = std::env::args().skip(2).collect();
ico::run(&rest.iter().map(String::as_str).collect::<Vec<_>>())
}
"version" => {
let rest: Vec<String> = std::env::args().skip(2).collect();
version::run(&root(), &rest.iter().map(String::as_str).collect::<Vec<_>>())
}
_ => Err("usage: cargo xtask <layers|assets|pdf-deps|lock-patches|aep-corpus [--dir DIR]|wasm|web [--dev] [--serve PORT]|ico OUT.ico IN.png…|version [set X.Y.Z]|ci [--affected [--base REV] [--dry-run]]|audio-backend|bench [--bin CLI] [--quick] [--project F --comp C] [--compare OLD.jsonl] (--help)>".into()),
};
match r {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("{e}");
ExitCode::FAILURE
}
}
}
/// The workspace root (the parent of `xtask/`).
pub fn root() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent().map(std::path::Path::to_path_buf).unwrap_or_else(|| std::path::PathBuf::from("."))
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::audio_backend_missing_hint;
#[test]
fn alsa_missing_names_the_package_for_each_distro() {
let e = audio_backend_missing_hint(None);
for want in ["alsa-lib-devel", "libasound2-dev", "alsa-lib", "pkg-config", "PKG_CONFIG_PATH"] {
assert!(e.contains(want), "hint lost `{want}`:\n{e}");
}
}
#[test]
fn alsa_missing_appends_pkg_config_s_own_detail() {
let e = audio_backend_missing_hint(Some("Package 'alsa' not found"));
assert!(e.contains("pkg-config said: Package 'alsa' not found"), "detail lost:\n{e}");
}
}