catalog.rs reads ground-game's schema 2 and refuses an unknown schema by name: a reader that silently accepted schema 1 would answer questions about aspects over a file that has none. config.rs holds the two types ADR-0022 chose. Configuration is identity and round-trips anything the catalog names, including modules with no kernel path; Rules is behaviour, exhaustive, no catch-all. resolve() is the boundary and emits the two distinct errors -- "known module with no kernel path (status: proposed)" vs "not a module the catalog has" -- which is the entire reason this shape was chosen over a per-aspect enum. Nothing about aspects, defaults or module status is written in our source; all of it is read. The tests find the proposed module by searching for status: proposed rather than naming one, so implementing it upstream makes the test look elsewhere instead of going stale. Four mutations, four red. scoped_plus_attack_soothe now resolves -- the combination the three-armed enum could not express. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
182 lines
5.9 KiB
Rust
182 lines
5.9 KiB
Rust
//! 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<String>,
|
|
/// The monolithic experiment ids this module used to be part of.
|
|
#[serde(default)]
|
|
pub legacy_experiment_ids: Vec<String>,
|
|
#[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<String>,
|
|
/// 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<String>,
|
|
#[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<String>,
|
|
#[serde(default)]
|
|
aspects: Vec<Aspect>,
|
|
#[serde(default)]
|
|
modules: Vec<Module>,
|
|
#[serde(default)]
|
|
profiles: Vec<Profile>,
|
|
}
|
|
|
|
/// The catalog, parsed.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Catalog {
|
|
pub default_baseline: String,
|
|
pub default_profile: Option<String>,
|
|
pub aspects: Vec<Aspect>,
|
|
pub modules: Vec<Module>,
|
|
pub profiles: Vec<Profile>,
|
|
}
|
|
|
|
/// 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<Catalog, String> {
|
|
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<String, String> {
|
|
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())
|
|
}
|
|
}
|