//! 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 { 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 `vectorcraft-` /// prefix. pub const TABLE: &[(&str, Class)] = &[ ("geom", Class::Layer(0)), ("color", Class::Layer(0)), ("doc", Class::Layer(1)), ("pathops", Class::Layer(2)), ("brush", 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 ("vectorcraft", Class::Exempt), ("cli", Class::Exempt), ("web", Class::Exempt), ("xtask", Class::Exempt), // Crates added later (append-only). ("cad", Class::Layer(3)), ("eps", Class::Layer(3)), ("metafile", Class::Layer(3)), ("plugins", Class::Layer(2)), ]; /// 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. EPS previews use the renderer's /// TIFF writer. Live effects run effect plug-ins. pub const INTRA_LAYER_ORDER: &[&[&str]] = &[&["geom", "color"], &["pathops", "effects"], &["pathops", "trace"], &["text", "effects"], &["render", "eps"], &["plugins", "effects"]]; 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("vectorcraft-").unwrap_or(pkg) } pub fn classify(pkg: &str) -> Option { 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, } #[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}: vectorcraft-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 { 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() }); } // The testkit as a dev-dependency is how tests use it. 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 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, 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) -> 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("vectorcraft-geom", &[("kurbo", Normal, false)]), c("vectorcraft-doc", &[("vectorcraft-geom", Normal, true)]), c("vectorcraft-engine", &[("vectorcraft-doc", Normal, true), ("vectorcraft-testkit", Dev, true)]), c("vectorcraft-ui-egui", &[("vectorcraft-engine", Normal, true), ("egui", Normal, false)]), c("vectorcraft-cli", &[("vectorcraft-ui-egui", Normal, true)]), ]; assert!(check(&g).is_empty(), "{:?}", check(&g)); } #[test] fn upward_dependency_flagged() { let v = check(&[c("vectorcraft-doc", &[("vectorcraft-engine", Normal, true)])]); assert!(matches!(v[..], [Violation::Upward { from: 1, to: 5, .. }])); } #[test] fn sideways_dependency_flagged() { let v = check(&[c("vectorcraft-text", &[("vectorcraft-pathops", Normal, true)])]); assert!(matches!(v[..], [Violation::Upward { from: 2, to: 2, .. }])); } #[test] fn l0_foundation_chain_allowed_one_way() { assert!(check(&[c("vectorcraft-color", &[("vectorcraft-geom", Normal, true)])]).is_empty()); let v = check(&[c("vectorcraft-geom", &[("vectorcraft-color", Normal, true)])]); assert!(matches!(v[..], [Violation::Upward { from: 0, to: 0, .. }])); } #[test] fn self_dev_dependency_ignored() { assert!(check(&[c("vectorcraft-doc", &[("vectorcraft-doc", Dev, true)])]).is_empty()); } #[test] fn upward_dev_dependency_flagged() { let v = check(&[c("vectorcraft-geom", &[("vectorcraft-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("vectorcraft-engine", &[(dep, Normal, false)])]); assert!(matches!(v[..], [Violation::UiBelowL6 { layer: 5, .. }]), "{dep}"); } assert!(check(&[c("vectorcraft-engine", &[("egui_extras_not", Normal, false)])]).is_empty()); assert!(check(&[c("vectorcraft-mcp", &[("winit", Normal, false)])]).is_empty()); } #[test] fn unregistered_crate_is_error() { let v = check(&[c("vectorcraft-mystery", &[])]); assert!(matches!(&v[..], [Violation::Unregistered { krate }] if krate == "vectorcraft-mystery")); assert!(v[0].to_string().contains("register")); } #[test] fn testkit_only_as_dev_dependency() { let v = check(&[c("vectorcraft-render", &[("vectorcraft-testkit", Normal, true)])]); assert!(matches!(v[..], [Violation::TestkitAsNormalDep { .. }])); assert!(check(&[c("vectorcraft-render", &[("vectorcraft-testkit", Dev, true)])]).is_empty()); // testkit itself may use anything up to L5 but not L6 crates. assert!(check(&[c("vectorcraft-testkit", &[("vectorcraft-engine", Normal, true)])]).is_empty()); assert!(!check(&[c("vectorcraft-testkit", &[("vectorcraft-ui-egui", Normal, true)])]).is_empty()); } #[test] fn apps_and_xtask_exempt() { for app in ["vectorcraft", "vectorcraft-cli", "vectorcraft-web", "xtask"] { assert!(check(&[c(app, &[("egui", Normal, false), ("vectorcraft-ui-egui", Normal, true)])]).is_empty()); } } #[test] fn metadata_parsing() { let meta: Value = serde_json::from_str( r#"{"packages":[ {"name":"vectorcraft-doc","dependencies":[ {"name":"vectorcraft-geom","kind":null,"path":"/x/crates/geom"}, {"name":"serde","kind":null}, {"name":"proptest","kind":"dev"}]}, {"name":"vectorcraft-geom","dependencies":[]} ]}"#, ) .unwrap(); let g = from_metadata(&meta).unwrap(); assert_eq!(g.len(), 2); let doc = g.iter().find(|c| c.name == "vectorcraft-doc").unwrap(); assert!(doc.deps[0].workspace && !doc.deps[1].workspace); assert_eq!(doc.deps[2].kind, Dev); assert!(check(&g).is_empty()); } }