diff --git a/xtask/src/affected.rs b/xtask/src/affected.rs new file mode 100644 index 0000000..24efe28 --- /dev/null +++ b/xtask/src/affected.rs @@ -0,0 +1,368 @@ +//! `cargo xtask affected --base `: which workspace crates a change touches, and which CI +//! jobs it needs (docs/releasing.md, "What CI runs when"). +//! +//! A changed file belongs to the package whose directory holds it (from `cargo metadata`). A +//! file outside every package (an oracle case under `tests/`, an icon under `assets/`) belongs to +//! each package whose Rust sources name its top-level directory as a path (`"../../tests/…"`, +//! `"docs/features.md"`), the way they read it. The affected set is those packages and every +//! workspace package that depends on one of them, through any kind of dependency. +//! +//! Flags: +//! - `everything`: the workspace manifest, the lock file, the toolchain, xtask or the CI +//! workflows changed, or a path under `crates/` or `apps/` belongs to no package: run it all. +//! - `solver`: the affected set holds the oracle's package or a package with heavy tests +//! (`#[ignore]`d, run by `cargo xtask test --heavy`); the heavy and oracle jobs run. +//! - `wasm`: the affected set holds a crate the wasm check builds (below the apps, and the web app). +//! +//! The rules work on a small model ([`Workspace`]) so they are unit-tested without git or cargo. + +use std::collections::BTreeSet; +use std::path::Path; +use std::process::Command; + +use serde_json::Value; + +use crate::root; + +/// The package whose `oracle` binary `cargo xtask oracle` runs. +pub const ORACLE_PACKAGE: &str = "fieldcraft-testkit"; + +/// Paths whose change runs everything: they reach every crate or the gate itself. A path matches +/// when it equals an entry or lies under an entry ending in `/`. +const EVERYTHING: &[&str] = + &["Cargo.toml", "Cargo.lock", "rust-toolchain", "rust-toolchain.toml", ".cargo/", "clippy.toml", "rustfmt.toml", "xtask/", ".github/"]; + +/// Directories that hold only packages: a file there outside every package is a crate being +/// added, removed or moved, so the whole workspace runs. +const PACKAGE_ROOTS: &[&str] = &["crates/", "apps/"]; + +/// A workspace package, as the rules see it. +#[derive(Debug, Clone)] +pub struct Package { + pub name: String, + /// Its directory relative to the workspace root, with `/` separators and no trailing one. + pub dir: String, + /// The workspace packages it depends on (normal, dev and build). + pub deps: Vec, + /// It has `#[ignore]`d (heavy) tests. + pub heavy: bool, + /// The top-level repository entries (`tests`, `assets`, `docs`, …) its Rust sources name. + pub reads: BTreeSet, + /// The wasm check builds it. + pub wasm: bool, +} + +#[derive(Debug, Clone, Default)] +pub struct Workspace { + pub packages: Vec, +} + +/// What a change needs. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Plan { + /// Affected packages, sorted. Every package when `everything`. + pub crates: BTreeSet, + pub everything: bool, + pub solver: bool, + pub wasm: bool, + /// Why `everything` is set: the first path that set it. + pub reason: Option, +} + +impl Workspace { + fn owner(&self, file: &str) -> Option<&Package> { + self.packages.iter().filter(|p| file.starts_with(&format!("{}/", p.dir))).max_by_key(|p| p.dir.len()) + } + + /// The plan for a list of changed paths (relative to the workspace root, `/` separators). + pub fn plan(&self, changed: &[String]) -> Plan { + let mut plan = Plan::default(); + let mut direct = BTreeSet::new(); + for file in changed { + if EVERYTHING.iter().any(|e| if e.ends_with('/') { file.starts_with(e) } else { file == e }) { + plan.everything = true; + plan.reason.get_or_insert_with(|| file.clone()); + continue; + } + if let Some(p) = self.owner(file) { + direct.insert(p.name.clone()); + continue; + } + if PACKAGE_ROOTS.iter().any(|r| file.starts_with(r)) { + plan.everything = true; + plan.reason.get_or_insert_with(|| file.clone()); + continue; + } + let top = file.split('/').next().unwrap_or(file); + direct.extend(self.packages.iter().filter(|p| p.reads.contains(top)).map(|p| p.name.clone())); + } + plan.crates = if plan.everything { self.packages.iter().map(|p| p.name.clone()).collect() } else { self.dependents(direct) }; + let selected = |p: &&Package| plan.crates.contains(&p.name); + plan.solver = plan.everything || self.packages.iter().filter(selected).any(|p| p.heavy || p.name == ORACLE_PACKAGE); + plan.wasm = plan.everything || self.packages.iter().filter(selected).any(|p| p.wasm); + plan + } + + /// `set` and every package that depends on one in it, directly or not. + fn dependents(&self, mut set: BTreeSet) -> BTreeSet { + loop { + let more: Vec = + self.packages.iter().filter(|p| !set.contains(&p.name) && p.deps.iter().any(|d| set.contains(d))).map(|p| p.name.clone()).collect(); + if more.is_empty() { + return set; + } + set.extend(more); + } + } +} + +/// The workspace from `cargo metadata` (the no-deps form [`crate::metadata`] returns), reading +/// each package's Rust sources for heavy tests and the top-level paths they name. +pub fn workspace(meta: &Value) -> Result { + let root = root(); + let tops: Vec = std::fs::read_dir(&root) + .map_err(|e| format!("read {}: {e}", root.display()))? + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n != "target" && !n.starts_with("target-")) + .collect(); + let pkgs = meta["packages"].as_array().ok_or("metadata: no packages array")?; + let members: BTreeSet<&str> = pkgs.iter().filter_map(|p| p["name"].as_str()).collect(); + let mut packages = Vec::new(); + for p in pkgs { + let name = p["name"].as_str().ok_or("metadata: package without name")?.to_owned(); + let manifest = Path::new(p["manifest_path"].as_str().ok_or("metadata: package without manifest_path")?); + let dir = manifest.parent().ok_or("metadata: manifest without a directory")?; + let rel = dir.strip_prefix(&root).map_err(|_| format!("{name}: {} is outside the workspace", dir.display()))?; + let deps = p["dependencies"] + .as_array() + .into_iter() + .flatten() + .filter_map(|d| d["name"].as_str()) + .filter(|d| members.contains(d) && *d != name) + .map(str::to_owned) + .collect(); + let mut sources = String::new(); + read_sources(dir, &mut sources)?; + let reads = tops + .iter() + .filter(|t| [format!("\"{t}/"), format!("/{t}/"), format!("\"{t}\""), format!("/{t}\"")].iter().any(|n| sources.contains(n.as_str()))) + .cloned() + .collect(); + packages.push(Package { + heavy: sources.contains("#[ignore"), + wasm: crate::wasm_crate(&name), + name, + dir: rel.to_string_lossy().replace('\\', "/"), + deps, + reads, + }); + } + Ok(Workspace { packages }) +} + +/// Append every `.rs` file under `dir` (build output and hidden directories left out). +fn read_sources(dir: &Path, out: &mut String) -> Result<(), String> { + let entries = std::fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))?; + for entry in entries { + let entry = entry.map_err(|e| format!("read {}: {e}", dir.display()))?; + let path = entry.path(); + let name = entry.file_name().to_string_lossy().into_owned(); + let kind = entry.file_type().map_err(|e| format!("{}: {e}", path.display()))?; + if kind.is_dir() && !name.starts_with('.') && name != "target" { + read_sources(&path, out)?; + } else if kind.is_file() && name.ends_with(".rs") { + out.push_str(&std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?); + out.push('\n'); + } + } + Ok(()) +} + +/// The files that differ between `base` and `head` (the working tree when `None`; renames count +/// as a deletion and an addition, so both sides count). +pub fn changed_files(base: &str, head: Option<&str>) -> Result, String> { + let git = |args: &[&str]| -> Result { + let out = Command::new("git").args(args).current_dir(root()).output().map_err(|e| format!("git: {e}"))?; + if out.status.success() { + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) + } else { + Err(format!("git {}: {}", args.join(" "), String::from_utf8_lossy(&out.stderr).trim())) + } + }; + for rev in std::iter::once(base).chain(head) { + let commit = format!("{rev}^{{commit}}"); + git(&["rev-parse", "--verify", "--quiet", &commit]).map_err(|_| format!("`{rev}` is not a commit in this repository"))?; + } + let mut args = vec!["diff", "--name-only", "--no-renames", base]; + args.extend(head); + let out = git(&args)?; + Ok(out.lines().filter(|l| !l.is_empty()).map(str::to_owned).collect()) +} + +/// The plan for the change since `base`. +pub fn plan_since(base: &str, head: Option<&str>) -> Result<(Plan, usize), String> { + let changed = changed_files(base, head)?; + let ws = workspace(&crate::metadata()?)?; + Ok((ws.plan(&changed), changed.len())) +} + +/// `cargo xtask affected --base [--head ] [--github-output]`. +pub fn run(args: &[&str]) -> Result<(), String> { + let mut base = None; + let mut head = None; + let mut github = false; + let mut it = args.iter(); + while let Some(a) = it.next() { + match *a { + "--base" => base = Some(*it.next().ok_or("--base needs a revision")?), + "--head" => head = Some(*it.next().ok_or("--head needs a revision")?), + "--github-output" => github = true, + other => return Err(format!("affected: unknown argument `{other}`")), + } + } + let base = base.ok_or("affected: --base is required")?; + let (plan, files) = plan_since(base, head)?; + print(&plan, base, files); + if github { + let path = std::env::var("GITHUB_OUTPUT").map_err(|_| "--github-output: GITHUB_OUTPUT is not set")?; + let lines = format!("everything={}\nsolver={}\nwasm={}\ncrates={}\n", plan.everything, plan.solver, plan.wasm, plan.crates.len()); + use std::io::Write; + std::fs::OpenOptions::new() + .append(true) + .open(&path) + .and_then(|mut f| f.write_all(lines.as_bytes())) + .map_err(|e| format!("write {path}: {e}"))?; + } + Ok(()) +} + +pub fn print(plan: &Plan, base: &str, files: usize) { + println!("affected since {base}: {files} changed file(s)"); + match &plan.reason { + Some(r) => println!("everything: {r} changed"), + None if plan.crates.is_empty() => println!("crates: none"), + None => println!("crates ({}): {}", plan.crates.len(), plan.crates.iter().cloned().collect::>().join(" ")), + } + println!("flags: everything={} solver={} wasm={}", plan.everything, plan.solver, plan.wasm); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pkg(name: &str, dir: &str, deps: &[&str], heavy: bool, wasm: bool, reads: &[&str]) -> Package { + Package { + name: name.into(), + dir: dir.into(), + deps: deps.iter().map(|d| d.to_string()).collect(), + heavy, + reads: reads.iter().map(|r| r.to_string()).collect(), + wasm, + } + } + + /// model <- solver <- engine <- ui-egui <- app; testkit (the oracle) uses engine; ui-egui reads assets. + fn ws() -> Workspace { + Workspace { + packages: vec![ + pkg("model", "crates/model", &[], false, true, &[]), + pkg("solver", "crates/solver", &["model"], true, true, &[]), + pkg("engine", "crates/engine", &["solver", "model"], false, true, &["docs"]), + pkg("ui-egui", "crates/ui-egui", &["engine"], false, true, &["assets"]), + pkg("app", "apps/app", &["ui-egui"], false, false, &["assets"]), + pkg(ORACLE_PACKAGE, "crates/testkit", &["engine"], false, false, &["tests", "docs"]), + pkg("xtask", "xtask", &[], true, false, &[]), + ], + } + } + + fn plan(files: &[&str]) -> Plan { + ws().plan(&files.iter().map(|f| f.to_string()).collect::>()) + } + + fn set(names: &[&str]) -> BTreeSet { + names.iter().map(|n| n.to_string()).collect() + } + + #[test] + fn ui_change_reaches_only_its_dependents() { + let p = plan(&["crates/ui-egui/src/theme.rs"]); + assert_eq!(p.crates, set(&["ui-egui", "app"])); + assert!(!p.solver && !p.everything); + assert!(p.wasm); + } + + #[test] + fn app_only_change_skips_wasm() { + let p = plan(&["apps/app/src/main.rs"]); + assert_eq!(p.crates, set(&["app"])); + assert!(!p.wasm && !p.solver); + } + + #[test] + fn solver_change_reaches_the_oracle() { + let p = plan(&["crates/solver/src/heat.rs"]); + assert_eq!(p.crates, set(&["solver", "engine", "ui-egui", "app", ORACLE_PACKAGE])); + assert!(p.solver && p.wasm && !p.everything); + } + + #[test] + fn engine_change_runs_the_oracle_through_the_testkit() { + let p = plan(&["crates/engine/src/lib.rs"]); + assert!(p.crates.contains(ORACLE_PACKAGE)); + assert!(p.solver); + } + + #[test] + fn lock_file_workflow_and_xtask_run_everything() { + for f in ["Cargo.lock", "Cargo.toml", ".github/workflows/ci.yml", "xtask/src/main.rs", "rust-toolchain.toml"] { + let p = plan(&[f]); + assert!(p.everything && p.solver && p.wasm, "{f}"); + assert_eq!(p.crates.len(), 7, "{f}"); + assert_eq!(p.reason.as_deref(), Some(f)); + } + } + + #[test] + fn a_crate_manifest_is_that_crate() { + let p = plan(&["crates/model/Cargo.toml"]); + assert!(!p.everything); + assert!(p.crates.contains("model") && p.solver); + } + + #[test] + fn unowned_path_under_crates_runs_everything() { + assert!(plan(&["crates/removed/src/lib.rs"]).everything); + } + + #[test] + fn files_outside_packages_reach_the_packages_that_read_them() { + let p = plan(&["tests/oracle/beam/recipe.json"]); + assert_eq!(p.crates, set(&[ORACLE_PACKAGE])); + assert!(p.solver && !p.wasm); + let p = plan(&["assets/icons/move.svg"]); + assert_eq!(p.crates, set(&["ui-egui", "app"])); + assert!(!p.solver); + } + + #[test] + fn prose_reaches_nothing() { + let p = plan(&["skills/meshing/SKILL.md", "README.md"]); + assert!(p.crates.is_empty()); + assert!(!p.everything && !p.solver && !p.wasm); + } + + #[test] + fn nested_directory_belongs_to_the_deepest_package() { + let mut w = ws(); + w.packages.push(pkg("inner", "crates/engine/inner", &[], false, false, &[])); + let p = w.plan(&["crates/engine/inner/src/lib.rs".to_string()]); + assert_eq!(p.crates, set(&["inner"])); + } + + #[test] + fn no_change_is_an_empty_plan() { + assert_eq!(plan(&[]), Plan::default()); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 9cfced8..d2ddfc6 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -5,6 +5,7 @@ //! ATTRIBUTION.md. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod affected; mod app_icon; mod assets; mod icons; @@ -42,10 +43,17 @@ commands: against their references and fixtures; write docs/oracle.md after a full run; --bless freezes a passing case's results in its fixture.json; --quick skips cases marked slow (release build) + affected --base REV [--head REV] [--github-output] + the workspace crates changed between REV and HEAD-REV (the working tree) and every crate that depends on them, + and the flags CI plans its jobs with: everything (manifest, lock file, + toolchain, xtask or workflows changed), solver (the heavy tests or the oracle + are affected), wasm (a crate the wasm check builds is affected) wasm cargo check every library crate and the web app for wasm32-unknown-unknown - ci fmt --check, assets, layers, clippy -D warnings, then the tests + ci [--affected REV] + fmt --check, assets, layers, clippy -D warnings, then the tests (not the heavy ones) with the wasm check alongside (stops at the first failing - step) + step); --affected REV lints and tests only the crates `affected --base REV` + lists, and checks wasm only when it flags wasm "; fn main() -> ExitCode { @@ -65,12 +73,17 @@ fn main() -> ExitCode { let (cargo_args, test_args) = rest.iter().position(|a| *a == "--").map_or((rest, &[][..]), |i| (&rest[..i], &rest[i + 1..])); if heavy { let ignored: Vec<&str> = ["--ignored"].into_iter().chain(test_args.iter().copied()).collect(); - test::run(cargo_args, &ignored, vec![test::doc_tests(cargo_args, test_args)]) + test::run(&[], cargo_args, &ignored, vec![test::doc_tests(cargo_args, test_args)]) } else { - test::run(cargo_args, test_args, vec![]) + test::run(&[], cargo_args, test_args, vec![]) } } - Some("ci") => cmd_ci(), + Some("affected") => affected::run(&rest), + Some("ci") => match rest.as_slice() { + [] => cmd_ci(), + ["--affected", base] => cmd_ci_affected(base), + _ => Err(format!("ci: expected no arguments or `--affected REV`\n\n{USAGE}")), + }, Some("corpus") => { // A binary of the testkit, so xtask itself stays free of SolidCraft. let mut c = cargo(); @@ -80,7 +93,7 @@ fn main() -> ExitCode { Some("oracle") => { // A binary of the testkit, which has the engine and SolidCraft scripts for geometry. let mut c = cargo(); - c.args(["run", "--release", "-p", "fieldcraft-testkit", "--bin", "oracle", "--"]).args(&rest); + c.args(["run", "--release", "-p", affected::ORACLE_PACKAGE, "--bin", "oracle", "--"]).args(&rest); run(c, "oracle") } Some("wasm") => cmd_wasm(), @@ -171,15 +184,16 @@ fn cmd_wasm() -> Result<(), String> { /// The app that runs in the browser (apps/fieldcraft-web), checked with the layered crates. const WEB_APP: &str = "fieldcraft-web"; +/// Whether the wasm check builds `pkg`: a layered crate or the web app. +pub fn wasm_crate(pkg: &str) -> bool { + pkg == WEB_APP || matches!(layers::classify(pkg), Some(layers::Class::Layer(_))) +} + /// The wasm check and how many crates it covers. It uses the ci profile so the host build /// scripts and proc macros built for clippy and the tests are reused. fn wasm_check() -> Result<(Command, usize), String> { let crates = layers::from_metadata(&metadata()?)?; - let set: Vec = crates - .iter() - .filter(|c| c.name == WEB_APP || matches!(layers::classify(&c.name), Some(layers::Class::Layer(_)))) - .map(|c| c.name.clone()) - .collect(); + let set: Vec = crates.iter().filter(|c| wasm_crate(&c.name)).map(|c| c.name.clone()).collect(); if !set.iter().any(|c| c == WEB_APP) { return Err(format!("wasm: the web app `{WEB_APP}` is not in the workspace")); } @@ -195,16 +209,54 @@ fn wasm_check() -> Result<(Command, usize), String> { /// which leaves cargo's build lock free. The heavy tests and the doc tests are the CI heavy job's /// (`cargo xtask test --heavy`). fn cmd_ci() -> Result<(), String> { - let mut c = cargo(); - c.args(["fmt", "--all", "--", "--check"]); - run(c, "cargo fmt --check")?; - assets::run(&root())?; - cmd_layers()?; + quick_gates()?; let mut c = cargo(); c.args(["clippy", "--workspace", "--all-targets", "--profile", "ci", "--", "-D", "warnings"]); run(c, "cargo clippy -D warnings")?; let (wasm, n) = wasm_check()?; - test::run(&[], &[], vec![(format!("wasm32-unknown-unknown check ({n} crates)"), wasm)])?; + test::run(&[], &[], &[], vec![(format!("wasm32-unknown-unknown check ({n} crates)"), wasm)])?; eprintln!("ci: all gates passed"); Ok(()) } + +/// The gates that look at the whole tree and take seconds: they run on every change. +fn quick_gates() -> Result<(), String> { + let mut c = cargo(); + c.args(["fmt", "--all", "--", "--check"]); + run(c, "cargo fmt --check")?; + assets::run(&root())?; + cmd_layers() +} + +/// `ci --affected REV`: the quick gates, then clippy and the tests of the affected crates only, and +/// the wasm check when an affected crate is built for wasm. A change that runs everything is the +/// full gate. +fn cmd_ci_affected(base: &str) -> Result<(), String> { + let (plan, files) = affected::plan_since(base, None)?; + affected::print(&plan, base, files); + if plan.everything { + return cmd_ci(); + } + quick_gates()?; + if plan.crates.is_empty() { + eprintln!("ci: no crate affected; the quick gates passed"); + return Ok(()); + } + let packages: Vec<&str> = plan.crates.iter().map(String::as_str).collect(); + let mut c = cargo(); + c.args(["clippy", "--all-targets", "--profile", "ci"]); + for p in &packages { + c.args(["-p", p]); + } + c.args(["--", "-D", "warnings"]); + run(c, &format!("cargo clippy -D warnings ({} crates)", packages.len()))?; + let side = if plan.wasm { + let (wasm, n) = wasm_check()?; + vec![(format!("wasm32-unknown-unknown check ({n} crates)"), wasm)] + } else { + vec![] + }; + test::run(&packages, &[], &[], side)?; + eprintln!("ci: all gates passed for {} affected crate(s)", packages.len()); + Ok(()) +} diff --git a/xtask/src/test.rs b/xtask/src/test.rs index c560707..b09cd7b 100644 --- a/xtask/src/test.rs +++ b/xtask/src/test.rs @@ -15,11 +15,11 @@ struct TestBin { dir: String, } -/// Build the tests with `cargo_args` (e.g. `--locked`), run each binary with `test_args`, and run +/// Build the tests of `packages` (every workspace package when empty) with `cargo_args` (e.g. `--locked`), run each binary with `test_args`, and run /// the `side` jobs one after another meanwhile. Each prints a line when it ends; a failure prints /// its whole output. -pub fn run(cargo_args: &[&str], test_args: &[&str], side: Vec<(String, Command)>) -> Result<(), String> { - let bins = build(cargo_args)?; +pub fn run(packages: &[&str], cargo_args: &[&str], test_args: &[&str], side: Vec<(String, Command)>) -> Result<(), String> { + let bins = build(packages, cargo_args)?; let start = Instant::now(); let (tx, rx) = mpsc::channel::<(String, Result, f64)>(); let mut failed = Vec::new(); @@ -66,11 +66,20 @@ fn finish(mut c: Command) -> Result { } } -/// `cargo test --workspace --profile ci --no-run`, and the executables it built. -fn build(cargo_args: &[&str]) -> Result, String> { +/// `cargo test --workspace --profile ci --no-run` (or `-p` each of `packages`), and the executables +/// it built. +fn build(packages: &[&str], cargo_args: &[&str]) -> Result, String> { let mut c = cargo(); - c.args(["test", "--workspace", "--profile", "ci", "--no-run", "--message-format=json-render-diagnostics"]).args(cargo_args); - eprintln!("$ cargo test --workspace --profile ci --no-run"); + c.arg("test"); + if packages.is_empty() { + c.arg("--workspace"); + } + for p in packages { + c.args(["-p", p]); + } + c.args(["--profile", "ci", "--no-run", "--message-format=json-render-diagnostics"]).args(cargo_args); + let scope = if packages.is_empty() { "--workspace".to_string() } else { format!("{} packages", packages.len()) }; + eprintln!("$ cargo test {scope} --profile ci --no-run"); let out = c.stderr(Stdio::inherit()).output().map_err(|e| format!("cargo test --no-run: failed to spawn: {e}"))?; if !out.status.success() { return Err(format!("cargo test --no-run: exited with {}", out.status));