338 lines
12 KiB
Rust
338 lines
12 KiB
Rust
//! Dependency layering rules (plan/architecture.md §3), enforced by `cargo xtask layers`.
|
|
//!
|
|
//! The rule engine works on a small, metadata-independent model so it can be unit-tested;
|
|
//! `from_metadata` builds that model from `cargo metadata`. (Structure shared with PhotoCraft.)
|
|
|
|
use serde_json::Value;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Class {
|
|
/// Regular layered crate.
|
|
Layer(u8),
|
|
/// Layer 0/1 core that may depend only on the workspace crates listed in `STANDALONE_DEPS`.
|
|
Standalone(u8),
|
|
/// Test tooling: dev-dependency only for other crates.
|
|
Testkit,
|
|
/// Binaries, build tooling: exempt.
|
|
Exempt,
|
|
}
|
|
|
|
impl Class {
|
|
fn layer(self) -> Option<u8> {
|
|
match self {
|
|
Class::Layer(l) | Class::Standalone(l) => Some(l),
|
|
Class::Testkit => Some(7),
|
|
Class::Exempt => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Every workspace crate (package name without the `printcraft-` prefix) and its layer.
|
|
pub const TABLE: &[(&str, Class)] = &[
|
|
// L0 foundation (standalone, publishable)
|
|
("geom", Class::Standalone(0)),
|
|
("filters", Class::Standalone(0)),
|
|
("crypt", Class::Standalone(0)),
|
|
("arlington", Class::Standalone(0)),
|
|
// L1 object layer
|
|
("cos", Class::Standalone(1)),
|
|
// L2 core model
|
|
("content", Class::Layer(2)),
|
|
("model", Class::Layer(2)),
|
|
("color", Class::Layer(2)),
|
|
("fonts", Class::Layer(2)),
|
|
("ops", Class::Layer(2)),
|
|
// L3 core services
|
|
("render", Class::Layer(3)),
|
|
("text", Class::Layer(3)),
|
|
("annot", Class::Layer(3)),
|
|
("forms", Class::Layer(3)),
|
|
("js", Class::Layer(3)),
|
|
("xfa", Class::Layer(3)),
|
|
("ml", Class::Layer(3)),
|
|
// L4 features
|
|
("edit", Class::Layer(4)),
|
|
("organize", Class::Layer(4)),
|
|
("security", Class::Layer(4)),
|
|
("redact", Class::Layer(4)),
|
|
("xfdf", Class::Layer(4)),
|
|
("sign", Class::Layer(4)),
|
|
("ocr", Class::Layer(4)),
|
|
("create", Class::Layer(4)),
|
|
("export", Class::Layer(4)),
|
|
("optimize", Class::Layer(4)),
|
|
("preflight", Class::Layer(4)),
|
|
("prepress", Class::Layer(4)),
|
|
("a11y", Class::Layer(4)),
|
|
("compare", Class::Layer(4)),
|
|
("measure", Class::Layer(4)),
|
|
("search", Class::Layer(4)),
|
|
("print", Class::Layer(4)),
|
|
("media", Class::Layer(4)),
|
|
("ai", Class::Layer(4)),
|
|
// L5 interaction
|
|
("viewport", Class::Layer(5)),
|
|
("tools", Class::Layer(5)),
|
|
// L6 façade
|
|
("engine", Class::Layer(6)),
|
|
// L7 platform + frontends
|
|
("platform", Class::Layer(7)),
|
|
("ui-common", Class::Layer(7)),
|
|
("ui-egui", Class::Layer(7)),
|
|
("automation", Class::Layer(7)),
|
|
// test support
|
|
("testkit", Class::Testkit),
|
|
("oracle", Class::Testkit),
|
|
// L8 apps + tooling
|
|
("printcraft", Class::Exempt),
|
|
("cli", Class::Exempt),
|
|
("web", Class::Exempt),
|
|
("xtask", Class::Exempt),
|
|
];
|
|
|
|
/// Workspace crates a standalone crate may use (architecture §3 rule 3: `cos → filters, crypt, geom`).
|
|
pub const STANDALONE_DEPS: &[(&str, &[&str])] = &[("cos", &["filters", "crypt", "geom"])];
|
|
|
|
/// Allowed same-layer edges (architecture §3 rule 1). `forms → js` is deliberately absent.
|
|
pub const SIDEWAYS: &[(&str, &str)] = &[
|
|
("redact", "edit"),
|
|
("a11y", "preflight"),
|
|
("prepress", "preflight"),
|
|
("export", "ocr"),
|
|
("sign", "security"),
|
|
("ui-egui", "ui-common"),
|
|
("ui-egui", "platform"),
|
|
("automation", "platform"),
|
|
];
|
|
|
|
/// External crates that constitute a UI toolkit / windowing dependency (prefix match with `*`).
|
|
pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "egui_extras", "egui_dock", "egui_tiles", "rfd", "wgpu*"];
|
|
pub const UI_MIN_LAYER: u8 = 7;
|
|
|
|
pub fn short_name(pkg: &str) -> &str {
|
|
pkg.strip_prefix("printcraft-").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,
|
|
})
|
|
}
|
|
|
|
fn sideways_allowed(from: &str, to: &str) -> bool {
|
|
SIDEWAYS.contains(&(short_name(from), short_name(to)))
|
|
}
|
|
|
|
fn standalone_allowed(from: &str, to: &str) -> bool {
|
|
STANDALONE_DEPS.iter().any(|(f, ok)| *f == short_name(from) && ok.contains(&short_name(to)))
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum DepKind {
|
|
Normal,
|
|
Dev,
|
|
Build,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Dep {
|
|
pub name: String,
|
|
pub kind: DepKind,
|
|
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, dep: String },
|
|
UiBelowL7 { 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 (plan/architecture.md §3)")
|
|
}
|
|
Violation::Upward { krate, dep, from, to, kind } => {
|
|
write!(f, "{krate} (L{from}) -> {dep} (L{to}) [{kind:?}]: may only depend on lower layers (or an allowed sideways edge)")
|
|
}
|
|
Violation::StandaloneHasWorkspaceDep { krate, dep } => write!(f, "{krate}: standalone crate must not depend on workspace crate {dep}"),
|
|
Violation::TestkitAsNormalDep { krate, dep } => write!(f, "{krate}: {dep} may only be a dev-dependency"),
|
|
Violation::UiBelowL7 { krate, dep, layer } => {
|
|
write!(f, "{krate} (L{layer}) depends on UI crate `{dep}`; UI toolkits are only allowed in L7+")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
if d.name == c.name {
|
|
continue;
|
|
}
|
|
if d.workspace {
|
|
if matches!(class, Class::Standalone(_)) && !standalone_allowed(&c.name, &d.name) {
|
|
out.push(Violation::StandaloneHasWorkspaceDep { krate: c.name.clone(), dep: d.name.clone() });
|
|
continue;
|
|
}
|
|
match classify(&d.name) {
|
|
None => {}
|
|
Some(Class::Testkit) => {
|
|
if d.kind != DepKind::Dev && class != Class::Testkit {
|
|
out.push(Violation::TestkitAsNormalDep { krate: c.name.clone(), dep: d.name.clone() });
|
|
}
|
|
}
|
|
Some(dc) => {
|
|
let to = dc.layer().unwrap_or(u8::MAX);
|
|
let ok = to < layer || (to == layer && sideways_allowed(&c.name, &d.name));
|
|
if !ok {
|
|
out.push(Violation::Upward { krate: c.name.clone(), dep: d.name.clone(), from: layer, to: to.min(8), kind: d.kind });
|
|
}
|
|
}
|
|
}
|
|
} else if layer < UI_MIN_LAYER && is_ui_crate(&d.name) && !(class == Class::Testkit || d.kind == DepKind::Dev && d.name == "egui_kittest")
|
|
{
|
|
out.push(Violation::UiBelowL7 { krate: c.name.clone(), dep: d.name.clone(), layer });
|
|
}
|
|
}
|
|
}
|
|
out.sort_by_key(|v| v.to_string());
|
|
out.dedup();
|
|
out
|
|
}
|
|
|
|
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());
|
|
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(l)) => format!("L{l} standalone"),
|
|
Some(Class::Testkit) => "test support".into(),
|
|
Some(Class::Exempt) => "exempt".into(),
|
|
None => "UNREGISTERED".into(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::DepKind::*;
|
|
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() }
|
|
}
|
|
|
|
#[test]
|
|
fn current_graph_shape_passes() {
|
|
let g = [
|
|
c("printcraft-geom", &[]),
|
|
c("printcraft-render", &[("hayro", Normal, false), ("lopdf", Normal, false)]),
|
|
c("printcraft-engine", &[("printcraft-render", Normal, true)]),
|
|
c("printcraft-ui-egui", &[("printcraft-engine", Normal, true), ("egui", Normal, false), ("egui_kittest", Dev, false)]),
|
|
c("printcraft", &[("printcraft-ui-egui", Normal, true)]),
|
|
];
|
|
assert!(check(&g).is_empty(), "{:?}", check(&g));
|
|
}
|
|
|
|
#[test]
|
|
fn upward_and_sideways_edges_flagged() {
|
|
let v = check(&[c("printcraft-model", &[("printcraft-engine", Normal, true)])]);
|
|
assert!(matches!(v[..], [Violation::Upward { from: 2, to: 6, .. }]));
|
|
// forms -> js must go through the ActionRunner trait, never directly.
|
|
let v = check(&[c("printcraft-forms", &[("printcraft-js", Normal, true)])]);
|
|
assert!(matches!(v[..], [Violation::Upward { from: 3, to: 3, .. }]));
|
|
}
|
|
|
|
#[test]
|
|
fn listed_sideways_edges_allowed() {
|
|
assert!(check(&[c("printcraft-redact", &[("printcraft-edit", Normal, true)])]).is_empty());
|
|
assert!(!check(&[c("printcraft-edit", &[("printcraft-redact", Normal, true)])]).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn cos_may_use_only_its_listed_foundation() {
|
|
assert!(check(&[c("printcraft-cos", &[("printcraft-filters", Normal, true), ("printcraft-crypt", Normal, true)])]).is_empty());
|
|
let v = check(&[c("printcraft-cos", &[("printcraft-arlington", Normal, true)])]);
|
|
assert!(matches!(v[..], [Violation::StandaloneHasWorkspaceDep { .. }]));
|
|
let v = check(&[c("printcraft-filters", &[("printcraft-geom", Normal, true)])]);
|
|
assert!(matches!(v[..], [Violation::StandaloneHasWorkspaceDep { .. }]));
|
|
}
|
|
|
|
#[test]
|
|
fn ui_crates_below_l7_flagged() {
|
|
for dep in ["egui", "eframe", "winit", "rfd", "wgpu", "wgpu-core", "egui_dock"] {
|
|
let v = check(&[c("printcraft-engine", &[(dep, Normal, false)])]);
|
|
assert!(matches!(v[..], [Violation::UiBelowL7 { layer: 6, .. }]), "{dep}");
|
|
}
|
|
assert!(check(&[c("printcraft-platform", &[("winit", Normal, false)])]).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_support_only_as_dev_dependency() {
|
|
let v = check(&[c("printcraft-cos", &[("printcraft-testkit", Normal, true)])]);
|
|
assert!(!v.is_empty());
|
|
assert!(check(&[c("printcraft-render", &[("printcraft-oracle", Dev, true)])]).is_empty());
|
|
let v = check(&[c("printcraft-render", &[("printcraft-oracle", Normal, true)])]);
|
|
assert!(matches!(v[..], [Violation::TestkitAsNormalDep { .. }]));
|
|
}
|
|
|
|
#[test]
|
|
fn unregistered_crate_is_error() {
|
|
let v = check(&[c("printcraft-mystery", &[])]);
|
|
assert!(matches!(&v[..], [Violation::Unregistered { krate }] if krate == "printcraft-mystery"));
|
|
}
|
|
|
|
#[test]
|
|
fn apps_exempt() {
|
|
for app in ["printcraft", "printcraft-cli", "printcraft-web", "xtask"] {
|
|
assert!(check(&[c(app, &[("egui", Normal, false), ("printcraft-ui-egui", Normal, true)])]).is_empty());
|
|
}
|
|
}
|
|
}
|