252 lines
8.9 KiB
Rust
252 lines
8.9 KiB
Rust
//! Workspace automation: `cargo xtask <layers|assets|wasm|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 and the egui UI.
|
||
//! - `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).
|
||
//! - `ci`: fmt check, clippy -D warnings, tests, layers, assets, wasm.
|
||
|
||
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),
|
||
("testkit", 0),
|
||
("raster", 1),
|
||
("keyframe", 1),
|
||
("path", 1),
|
||
("project", 2),
|
||
("text", 2),
|
||
("effects", 2),
|
||
("render", 3),
|
||
("media", 3),
|
||
("expr", 3),
|
||
("export", 3),
|
||
("gpu", 3),
|
||
("lottie", 3),
|
||
("format", 3),
|
||
("engine", 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"),
|
||
("effects", "project"),
|
||
("effects", "text"),
|
||
("effects", "path"),
|
||
("media", "render"),
|
||
("expr", "render"),
|
||
("export", "render"),
|
||
("export", "media"),
|
||
("gpu", "render"),
|
||
("lottie", "format"),
|
||
("host", "engine"),
|
||
("cli", "effectcraft"),
|
||
];
|
||
|
||
/// Crates that must not appear below L5 (UI toolkits, windowing, OS audio/menus).
|
||
const UI_ONLY: &[&str] = &["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",
|
||
];
|
||
|
||
/// 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(|e| e.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"];
|
||
|
||
fn wasm() -> Result<(), String> {
|
||
let md = metadata()?;
|
||
let mut cmd = Command::new(env!("CARGO"));
|
||
cmd.args(["check", "--target", "wasm32-unknown-unknown"]);
|
||
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(())
|
||
}
|
||
|
||
fn ci() -> Result<(), String> {
|
||
let cargo = env!("CARGO");
|
||
run(Command::new(cargo).args(["fmt", "--check"]))?;
|
||
run(Command::new(cargo).args(["clippy", "--workspace", "--all-targets", "--release", "--", "-D", "warnings"]))?;
|
||
run(Command::new(cargo).args(["test", "--workspace", "--release"]))?;
|
||
layers()?;
|
||
assets()?;
|
||
wasm()
|
||
}
|
||
|
||
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(),
|
||
"ci" => ci(),
|
||
_ => Err("usage: cargo xtask <layers|assets|wasm|ci>".into()),
|
||
};
|
||
match r {
|
||
Ok(()) => ExitCode::SUCCESS,
|
||
Err(e) => {
|
||
eprintln!("{e}");
|
||
ExitCode::FAILURE
|
||
}
|
||
}
|
||
}
|