//! 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 `deckcraft-` /// prefix. pub const TABLE: &[(&str, Class)] = &[ ("geom", Class::Layer(0)), // Codecs and containers (ported from FilmCraft): L0 standalone leaves, L1 codecs over // `bitstream`, and `media` (probe, decode, playback mixer) at L2. ("bitstream", Class::Standalone), ("matroska", Class::Standalone), ("ogg", Class::Standalone), ("opus", Class::Standalone), ("h264", Class::Layer(1)), ("hevc", Class::Layer(1)), ("vp9", Class::Layer(1)), ("av1", Class::Layer(1)), ("isobmff", Class::Layer(1)), ("media", Class::Layer(2)), ("color", Class::Layer(0)), // WMF/EMF reader (ported from WordCraft). ("metafile", Class::Layer(0)), ("model", Class::Layer(1)), ("fonts", Class::Layer(1)), ("text", Class::Layer(2)), ("anim", Class::Layer(2)), ("format", Class::Layer(2)), ("render", Class::Layer(3)), ("pptx", Class::Layer(3)), ("pdf", Class::Layer(4)), ("engine", Class::Layer(5)), ("ui-egui", Class::Layer(6)), ("mcp", Class::Layer(6)), ("testkit", Class::Testkit), // L7 apps and tooling ("deckcraft", Class::Exempt), ("cli", Class::Exempt), ("web", Class::Exempt), ("xtask", Class::Exempt), ]; /// Upward dev-dependencies allowed for integration tests: the PPTX round-trip tests drive the /// engine (build a deck with commands, export, re-import). pub const DEV_EXCEPTIONS: &[(&str, &str)] = &[("pptx", "engine")]; /// 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", "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("deckcraft-").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}: deckcraft-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() }); } } Some(dc) => { let to = dc.layer().unwrap_or(u8::MAX); let dev_ok = d.kind == DepKind::Dev && DEV_EXCEPTIONS.contains(&(short_name(&c.name), short_name(&d.name))); if to >= layer && !dev_ok && !(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("deckcraft-geom", &[("kurbo", Normal, false)]), c("deckcraft-model", &[("deckcraft-geom", Normal, true)]), c("deckcraft-engine", &[("deckcraft-model", Normal, true), ("deckcraft-testkit", Dev, true)]), c("deckcraft-ui-egui", &[("deckcraft-engine", Normal, true), ("egui", Normal, false)]), c("deckcraft-cli", &[("deckcraft-ui-egui", Normal, true)]), ]; assert!(check(&g).is_empty(), "{:?}", check(&g)); } #[test] fn upward_dependency_flagged() { let v = check(&[c("deckcraft-model", &[("deckcraft-engine", Normal, true)])]); assert!(matches!(v[..], [Violation::Upward { from: 1, to: 5, .. }])); } #[test] fn sideways_dependency_flagged() { let v = check(&[c("deckcraft-text", &[("deckcraft-anim", Normal, true)])]); assert!(matches!(v[..], [Violation::Upward { from: 2, to: 2, .. }])); } #[test] fn l0_foundations_are_independent() { let v = check(&[c("deckcraft-geom", &[("deckcraft-color", Normal, true)])]); assert!(matches!(v[..], [Violation::Upward { from: 0, to: 0, .. }])); } #[test] fn self_dev_dependency_ignored() { assert!(check(&[c("deckcraft-model", &[("deckcraft-model", Dev, true)])]).is_empty()); } #[test] fn upward_dev_dependency_flagged() { let v = check(&[c("deckcraft-geom", &[("deckcraft-model", 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("deckcraft-engine", &[(dep, Normal, false)])]); assert!(matches!(v[..], [Violation::UiBelowL6 { layer: 5, .. }]), "{dep}"); } assert!(check(&[c("deckcraft-engine", &[("egui_extras_not", Normal, false)])]).is_empty()); assert!(check(&[c("deckcraft-mcp", &[("winit", Normal, false)])]).is_empty()); } #[test] fn unregistered_crate_is_error() { let v = check(&[c("deckcraft-mystery", &[])]); assert!(matches!(&v[..], [Violation::Unregistered { krate }] if krate == "deckcraft-mystery")); assert!(v[0].to_string().contains("register")); } #[test] fn testkit_only_as_dev_dependency() { let v = check(&[c("deckcraft-render", &[("deckcraft-testkit", Normal, true)])]); assert!(matches!(v[..], [Violation::TestkitAsNormalDep { .. }])); assert!(check(&[c("deckcraft-render", &[("deckcraft-testkit", Dev, true)])]).is_empty()); // testkit itself may use anything up to L5 but not L6 crates. assert!(check(&[c("deckcraft-testkit", &[("deckcraft-engine", Normal, true)])]).is_empty()); assert!(!check(&[c("deckcraft-testkit", &[("deckcraft-ui-egui", Normal, true)])]).is_empty()); } #[test] fn apps_and_xtask_exempt() { for app in ["deckcraft", "deckcraft-cli", "deckcraft-web", "xtask"] { assert!(check(&[c(app, &[("egui", Normal, false), ("deckcraft-ui-egui", Normal, true)])]).is_empty()); } } #[test] fn metadata_parsing() { let meta: Value = serde_json::from_str( r#"{"packages":[ {"name":"deckcraft-model","dependencies":[ {"name":"deckcraft-geom","kind":null,"path":"/x/crates/geom"}, {"name":"serde","kind":null}, {"name":"proptest","kind":"dev"}]}, {"name":"deckcraft-geom","dependencies":[]} ]}"#, ) .unwrap(); let g = from_metadata(&meta).unwrap(); assert_eq!(g.len(), 2); let doc = g.iter().find(|c| c.name == "deckcraft-model").unwrap(); assert!(doc.deps[0].workspace && !doc.deps[1].workspace); assert_eq!(doc.deps[2].kind, Dev); assert!(check(&g).is_empty()); } }