forked from 1b-agent/fieldcraft-upstream
ModelCraft foundation: workspace, quality gates, FE model, command engine, geometry boundary
A pure-Rust CAE modeling workspace following the SolidCraft conventions: - modelcraft-model: nodes and elements by label with hostile-input validation - modelcraft-geometry: SolidCraft's kernel (pinned git dependency) behind our own API; tessellation into a face-tagged boundary surface for volume meshing - modelcraft-engine: CommandSpec registry, Session::execute with rollback and panic guard, scripts and a journal - modelcraft-cli: list commands, run scripts - xtask: layers (only geometry may use solidcraft-*), assets, licences, tests, wasm, ci - AGENTS.md, architecture, ADR 0001 (libraries and licences), roadmap, CI Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
This commit is contained in:
commit
b64ec3f698
38 files changed
+3931
No files matched your search
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "xtask"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,40 @@
|
||||
//! `cargo xtask assets`: every non-code asset in the repository must have a row in
|
||||
//! ATTRIBUTION.md (author, source, licence). Icons are drawn in code and need no file.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
const ASSET_EXT: &[&str] =
|
||||
&["png", "jpg", "jpeg", "svg", "ico", "icns", "ttf", "otf", "woff", "woff2", "gif", "bmp", "webp", "wav", "mp3", "stl", "step", "stp", "obj"];
|
||||
const SKIP_DIRS: &[&str] = &["target", ".git", "plan", "dist"];
|
||||
|
||||
fn walk(dir: &Path, root: &Path, out: &mut Vec<String>) {
|
||||
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() {
|
||||
if !SKIP_DIRS.contains(&name.as_str()) && !name.starts_with('.') {
|
||||
walk(&p, root, out);
|
||||
}
|
||||
} else if let Some(ext) = p.extension().map(|x| x.to_string_lossy().to_ascii_lowercase())
|
||||
&& ASSET_EXT.contains(&ext.as_str())
|
||||
&& let Ok(rel) = p.strip_prefix(root)
|
||||
{
|
||||
out.push(rel.to_string_lossy().replace('\\', "/"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
let attribution = std::fs::read_to_string(root.join("ATTRIBUTION.md")).map_err(|e| format!("ATTRIBUTION.md: {e}"))?;
|
||||
let mut files = Vec::new();
|
||||
walk(root, root, &mut files);
|
||||
files.sort();
|
||||
let missing: Vec<&String> = files.iter().filter(|f| !attribution.contains(f.as_str())).collect();
|
||||
if missing.is_empty() {
|
||||
println!("assets: {} asset file(s), all attributed", files.len());
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("assets missing from ATTRIBUTION.md:\n{}", missing.iter().map(|m| format!(" {m}")).collect::<Vec<_>>().join("\n")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
//! Dependency layering rules (plan/architecture.md §3).
|
||||
//!
|
||||
//! Adapted from SolidCraft's xtask (MIT OR Apache-2.0), see ATTRIBUTION.md.
|
||||
//!
|
||||
//! The rule engine works on a small, metadata-independent model so it can be
|
||||
//! unit-tested; `from_metadata` builds that model from `cargo metadata`.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Where a workspace crate sits.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Class {
|
||||
/// Regular layered crate.
|
||||
Layer(u8),
|
||||
/// Layer 0, and additionally may depend on no workspace crate at all.
|
||||
Standalone,
|
||||
/// Test tooling: may depend on anything up to L5 (it sits at L6 for rule
|
||||
/// purposes); other crates may use it only as a dev-dependency.
|
||||
Testkit,
|
||||
/// Binaries and build tooling: exempt from the rules.
|
||||
Exempt,
|
||||
}
|
||||
|
||||
impl Class {
|
||||
fn layer(self) -> Option<u8> {
|
||||
match self {
|
||||
Class::Layer(l) => Some(l),
|
||||
Class::Standalone => Some(0),
|
||||
Class::Testkit => Some(6),
|
||||
Class::Exempt => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The layering table. Names are package names without the `modelcraft-`
|
||||
/// prefix.
|
||||
pub const TABLE: &[(&str, Class)] = &[
|
||||
("model", Class::Layer(0)),
|
||||
("geometry", Class::Layer(1)),
|
||||
("mesh", Class::Layer(1)),
|
||||
("solver", Class::Layer(1)),
|
||||
("decks", Class::Layer(1)),
|
||||
("render", Class::Layer(1)),
|
||||
("doc", Class::Layer(2)),
|
||||
("io", Class::Layer(3)),
|
||||
("engine", Class::Layer(4)),
|
||||
("ui-egui", Class::Layer(5)),
|
||||
("mcp", Class::Layer(5)),
|
||||
("testkit", Class::Testkit),
|
||||
// apps and tooling
|
||||
("modelcraft", Class::Exempt),
|
||||
("cli", Class::Exempt),
|
||||
("web", Class::Exempt),
|
||||
("xtask", Class::Exempt),
|
||||
];
|
||||
|
||||
/// Explicit orderings *within* a layer (earlier may be used by later).
|
||||
pub const INTRA_LAYER_ORDER: &[&[&str]] = &[];
|
||||
|
||||
fn intra_layer_allowed(from: &str, to: &str) -> bool {
|
||||
let (from, to) = (short_name(from), short_name(to));
|
||||
INTRA_LAYER_ORDER.iter().any(|chain| match (chain.iter().position(|n| *n == from), chain.iter().position(|n| *n == to)) {
|
||||
(Some(f), Some(t)) => t < f,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// External crates that constitute a UI toolkit / windowing dependency.
|
||||
/// Entries ending in `*` are prefixes.
|
||||
pub const UI_CRATES: &[&str] = &["egui", "eframe", "egui-wgpu", "wgpu", "winit", "egui_kittest", "rfd", "bevy*"];
|
||||
|
||||
/// The only crate that may depend on SolidCraft (`solidcraft-*`): the geometry boundary.
|
||||
pub const SOLIDCRAFT_GATEWAY: &str = "geometry";
|
||||
|
||||
/// First layer allowed to use UI crates.
|
||||
pub const UI_MIN_LAYER: u8 = 5;
|
||||
|
||||
pub fn short_name(pkg: &str) -> &str {
|
||||
pkg.strip_prefix("modelcraft-").unwrap_or(pkg)
|
||||
}
|
||||
|
||||
pub fn classify(pkg: &str) -> Option<Class> {
|
||||
let s = short_name(pkg);
|
||||
TABLE.iter().find(|(n, _)| *n == s).map(|(_, c)| *c)
|
||||
}
|
||||
|
||||
fn is_ui_crate(name: &str) -> bool {
|
||||
UI_CRATES.iter().any(|p| match p.strip_suffix('*') {
|
||||
Some(prefix) => name.starts_with(prefix),
|
||||
None => name == *p,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DepKind {
|
||||
Normal,
|
||||
Dev,
|
||||
Build,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Dep {
|
||||
pub name: String,
|
||||
pub kind: DepKind,
|
||||
/// `true` if the dependency is a workspace member.
|
||||
pub workspace: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Crate {
|
||||
pub name: String,
|
||||
pub deps: Vec<Dep>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Violation {
|
||||
Unregistered { krate: String },
|
||||
Upward { krate: String, dep: String, from: u8, to: u8, kind: DepKind },
|
||||
StandaloneHasWorkspaceDep { krate: String, dep: String },
|
||||
TestkitAsNormalDep { krate: String },
|
||||
UiBelowL5 { krate: String, dep: String, layer: u8 },
|
||||
SolidCraftOutsideGeometry { krate: String, dep: String },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Violation {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Violation::Unregistered { krate } => {
|
||||
write!(f, "{krate}: unknown workspace crate; register it in xtask/src/layers.rs TABLE (see plan/architecture.md §3)")
|
||||
}
|
||||
Violation::Upward { krate, dep, from, to, kind } => {
|
||||
write!(f, "{krate} (L{from}) -> {dep} (L{to}) [{kind:?}]: may only depend on strictly lower layers")
|
||||
}
|
||||
Violation::StandaloneHasWorkspaceDep { krate, dep } => {
|
||||
write!(f, "{krate}: standalone crate must not depend on workspace crate {dep}")
|
||||
}
|
||||
Violation::TestkitAsNormalDep { krate } => {
|
||||
write!(f, "{krate}: modelcraft-testkit may only be a dev-dependency")
|
||||
}
|
||||
Violation::UiBelowL5 { krate, dep, layer } => {
|
||||
write!(f, "{krate} (L{layer}) depends on UI crate `{dep}`; UI toolkits are only allowed in L5+")
|
||||
}
|
||||
Violation::SolidCraftOutsideGeometry { krate, dep } => {
|
||||
write!(f, "{krate} depends on `{dep}`; only modelcraft-{SOLIDCRAFT_GATEWAY} may use SolidCraft")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check all rules. Returns violations sorted for stable output.
|
||||
pub fn check(crates: &[Crate]) -> Vec<Violation> {
|
||||
let mut out = Vec::new();
|
||||
for c in crates {
|
||||
let Some(class) = classify(&c.name) else {
|
||||
out.push(Violation::Unregistered { krate: c.name.clone() });
|
||||
continue;
|
||||
};
|
||||
if class == Class::Exempt {
|
||||
continue;
|
||||
}
|
||||
let layer = class.layer().unwrap_or(0);
|
||||
for d in &c.deps {
|
||||
// Self dev-dependencies (e.g. to enable features in tests) are fine.
|
||||
if d.name == c.name {
|
||||
continue;
|
||||
}
|
||||
if d.workspace {
|
||||
if class == Class::Standalone {
|
||||
out.push(Violation::StandaloneHasWorkspaceDep { krate: c.name.clone(), dep: d.name.clone() });
|
||||
continue;
|
||||
}
|
||||
match classify(&d.name) {
|
||||
// Unregistered deps are reported on their own entry.
|
||||
None => {}
|
||||
Some(Class::Testkit) if d.kind != DepKind::Dev => {
|
||||
out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() });
|
||||
}
|
||||
Some(Class::Testkit) => {}
|
||||
Some(dc) => {
|
||||
let to = dc.layer().unwrap_or(u8::MAX);
|
||||
if to >= layer && !(to == layer && intra_layer_allowed(&c.name, &d.name)) {
|
||||
out.push(Violation::Upward {
|
||||
krate: c.name.clone(),
|
||||
dep: d.name.clone(),
|
||||
from: layer,
|
||||
to: if to == u8::MAX { 7 } else { to },
|
||||
kind: d.kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if d.name.starts_with("solidcraft-") && short_name(&c.name) != SOLIDCRAFT_GATEWAY {
|
||||
out.push(Violation::SolidCraftOutsideGeometry { krate: c.name.clone(), dep: d.name.clone() });
|
||||
} else if layer < UI_MIN_LAYER && is_ui_crate(&d.name) {
|
||||
out.push(Violation::UiBelowL5 { krate: c.name.clone(), dep: d.name.clone(), layer });
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|v| v.to_string());
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
/// Build the model from `cargo metadata --format-version 1 --no-deps`.
|
||||
pub fn from_metadata(meta: &Value) -> Result<Vec<Crate>, String> {
|
||||
let pkgs = meta["packages"].as_array().ok_or("metadata: no packages array")?;
|
||||
let members: Vec<&str> = pkgs.iter().filter_map(|p| p["name"].as_str()).collect();
|
||||
let mut out = Vec::new();
|
||||
for p in pkgs {
|
||||
let name = p["name"].as_str().ok_or("package without name")?.to_owned();
|
||||
let mut deps = Vec::new();
|
||||
for d in p["dependencies"].as_array().into_iter().flatten() {
|
||||
let dname = d["name"].as_str().unwrap_or_default().to_owned();
|
||||
let kind = match d["kind"].as_str() {
|
||||
Some("dev") => DepKind::Dev,
|
||||
Some("build") => DepKind::Build,
|
||||
_ => DepKind::Normal,
|
||||
};
|
||||
let workspace = members.contains(&dname.as_str()) || d["path"].is_string();
|
||||
deps.push(Dep { name: dname, kind, workspace });
|
||||
}
|
||||
out.push(Crate { name, deps });
|
||||
}
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn describe(class: Option<Class>) -> String {
|
||||
match class {
|
||||
Some(Class::Layer(l)) => format!("L{l}"),
|
||||
Some(Class::Standalone) => "L0 standalone".into(),
|
||||
Some(Class::Testkit) => "testkit".into(),
|
||||
Some(Class::Exempt) => "exempt".into(),
|
||||
None => "UNREGISTERED".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn c(name: &str, deps: &[(&str, DepKind, bool)]) -> Crate {
|
||||
Crate { name: name.into(), deps: deps.iter().map(|(n, k, w)| Dep { name: (*n).into(), kind: *k, workspace: *w }).collect() }
|
||||
}
|
||||
use DepKind::*;
|
||||
|
||||
#[test]
|
||||
fn clean_downward_graph_passes() {
|
||||
let g = [
|
||||
c("modelcraft-model", &[("serde", Normal, false)]),
|
||||
c("modelcraft-doc", &[("modelcraft-model", Normal, true)]),
|
||||
c("modelcraft-engine", &[("modelcraft-doc", Normal, true), ("modelcraft-testkit", Dev, true)]),
|
||||
c("modelcraft-ui-egui", &[("modelcraft-engine", Normal, true), ("egui", Normal, false)]),
|
||||
c("modelcraft-cli", &[("modelcraft-ui-egui", Normal, true)]),
|
||||
];
|
||||
assert!(check(&g).is_empty(), "{:?}", check(&g));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upward_dependency_flagged() {
|
||||
let v = check(&[c("modelcraft-doc", &[("modelcraft-engine", Normal, true)])]);
|
||||
assert!(matches!(v[..], [Violation::Upward { from: 2, to: 4, .. }]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sideways_dependency_flagged() {
|
||||
let v = check(&[c("modelcraft-mesh", &[("modelcraft-solver", Normal, true)])]);
|
||||
assert!(matches!(v[..], [Violation::Upward { from: 1, to: 1, .. }]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_dev_dependency_ignored() {
|
||||
assert!(check(&[c("modelcraft-doc", &[("modelcraft-doc", Dev, true)])]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upward_dev_dependency_flagged() {
|
||||
let v = check(&[c("modelcraft-model", &[("modelcraft-doc", Dev, true)])]);
|
||||
assert!(matches!(v[..], [Violation::Upward { kind: Dev, .. }]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_crates_below_l6_flagged() {
|
||||
for dep in ["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy_ecs", "bevy"] {
|
||||
let v = check(&[c("modelcraft-engine", &[(dep, Normal, false)])]);
|
||||
assert!(matches!(v[..], [Violation::UiBelowL5 { layer: 4, .. }]), "{dep}");
|
||||
}
|
||||
assert!(check(&[c("modelcraft-engine", &[("egui_extras_not", Normal, false)])]).is_empty());
|
||||
assert!(check(&[c("modelcraft-ui-egui", &[("winit", Normal, false)])]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_geometry_uses_solidcraft() {
|
||||
assert!(check(&[c("modelcraft-geometry", &[("solidcraft-kernel", Normal, false)])]).is_empty());
|
||||
let v = check(&[c("modelcraft-mesh", &[("solidcraft-geom", Normal, false)])]);
|
||||
assert!(matches!(v[..], [Violation::SolidCraftOutsideGeometry { .. }]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregistered_crate_is_error() {
|
||||
let v = check(&[c("modelcraft-mystery", &[])]);
|
||||
assert!(matches!(&v[..], [Violation::Unregistered { krate }] if krate == "modelcraft-mystery"));
|
||||
assert!(v[0].to_string().contains("register"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn testkit_only_as_dev_dependency() {
|
||||
let v = check(&[c("modelcraft-doc", &[("modelcraft-testkit", Normal, true)])]);
|
||||
assert!(matches!(v[..], [Violation::TestkitAsNormalDep { .. }]));
|
||||
assert!(check(&[c("modelcraft-doc", &[("modelcraft-testkit", Dev, true)])]).is_empty());
|
||||
assert!(check(&[c("modelcraft-testkit", &[("modelcraft-engine", Normal, true)])]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apps_and_xtask_exempt() {
|
||||
for app in ["modelcraft", "modelcraft-cli", "xtask"] {
|
||||
assert!(check(&[c(app, &[("egui", Normal, false), ("modelcraft-ui-egui", Normal, true)])]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_parsing() {
|
||||
let meta: Value = serde_json::from_str(
|
||||
r#"{"packages":[
|
||||
{"name":"modelcraft-doc","dependencies":[
|
||||
{"name":"modelcraft-model","kind":null,"path":"/x/crates/model"},
|
||||
{"name":"serde","kind":null},
|
||||
{"name":"proptest","kind":"dev"}]},
|
||||
{"name":"modelcraft-model","dependencies":[]}
|
||||
]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let g = from_metadata(&meta).unwrap();
|
||||
assert_eq!(g.len(), 2);
|
||||
let doc = g.iter().find(|c| c.name == "modelcraft-doc").unwrap();
|
||||
assert!(doc.deps[0].workspace && !doc.deps[1].workspace);
|
||||
assert_eq!(doc.deps[2].kind, Dev);
|
||||
assert!(check(&g).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! `cargo xtask licences [--check]`: THIRD-PARTY-LICENSES.txt, the licences of every crate built
|
||||
//! into the shipped programs (modelcraft, modelcraft-cli, modelcraft-web), read from
|
||||
//! `cargo metadata` and the crates' own licence files, so a release download can carry them.
|
||||
//! `--check` (part of `cargo xtask ci`) fails when the committed file is out of date, e.g. after a
|
||||
//! dependency changed.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::cargo;
|
||||
|
||||
pub const FILE: &str = "THIRD-PARTY-LICENSES.txt";
|
||||
const ROOTS: &[&str] = &["modelcraft", "modelcraft-cli", "modelcraft-web"];
|
||||
|
||||
pub fn run(root: &Path, check: bool) -> Result<(), String> {
|
||||
let text = generate()?;
|
||||
let path = root.join(FILE);
|
||||
if check {
|
||||
let old = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
if old.replace("\r\n", "\n") != text {
|
||||
return Err(format!("{FILE} is out of date (dependencies changed): run `cargo xtask licences` and commit it"));
|
||||
}
|
||||
println!("licences: {FILE} is up to date");
|
||||
return Ok(());
|
||||
}
|
||||
std::fs::write(&path, &text).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
println!("licences: wrote {FILE} ({} KB)", text.len() / 1024);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Crate {
|
||||
name: String,
|
||||
version: String,
|
||||
license: String,
|
||||
repository: String,
|
||||
/// (file name, text) of the licence files shipped in the crate.
|
||||
files: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
fn generate() -> Result<String, String> {
|
||||
let out = cargo().args(["metadata", "--format-version", "1", "--locked"]).output().map_err(|e| format!("cargo metadata: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!("cargo metadata failed:\n{}", String::from_utf8_lossy(&out.stderr)));
|
||||
}
|
||||
let meta: Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("cargo metadata JSON: {e}"))?;
|
||||
let packages: BTreeMap<&str, &Value> = meta["packages"].as_array().into_iter().flatten().filter_map(|p| Some((p["id"].as_str()?, p))).collect();
|
||||
let nodes: BTreeMap<&str, &Value> =
|
||||
meta["resolve"]["nodes"].as_array().into_iter().flatten().filter_map(|n| Some((n["id"].as_str()?, n))).collect();
|
||||
let members: BTreeSet<&str> = meta["workspace_members"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
|
||||
|
||||
// Everything reachable from the shipped programs through normal (not dev) dependencies, on any
|
||||
// platform.
|
||||
let mut seen: BTreeSet<&str> = BTreeSet::new();
|
||||
let mut queue: VecDeque<&str> = packages
|
||||
.iter()
|
||||
.filter(|(id, p)| members.contains(*id) && ROOTS.contains(&p["name"].as_str().unwrap_or_default()))
|
||||
.map(|(id, _)| *id)
|
||||
.collect();
|
||||
while let Some(id) = queue.pop_front() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
for d in nodes.get(id).and_then(|n| n["deps"].as_array()).into_iter().flatten() {
|
||||
let normal = d["dep_kinds"].as_array().into_iter().flatten().any(|k| k["kind"].is_null());
|
||||
if normal && let Some(pkg) = d["pkg"].as_str() {
|
||||
queue.push_back(pkg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut crates = Vec::new();
|
||||
for id in seen {
|
||||
let Some(p) = packages.get(id) else { continue };
|
||||
if members.contains(id) {
|
||||
continue;
|
||||
}
|
||||
let s = |k: &str| p[k].as_str().unwrap_or_default().to_string();
|
||||
let dir = Path::new(p["manifest_path"].as_str().unwrap_or_default()).parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
crates.push(Crate {
|
||||
name: s("name"),
|
||||
version: s("version"),
|
||||
license: s("license"),
|
||||
repository: s("repository"),
|
||||
files: licence_files(&dir, p),
|
||||
});
|
||||
}
|
||||
crates.sort_by(|a, b| (&a.name, &a.version).cmp(&(&b.name, &b.version)));
|
||||
Ok(render(&crates))
|
||||
}
|
||||
|
||||
/// The licence and notice files at the top of a crate's package (and its `license-file`).
|
||||
fn licence_files(dir: &Path, p: &Value) -> Vec<(String, String)> {
|
||||
let mut names: BTreeSet<String> = BTreeSet::new();
|
||||
if let Ok(rd) = std::fs::read_dir(dir) {
|
||||
for e in rd.flatten() {
|
||||
let n = e.file_name().to_string_lossy().to_string();
|
||||
let u = n.to_ascii_uppercase();
|
||||
if e.path().is_file() && ["LICENSE", "LICENCE", "COPYING", "NOTICE", "UNLICENSE", "COPYRIGHT"].iter().any(|k| u.starts_with(k)) {
|
||||
names.insert(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(f) = p["license_file"].as_str() {
|
||||
names.insert(f.to_string());
|
||||
}
|
||||
names
|
||||
.into_iter()
|
||||
.filter_map(|n| {
|
||||
let text = std::fs::read_to_string(dir.join(&n)).ok()?;
|
||||
let text = text.replace("\r\n", "\n").trim().to_string();
|
||||
(!text.is_empty()).then(|| (Path::new(&n).file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or(n), text))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render(crates: &[Crate]) -> String {
|
||||
let rule = "=".repeat(80);
|
||||
let mut out = format!(
|
||||
"Third-party software in ModelCraft\n\nModelCraft (the desktop app, modelcraft-cli and the web app) is built from the {} crates below.\n\
|
||||
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.\n\
|
||||
ModelCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.\n\n",
|
||||
crates.len()
|
||||
);
|
||||
for c in crates {
|
||||
out += &format!("{} {} ({})\n", c.name, c.version, if c.license.is_empty() { "see its licence file" } else { &c.license });
|
||||
}
|
||||
// One copy of each distinct licence text, with the crates it covers.
|
||||
let mut by_text: BTreeMap<&str, Vec<String>> = BTreeMap::new();
|
||||
let mut without: Vec<&Crate> = Vec::new();
|
||||
for c in crates {
|
||||
if c.files.is_empty() {
|
||||
without.push(c);
|
||||
}
|
||||
for (_, text) in &c.files {
|
||||
by_text.entry(text.as_str()).or_default().push(format!("{} {}", c.name, c.version));
|
||||
}
|
||||
}
|
||||
let mut sections: Vec<(Vec<String>, &str)> = by_text.into_iter().map(|(t, v)| (v, t)).collect();
|
||||
sections.sort();
|
||||
for (names, text) in sections {
|
||||
out += &format!("\n{rule}\n{}\n{rule}\n\n{text}\n", wrap(&names.join(", ")));
|
||||
}
|
||||
if !without.is_empty() {
|
||||
out += &format!(
|
||||
"\n{rule}\nCrates whose package carries no licence file (their licence is named in Cargo.toml; the MIT and Apache-2.0\ntexts are as in ModelCraft's own LICENSE-MIT and LICENSE-APACHE)\n{rule}\n\n"
|
||||
);
|
||||
for c in without {
|
||||
out += &format!(
|
||||
"{} {}: {}{}\n",
|
||||
c.name,
|
||||
c.version,
|
||||
c.license,
|
||||
if c.repository.is_empty() { String::new() } else { format!(" ({})", c.repository) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Wrap a long list of crate names at 100 columns.
|
||||
fn wrap(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut col = 0;
|
||||
for w in s.split(' ') {
|
||||
if col > 0 && col + w.len() + 1 > 100 {
|
||||
out.push('\n');
|
||||
col = 0;
|
||||
} else if col > 0 {
|
||||
out.push(' ');
|
||||
col += 1;
|
||||
}
|
||||
out += w;
|
||||
col += w.len();
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Workspace tooling: `cargo xtask <command>`.
|
||||
//!
|
||||
//! Pure Rust (std + serde_json). External tools (`cargo`) are invoked through
|
||||
//! `std::process::Command`. Adapted from SolidCraft's xtask (MIT OR Apache-2.0), see
|
||||
//! ATTRIBUTION.md.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod assets;
|
||||
mod layers;
|
||||
mod licences;
|
||||
mod test;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitCode};
|
||||
|
||||
const USAGE: &str = "\
|
||||
usage: cargo xtask <command>
|
||||
|
||||
commands:
|
||||
layers enforce the crate dependency layering (docs/architecture.md)
|
||||
assets check that every non-code asset file is attributed in ATTRIBUTION.md
|
||||
licences [--check]
|
||||
write THIRD-PARTY-LICENSES.txt (the licences of every shipped crate);
|
||||
--check fails when it is out of date
|
||||
test [CARGO ARGS] [-- TEST ARGS]
|
||||
build the tests with the ci profile, then run every test binary at once
|
||||
(and the doc tests alongside)
|
||||
wasm cargo check every library crate for wasm32-unknown-unknown
|
||||
ci fmt --check, assets, licences --check, layers, clippy -D warnings, then the tests
|
||||
with the wasm check alongside (stops at the first failing step)
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect();
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("layers") => cmd_layers(),
|
||||
Some("assets") => assets::run(&root()),
|
||||
Some("licences") => licences::run(&root(), rest.contains(&"--check")),
|
||||
Some("test") => {
|
||||
let (cargo_args, test_args) = rest.iter().position(|a| *a == "--").map_or((&rest[..], &[][..]), |i| (&rest[..i], &rest[i + 1..]));
|
||||
test::run(cargo_args, test_args, vec![test::doc_tests(cargo_args, test_args)])
|
||||
}
|
||||
Some("ci") => cmd_ci(),
|
||||
Some("wasm") => cmd_wasm(),
|
||||
Some("-h" | "--help" | "help") | None => {
|
||||
print!("{USAGE}");
|
||||
Ok(())
|
||||
}
|
||||
Some(other) => Err(format!("unknown command `{other}`\n\n{USAGE}")),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Workspace root (parent of the xtask crate).
|
||||
pub fn root() -> PathBuf {
|
||||
let here = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
here.parent().unwrap_or(here).to_path_buf()
|
||||
}
|
||||
|
||||
pub fn cargo() -> Command {
|
||||
let mut c = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()));
|
||||
c.current_dir(root());
|
||||
c
|
||||
}
|
||||
|
||||
fn run(mut cmd: Command, what: &str) -> Result<(), String> {
|
||||
eprintln!("$ {what}");
|
||||
let status = cmd.status().map_err(|e| format!("{what}: failed to spawn: {e}"))?;
|
||||
if status.success() { Ok(()) } else { Err(format!("{what}: exited with {status}")) }
|
||||
}
|
||||
|
||||
pub fn metadata() -> Result<serde_json::Value, String> {
|
||||
let out = cargo().args(["metadata", "--format-version", "1", "--no-deps"]).output().map_err(|e| format!("cargo metadata: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!("cargo metadata failed:\n{}", String::from_utf8_lossy(&out.stderr)));
|
||||
}
|
||||
serde_json::from_slice(&out.stdout).map_err(|e| format!("cargo metadata: bad JSON: {e}"))
|
||||
}
|
||||
|
||||
fn cmd_layers() -> Result<(), String> {
|
||||
let crates = layers::from_metadata(&metadata()?)?;
|
||||
println!("Dependency layering (docs/architecture.md)\n");
|
||||
println!("{:<28} {:<14} workspace deps", "crate", "layer");
|
||||
for c in &crates {
|
||||
let ws: Vec<String> = c
|
||||
.deps
|
||||
.iter()
|
||||
.filter(|d| d.workspace)
|
||||
.map(|d| {
|
||||
let k = match d.kind {
|
||||
layers::DepKind::Normal => "",
|
||||
layers::DepKind::Dev => " (dev)",
|
||||
layers::DepKind::Build => " (build)",
|
||||
};
|
||||
format!("{}{k}", layers::short_name(&d.name))
|
||||
})
|
||||
.collect();
|
||||
println!("{:<28} {:<14} {}", c.name, layers::describe(layers::classify(&c.name)), ws.join(", "));
|
||||
}
|
||||
let violations = layers::check(&crates);
|
||||
println!();
|
||||
if violations.is_empty() {
|
||||
println!("OK: {} crates, no layering violations.", crates.len());
|
||||
Ok(())
|
||||
} else {
|
||||
println!("{} violation(s):", violations.len());
|
||||
for v in &violations {
|
||||
println!(" - {v}");
|
||||
}
|
||||
Err(format!("{} layering violation(s)", violations.len()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Never break wasm: every layered crate (everything below the apps) must build for the browser.
|
||||
fn cmd_wasm() -> Result<(), String> {
|
||||
let (c, n) = wasm_check()?;
|
||||
run(c, &format!("cargo check --target wasm32-unknown-unknown ({n} crates)"))?;
|
||||
println!("wasm: {n} crates build for wasm32-unknown-unknown");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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<String> =
|
||||
crates.iter().filter(|c| matches!(layers::classify(&c.name), Some(layers::Class::Layer(_)))).map(|c| c.name.clone()).collect();
|
||||
let mut c = cargo();
|
||||
c.args(["check", "--target", "wasm32-unknown-unknown", "--profile", "ci"]);
|
||||
for p in &set {
|
||||
c.args(["-p", p]);
|
||||
}
|
||||
Ok((c, set.len()))
|
||||
}
|
||||
|
||||
/// The quick gates first, so they fail fast; the wasm check (and doc tests) run while the test
|
||||
/// binaries do, which leaves cargo's build lock free.
|
||||
fn cmd_ci() -> Result<(), String> {
|
||||
let mut c = cargo();
|
||||
c.args(["fmt", "--all", "--", "--check"]);
|
||||
run(c, "cargo fmt --check")?;
|
||||
assets::run(&root())?;
|
||||
licences::run(&root(), true)?;
|
||||
cmd_layers()?;
|
||||
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::doc_tests(&[], &[])])?;
|
||||
eprintln!("ci: all gates passed");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! `cargo xtask test`: build the workspace's tests once, then run every test binary at the same
|
||||
//! time. `cargo test` runs them one after another, so one slow binary held up all the others;
|
||||
//! the doc tests and any other `side` jobs run alongside on their own thread.
|
||||
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::{cargo, root};
|
||||
|
||||
/// A test executable from `cargo test --no-run`.
|
||||
struct TestBin {
|
||||
label: String,
|
||||
exe: String,
|
||||
dir: String,
|
||||
}
|
||||
|
||||
/// Build the tests 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)?;
|
||||
let start = Instant::now();
|
||||
let (tx, rx) = mpsc::channel::<(String, Result<String, String>, f64)>();
|
||||
let mut failed = Vec::new();
|
||||
std::thread::scope(|scope| {
|
||||
for b in &bins {
|
||||
let tx = tx.clone();
|
||||
scope.spawn(move || {
|
||||
let t0 = Instant::now();
|
||||
let mut c = Command::new(&b.exe);
|
||||
c.args(test_args).current_dir(&b.dir).env("CARGO_MANIFEST_DIR", &b.dir);
|
||||
let _ = tx.send((b.label.clone(), finish(c), t0.elapsed().as_secs_f64()));
|
||||
});
|
||||
}
|
||||
let tx_side = tx.clone();
|
||||
scope.spawn(move || {
|
||||
for (label, c) in side {
|
||||
let t0 = Instant::now();
|
||||
let _ = tx_side.send((label, finish(c), t0.elapsed().as_secs_f64()));
|
||||
}
|
||||
});
|
||||
drop(tx);
|
||||
for (label, result, secs) in rx {
|
||||
match result {
|
||||
Ok(summary) => println!("ok {label:<44} {secs:6.1}s {summary}"),
|
||||
Err(output) => {
|
||||
println!("FAIL {label:<44} {secs:6.1}s\n{output}");
|
||||
failed.push(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
println!("test: {} binaries in {:.1}s", bins.len(), start.elapsed().as_secs_f64());
|
||||
if failed.is_empty() { Ok(()) } else { Err(format!("failed: {}", failed.join(", "))) }
|
||||
}
|
||||
|
||||
/// Run to the end: the last `test result:` line on success, all output on failure.
|
||||
fn finish(mut c: Command) -> Result<String, String> {
|
||||
let out = c.stdin(Stdio::null()).output().map_err(|e| format!("failed to spawn: {e}"))?;
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
if out.status.success() {
|
||||
Ok(stdout.lines().rev().find(|l| l.starts_with("test result:")).unwrap_or("").trim_start_matches("test result: ").to_string())
|
||||
} else {
|
||||
Err(format!("{stdout}{}\n({})", String::from_utf8_lossy(&out.stderr), out.status))
|
||||
}
|
||||
}
|
||||
|
||||
/// `cargo test --workspace --profile ci --no-run`, and the executables it built.
|
||||
fn build(cargo_args: &[&str]) -> Result<Vec<TestBin>, 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");
|
||||
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));
|
||||
}
|
||||
let mut bins: Vec<TestBin> = Vec::new();
|
||||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||||
let Ok(m) = serde_json::from_str::<serde_json::Value>(line) else { continue };
|
||||
let Some(exe) = m["executable"].as_str().filter(|_| m["reason"] == "compiler-artifact" && m["profile"]["test"] == true) else {
|
||||
continue;
|
||||
};
|
||||
if bins.iter().any(|b| b.exe == exe) {
|
||||
continue;
|
||||
}
|
||||
let manifest = std::path::Path::new(m["manifest_path"].as_str().unwrap_or_default()).to_path_buf();
|
||||
let dir = manifest.parent().map(|d| d.to_path_buf()).unwrap_or_else(root);
|
||||
let pkg = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let kind = m["target"]["kind"][0].as_str().unwrap_or("");
|
||||
let label = format!("{pkg} {kind} {}", m["target"]["name"].as_str().unwrap_or(""));
|
||||
bins.push(TestBin { label, exe: exe.to_string(), dir: dir.to_string_lossy().into_owned() });
|
||||
}
|
||||
if bins.is_empty() {
|
||||
return Err("cargo test --no-run built no test executables".into());
|
||||
}
|
||||
Ok(bins)
|
||||
}
|
||||
|
||||
/// The doc tests (`cargo test --doc`), to run as a side job.
|
||||
pub fn doc_tests(cargo_args: &[&str], test_args: &[&str]) -> (String, Command) {
|
||||
let mut c = cargo();
|
||||
c.args(["test", "--workspace", "--profile", "ci", "--doc"]).args(cargo_args).arg("--").args(test_args);
|
||||
("doc tests".into(), c)
|
||||
}
|
||||
Reference in new issue
Block a user