//! ground-game's rules catalog, schema 2 (CB-WP-0048, [ADR-0022]). //! //! An **aspect** is an orthogonal design dimension of the game; a //! **module** is one concrete answer on one aspect; a **profile** is a //! named list of modules. A configuration is a baseline plus at most one //! module per aspect — a point in aspect space. //! //! **This reads; it does not decide.** The aspect list, the module list, //! which modules are default and which are merely proposed are all //! ground-game's (ADR-0011). Hardcoding any of them here is F25's shape: //! a second copy of a number the edition already prints. //! //! [ADR-0022]: ../../../decisions/ADR-0022-a-configuration-is-a-point-in-aspect-space.md use serde::Deserialize; use std::collections::BTreeMap; /// The vendored catalog. Digested and freshness-checked by /// `edition-check` against the sibling checkout. const CATALOG_YAML: &str = include_str!("../../../editions/catalog.yaml"); /// An orthogonal design dimension. #[derive(Debug, Clone, Deserialize)] pub struct Aspect { pub id: String, #[serde(default)] pub title: String, pub default_module: String, #[serde(default)] pub summary: String, } /// One concrete design on one aspect. #[derive(Debug, Clone, Deserialize)] pub struct Module { pub module_id: String, pub aspect: String, #[serde(default)] pub path: String, #[serde(default)] pub is_default: bool, #[serde(default = "yes")] pub selectable: bool, /// `baseline-default`, `measured`, `unmeasured`, `proposed`, … /// /// **Read, never assumed.** CB-RES-0010 §5.6 requires a proposed /// module to be refused or to no-op *loudly*; we refuse, and this is /// the field that says which are which. #[serde(default)] pub status: String, #[serde(default)] pub rules_delta: Option, /// The monolithic experiment ids this module used to be part of. #[serde(default)] pub legacy_experiment_ids: Vec, #[serde(default)] pub summary: String, } fn yes() -> bool { true } /// A named list of modules. #[derive(Debug, Clone, Deserialize)] pub struct Profile { pub profile_id: String, #[serde(default)] pub title: String, #[serde(default)] pub modules: Vec, /// The monolithic experiment this profile replaces, if any. **This is /// where `--variant h2` resolves**, and it is read from the catalog /// rather than mapped in our source (ADR-0022 D2). #[serde(default)] pub legacy_experiment_id: Option, #[serde(default)] pub summary: String, #[serde(default)] pub status: String, } #[derive(Debug, Clone, Deserialize)] struct CatalogFile { schema_version: u32, default_baseline: String, #[serde(default)] default_profile: Option, #[serde(default)] aspects: Vec, #[serde(default)] modules: Vec, #[serde(default)] profiles: Vec, } /// The catalog, parsed. #[derive(Debug, Clone)] pub struct Catalog { pub default_baseline: String, pub default_profile: Option, pub aspects: Vec, pub modules: Vec, pub profiles: Vec, } /// The schema this reader understands. /// /// **Refusing a newer schema is the point.** Schema 1 named monolithic /// `variant_id` packages; a reader that silently accepted either would /// answer questions about aspects over a file that has none, which is the /// wrong-subject family (ADR-0018). If ground-game moves to schema 3 this /// says so by name rather than returning an empty aspect list. pub const SCHEMA: u32 = 2; /// Parse the vendored catalog. pub fn catalog() -> Result { let f: CatalogFile = serde_yaml::from_str(CATALOG_YAML).map_err(|e| format!("catalog.yaml: {e}"))?; if f.schema_version != SCHEMA { return Err(format!( "catalog.yaml is schema {}; this reader understands schema {SCHEMA} \ — the aspect vocabulary may have moved", f.schema_version )); } if f.aspects.is_empty() { return Err("catalog.yaml declares no aspects".into()); } Ok(Catalog { default_baseline: f.default_baseline, default_profile: f.default_profile, aspects: f.aspects, modules: f.modules, profiles: f.profiles, }) } impl Catalog { pub fn aspect(&self, id: &str) -> Option<&Aspect> { self.aspects.iter().find(|a| a.id == id) } pub fn module(&self, id: &str) -> Option<&Module> { self.modules.iter().find(|m| m.module_id == id) } pub fn profile(&self, id: &str) -> Option<&Profile> { self.profiles.iter().find(|p| p.profile_id == id) } /// Every aspect at its default module — the printed game. pub fn defaults(&self) -> BTreeMap { self.aspects .iter() .map(|a| (a.id.clone(), a.default_module.clone())) .collect() } /// The profile a legacy experiment id names (ADR-0022 D2). /// /// **From the catalog's own `legacy_experiment_id`**, so the mapping /// has one home. A table in our source would be a second copy of /// ground-game's decision, which is exactly what F25 is about. pub fn profile_for_legacy(&self, experiment_id: &str) -> Option<&Profile> { self.profiles .iter() .find(|p| p.legacy_experiment_id.as_deref() == Some(experiment_id)) } /// A module id's aspect, taken from the module entry rather than by /// splitting on the dot. /// /// **The dot is a naming convention, not a schema.** `module_id` /// carries the aspect prefix today and the entry carries an explicit /// `aspect:` field; parsing the string would work until the day a /// module id contains a second dot, and would then be confidently /// wrong rather than absent. pub fn aspect_of(&self, module_id: &str) -> Option<&str> { self.module(module_id).map(|m| m.aspect.as_str()) } }