mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
340 lines
13 KiB
Rust
340 lines
13 KiB
Rust
//! Dependency layering rules (plan/architecture.md §3).
|
|
//!
|
|
//! 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 `drawcraft-`
|
|
/// prefix.
|
|
pub const TABLE: &[(&str, Class)] = &[
|
|
("geom", Class::Layer(0)),
|
|
("color", Class::Layer(0)),
|
|
("doc", Class::Layer(1)),
|
|
("pathops", Class::Layer(2)),
|
|
("trace", Class::Layer(2)),
|
|
("text", Class::Layer(2)),
|
|
("effects", Class::Layer(2)),
|
|
("render", Class::Layer(3)),
|
|
("svg", Class::Layer(3)),
|
|
("pdf", Class::Layer(3)),
|
|
("format", Class::Layer(3)),
|
|
("tools", Class::Layer(4)),
|
|
("engine", Class::Layer(5)),
|
|
("ui-egui", Class::Layer(6)),
|
|
("mcp", Class::Layer(6)),
|
|
("testkit", Class::Testkit),
|
|
// L7 apps and tooling
|
|
("drawcraft", Class::Exempt),
|
|
("cli", Class::Exempt),
|
|
("web", Class::Exempt),
|
|
("xtask", Class::Exempt),
|
|
];
|
|
|
|
/// Explicit orderings *within* a layer (earlier may be used by later).
|
|
/// The L0 foundation is a small chain: `raster` builds on `color` and
|
|
/// `geom`, which the §3 diagram draws on one line. The GPU backend (`gpu`)
|
|
/// reuses the CPU reference (`compose`) for LUTs and parity tests.
|
|
pub const INTRA_LAYER_ORDER: &[&[&str]] = &[&["geom", "color"], &["pathops", "effects"], &["pathops", "trace"]];
|
|
|
|
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", "winit", "egui_kittest", "rfd", "bevy*"];
|
|
|
|
/// First layer allowed to use UI crates.
|
|
pub const UI_MIN_LAYER: u8 = 6;
|
|
|
|
pub fn short_name(pkg: &str) -> &str {
|
|
pkg.strip_prefix("drawcraft-").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 },
|
|
UiBelowL6 { krate: String, dep: String, layer: u8 },
|
|
}
|
|
|
|
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}: drawcraft-testkit may only be a dev-dependency")
|
|
}
|
|
Violation::UiBelowL6 { krate, dep, layer } => {
|
|
write!(f, "{krate} (L{layer}) depends on UI crate `{dep}`; UI toolkits are only allowed in L6+")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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(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 layer < UI_MIN_LAYER && is_ui_crate(&d.name) {
|
|
out.push(Violation::UiBelowL6 { 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("drawcraft-geom", &[("kurbo", Normal, false)]),
|
|
c("drawcraft-doc", &[("drawcraft-geom", Normal, true)]),
|
|
c("drawcraft-engine", &[("drawcraft-doc", Normal, true), ("drawcraft-testkit", Dev, true)]),
|
|
c("drawcraft-ui-egui", &[("drawcraft-engine", Normal, true), ("egui", Normal, false)]),
|
|
c("drawcraft-cli", &[("drawcraft-ui-egui", Normal, true)]),
|
|
];
|
|
assert!(check(&g).is_empty(), "{:?}", check(&g));
|
|
}
|
|
|
|
#[test]
|
|
fn upward_dependency_flagged() {
|
|
let v = check(&[c("drawcraft-doc", &[("drawcraft-engine", Normal, true)])]);
|
|
assert!(matches!(v[..], [Violation::Upward { from: 1, to: 5, .. }]));
|
|
}
|
|
|
|
#[test]
|
|
fn sideways_dependency_flagged() {
|
|
let v = check(&[c("drawcraft-text", &[("drawcraft-pathops", Normal, true)])]);
|
|
assert!(matches!(v[..], [Violation::Upward { from: 2, to: 2, .. }]));
|
|
}
|
|
|
|
#[test]
|
|
fn l0_foundation_chain_allowed_one_way() {
|
|
assert!(check(&[c("drawcraft-color", &[("drawcraft-geom", Normal, true)])]).is_empty());
|
|
let v = check(&[c("drawcraft-geom", &[("drawcraft-color", Normal, true)])]);
|
|
assert!(matches!(v[..], [Violation::Upward { from: 0, to: 0, .. }]));
|
|
}
|
|
|
|
#[test]
|
|
fn self_dev_dependency_ignored() {
|
|
assert!(check(&[c("drawcraft-doc", &[("drawcraft-doc", Dev, true)])]).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn upward_dev_dependency_flagged() {
|
|
let v = check(&[c("drawcraft-geom", &[("drawcraft-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("drawcraft-engine", &[(dep, Normal, false)])]);
|
|
assert!(matches!(v[..], [Violation::UiBelowL6 { layer: 5, .. }]), "{dep}");
|
|
}
|
|
assert!(check(&[c("drawcraft-engine", &[("egui_extras_not", Normal, false)])]).is_empty());
|
|
assert!(check(&[c("drawcraft-mcp", &[("winit", Normal, false)])]).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn unregistered_crate_is_error() {
|
|
let v = check(&[c("drawcraft-mystery", &[])]);
|
|
assert!(matches!(&v[..], [Violation::Unregistered { krate }] if krate == "drawcraft-mystery"));
|
|
assert!(v[0].to_string().contains("register"));
|
|
}
|
|
|
|
#[test]
|
|
fn testkit_only_as_dev_dependency() {
|
|
let v = check(&[c("drawcraft-render", &[("drawcraft-testkit", Normal, true)])]);
|
|
assert!(matches!(v[..], [Violation::TestkitAsNormalDep { .. }]));
|
|
assert!(check(&[c("drawcraft-render", &[("drawcraft-testkit", Dev, true)])]).is_empty());
|
|
// testkit itself may use anything up to L5 but not L6 crates.
|
|
assert!(check(&[c("drawcraft-testkit", &[("drawcraft-engine", Normal, true)])]).is_empty());
|
|
assert!(!check(&[c("drawcraft-testkit", &[("drawcraft-ui-egui", Normal, true)])]).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn apps_and_xtask_exempt() {
|
|
for app in ["drawcraft", "drawcraft-cli", "drawcraft-web", "xtask"] {
|
|
assert!(check(&[c(app, &[("egui", Normal, false), ("drawcraft-ui-egui", Normal, true)])]).is_empty());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn metadata_parsing() {
|
|
let meta: Value = serde_json::from_str(
|
|
r#"{"packages":[
|
|
{"name":"drawcraft-doc","dependencies":[
|
|
{"name":"drawcraft-geom","kind":null,"path":"/x/crates/geom"},
|
|
{"name":"serde","kind":null},
|
|
{"name":"proptest","kind":"dev"}]},
|
|
{"name":"drawcraft-geom","dependencies":[]}
|
|
]}"#,
|
|
)
|
|
.unwrap();
|
|
let g = from_metadata(&meta).unwrap();
|
|
assert_eq!(g.len(), 2);
|
|
let doc = g.iter().find(|c| c.name == "drawcraft-doc").unwrap();
|
|
assert!(doc.deps[0].workspace && !doc.deps[1].workspace);
|
|
assert_eq!(doc.deps[2].kind, Dev);
|
|
assert!(check(&g).is_empty());
|
|
}
|
|
}
|