use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::composition::Compositions;
use crate::library::Library;
use crate::policy::Policy;
pub const KITS_VERSION: u32 = 3;
pub const KITS_FILE: &str = "kits.ron";
pub const MAX_DIVISIONS: u32 = 8;
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Lattice {
#[serde(default = "one")]
pub face_bands: u32,
#[serde(default = "three")]
pub snap_divisor: u32,
#[serde(default = "four_metres")]
pub cell_height: f32,
}
impl Default for Lattice {
fn default() -> Self {
Lattice {
face_bands: one(),
snap_divisor: three(),
cell_height: four_metres(),
}
}
}
impl Lattice {
fn validate(&self) -> Result<(), String> {
if self.snap_divisor < 2 || self.snap_divisor > MAX_DIVISIONS {
return Err(format!(
"kits: `snap_divisor` is {}; a tile divides between 2 and {MAX_DIVISIONS} ways. \
One leaves no rung between the corners, and past {MAX_DIVISIONS} the finest rung \
is smaller than the meshes it positions.",
self.snap_divisor
));
}
if self.face_bands == 0 || self.face_bands > MAX_DIVISIONS {
return Err(format!(
"kits: `face_bands` is {}; a face reads between 1 and {MAX_DIVISIONS} ways. Zero \
leaves every piece without cells, and past {MAX_DIVISIONS} the lattice is finer \
than the art.",
self.face_bands
));
}
if !(self.cell_height.is_finite() && self.cell_height > 0.0) {
return Err(format!(
"kits: `cell_height` is {}; a tile has a real, positive height in metres.",
self.cell_height
));
}
Ok(())
}
}
fn one() -> u32 {
1
}
fn three() -> u32 {
3
}
fn four_metres() -> f32 {
4.0
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Bind {
pub namespace: String,
pub dir: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Bash {
pub name: String,
pub kits: Vec<String>,
#[serde(default)]
pub note: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Kits {
pub version: u32,
#[serde(default)]
pub note: Option<String>,
#[serde(default)]
pub lattice: Lattice,
pub bind: Vec<Bind>,
pub bash: Vec<Bash>,
#[serde(default)]
pub authoring: Option<String>,
}
impl Kits {
pub fn parse(text: &str) -> Result<Kits, String> {
let k: Kits = ron::from_str(text).map_err(|e| format!("kits: {e}"))?;
k.validate()?;
Ok(k)
}
pub fn to_ron(&self) -> Result<String, String> {
ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
.map_err(|e| format!("kits: serialize: {e}"))
}
pub fn authoring_bind(&self) -> Option<&Bind> {
let want = self.authoring.as_deref()?;
self.bind.iter().find(|b| b.dir == want)
}
pub fn bash_named(&self, name: &str) -> Option<&Bash> {
self.bash.iter().find(|b| b.name == name)
}
fn validate(&self) -> Result<(), String> {
if self.version != KITS_VERSION {
return Err(format!(
"kits: version {} but this build reads {KITS_VERSION}. The binding is a schema, not \
a setting — regenerate it rather than editing the number.",
self.version
));
}
self.lattice.validate()?;
for (i, b) in self.bind.iter().enumerate() {
if !crate::naming::is_snake_case(&b.namespace) {
return Err(format!(
"kits: `{}` is not a usable namespace. A namespace is one snake_case segment — \
the `site` in `site/wall_corner`.",
b.namespace
));
}
if !crate::naming::is_snake_case(&b.dir) {
return Err(format!(
"kits: `{}` is not a usable directory name. Kits are snake_case directories \
under the project root.",
b.dir
));
}
if let Some(j) = self.bind.iter().position(|o| o.namespace == b.namespace)
&& j != i
{
return Err(format!(
"kits: `{}` is bound twice — to `{}` and to `{}`. A namespace is an \
interface and a directory is one implementation of it, so a project binds \
exactly one. Pick the skin this project uses.",
b.namespace, self.bind[j].dir, b.dir
));
}
if let Some(j) = self.bind.iter().position(|o| o.dir == b.dir)
&& j != i
{
return Err(format!(
"kits: `{}` is bound twice, to `{}` and to `{}`. One directory provides one \
namespace; a directory answering to two is a library that disagrees with \
itself, which `Library::namespace` refuses at load.",
b.dir, self.bind[j].namespace, b.namespace
));
}
}
for (i, b) in self.bash.iter().enumerate() {
if !crate::naming::is_snake_case(&b.name) {
return Err(format!(
"kits: `{}` is not a usable bash name. A bash is one snake_case segment — the \
name a map's `bash` field gives.",
b.name
));
}
if let Some(j) = self.bash.iter().position(|o| o.name == b.name)
&& j != i
{
return Err(format!(
"kits: bash `{}` is declared twice. A map names one by name, so two of them \
make the name ambiguous.",
b.name
));
}
if b.kits.is_empty() {
return Err(format!(
"kits: bash `{}` names no kits. A bash is what a map builds from, and an empty \
one offers nothing.",
b.name
));
}
for (k, ns) in b.kits.iter().enumerate() {
if !self.bind.iter().any(|d| d.namespace == *ns) {
return Err(format!(
"kits: bash `{}` names `{ns}`, which this project does not bind. Bind it, \
or take it out of the bash.",
b.name
));
}
if let Some(j) = b.kits.iter().position(|o| o == ns)
&& j != k
{
return Err(format!(
"kits: bash `{}` names `{ns}` twice. A bash is a set of kits, so the \
second naming says nothing the first did not.",
b.name
));
}
}
}
match (&self.authoring, self.bind.is_empty()) {
(None, true) => {}
(None, false) => {
return Err(format!(
"kits: {} kit(s) bound and no `authoring`. New meshes and new tiles have to land \
somewhere; name one of them.",
self.bind.len()
));
}
(Some(a), true) => {
return Err(format!(
"kits: `authoring` names `{a}` and nothing is bound. A project with no kits has \
nowhere for work to land, and says so by leaving this out."
));
}
(Some(a), false) if self.authoring_bind().is_none() => {
return Err(format!(
"kits: `authoring` names `{a}`, which is not a bound directory. New meshes and \
new tiles have to land in a kit this project actually loads."
));
}
(Some(_), false) => {}
}
Ok(())
}
}
#[derive(Clone, Debug)]
pub struct KitLayer {
pub dir: PathBuf,
pub namespace: String,
pub measured: Library,
pub library: Library,
pub policy: Policy,
}
#[derive(Clone, Debug)]
pub struct Bound {
pub kits: Vec<KitLayer>,
pub library: Library,
pub compositions: Compositions,
}
pub fn bound_library(project: &Path, kits: &Kits) -> Result<Bound, String> {
let mut layers = Vec::with_capacity(kits.bind.len());
let mut descriptors = Vec::new();
for b in &kits.bind {
let dir = project.join(&b.dir);
let layered = crate::policy::layered_library(&dir)?;
match layered.measured.namespace()? {
Some(ns) if ns != b.namespace => {
return Err(format!(
"{}: bound as `{}` but its pieces are `{ns}/*`. A directory is a skin and the \
namespace is the interface it implements — bind it as `{ns}`, or point `{}` at \
the directory that provides it.",
dir.display(),
b.namespace,
b.namespace
));
}
_ => {}
}
descriptors.extend(layered.library.descriptors.iter().cloned());
layers.push(KitLayer {
dir,
namespace: b.namespace.clone(),
measured: layered.measured,
library: layered.library,
policy: layered.policy,
});
}
let library = Library {
version: crate::library::LIBRARY_VERSION,
note: Some(format!(
"Derived: {} bound kit(s) merged. Not a file — edit a kit's own library.ron.",
layers.len()
)),
descriptors,
};
library.validate().map_err(|e| {
format!(
"{}: {e} — two bound kits define it. Check `{KITS_FILE}`: a namespace binds to one \
directory, and two skins of one kit cannot both be loaded.",
project.display()
)
})?;
let comp_path = project.join(Compositions::FILE);
let compositions = if comp_path.exists() {
let text = std::fs::read_to_string(&comp_path)
.map_err(|e| format!("{}: {e}", comp_path.display()))?;
Compositions::parse(&text).map_err(|e| format!("{}: {e}", comp_path.display()))?
} else {
Compositions {
version: crate::composition::COMPOSITIONS_VERSION,
..Default::default()
}
};
crate::composition::validate(&compositions.compositions, &library)
.map_err(|e| format!("{}: {e}", comp_path.display()))?;
Ok(Bound {
kits: layers,
library,
compositions,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn kits(bind: &[(&str, &str)], authoring: &str) -> Kits {
Kits {
version: KITS_VERSION,
note: None,
lattice: Lattice::default(),
bind: bind
.iter()
.map(|(n, d)| Bind {
namespace: (*n).to_owned(),
dir: (*d).to_owned(),
})
.collect(),
bash: Vec::new(),
authoring: (!authoring.is_empty()).then(|| authoring.to_owned()),
}
}
#[test]
fn a_binding_round_trips() {
let k = kits(&[("site", "site_greybox"), ("furniture", "furniture")], "furniture");
let text = k.to_ron().unwrap_or_else(|e| panic!("{e}"));
assert_eq!(Kits::parse(&text).unwrap_or_else(|e| panic!("{e}")), k);
}
#[test]
fn one_namespace_cannot_bind_to_two_directories() {
let text = kits(&[("site", "site"), ("site", "site_greybox")], "site")
.to_ron()
.unwrap_or_else(|e| panic!("{e}"));
let e = Kits::parse(&text).err().unwrap_or_default();
assert!(e.contains("bound twice"), "{e}");
assert!(e.contains("site_greybox"), "and it names both skins: {e}");
}
#[test]
fn one_directory_cannot_provide_two_namespaces() {
let text = kits(&[("site", "muddle"), ("lab", "muddle")], "muddle")
.to_ron()
.unwrap_or_else(|e| panic!("{e}"));
let e = Kits::parse(&text).err().unwrap_or_default();
assert!(e.contains("bound twice"), "{e}");
}
#[test]
fn authoring_must_name_a_bound_kit() {
let text = kits(&[("site", "site")], "furniture")
.to_ron()
.unwrap_or_else(|e| panic!("{e}"));
let e = Kits::parse(&text).err().unwrap_or_default();
assert!(e.contains("not a bound directory"), "{e}");
}
#[test]
fn a_project_with_no_kits_has_nowhere_for_work_to_land_and_says_so() {
let text = kits(&[], "").to_ron().unwrap_or_else(|e| panic!("{e}"));
Kits::parse(&text).unwrap_or_else(|e| panic!("an empty project is openable: {e}"));
let text = kits(&[], "furniture").to_ron().unwrap_or_else(|e| panic!("{e}"));
let e = Kits::parse(&text).err().unwrap_or_default();
assert!(e.contains("nothing is bound"), "{e}");
let text = kits(&[("site", "site")], "").to_ron().unwrap_or_else(|e| panic!("{e}"));
let e = Kits::parse(&text).err().unwrap_or_default();
assert!(e.contains("no `authoring`"), "{e}");
}
#[test]
fn a_project_that_states_no_lattice_gets_the_authored_one() {
let text =
r#"(version: 3, bind: [(namespace: "f", dir: "f")], authoring: Some("f"), bash: [])"#;
let k = Kits::parse(text).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(k.lattice, Lattice::default());
assert_eq!(k.lattice.face_bands, 1, "the half-metre grid the kits use");
assert_eq!(k.lattice.snap_divisor, 3, "the thirds ladder");
assert_eq!(k.lattice.cell_height, 4.0, "what Map::default's ceiling already gave a tile");
}
#[test]
fn a_lattice_outside_the_ceiling_is_refused() {
let bad = |set: fn(&mut Lattice)| {
let mut k = kits(&[("f", "f")], "f");
set(&mut k.lattice);
let text = ron::ser::to_string(&k).unwrap_or_default();
Kits::parse(&text).err().unwrap_or_default()
};
assert!(bad(|l| l.face_bands = 0).contains("a face reads between 1 and"));
assert!(bad(|l| l.face_bands = MAX_DIVISIONS + 1).contains("a face reads between 1 and"));
assert!(bad(|l| l.snap_divisor = 1).contains("a tile divides between 2 and"));
assert!(bad(|l| l.snap_divisor = MAX_DIVISIONS + 1).contains("a tile divides between 2 and"));
assert!(bad(|l| l.cell_height = 0.0).contains("real, positive height"));
assert!(bad(|l| l.cell_height = f32::NAN).contains("real, positive height"));
}
#[test]
fn a_binding_from_another_schema_is_refused() {
let mut k = kits(&[("site", "site")], "site");
k.version = KITS_VERSION + 1;
let text = k.to_ron().unwrap_or_else(|e| panic!("{e}"));
let e = Kits::parse(&text).err().unwrap_or_default();
assert!(e.contains("this build reads"), "{e}");
}
#[test]
fn a_bash_round_trips_with_its_note() {
let mut k = kits(&[("f", "f"), ("g", "g")], "f");
k.bash.push(Bash {
name: "hub".to_owned(),
kits: vec!["f".to_owned(), "g".to_owned()],
note: Some("the two kits the atrium is built from".to_owned()),
});
let text = k.to_ron().unwrap_or_else(|e| panic!("{e}"));
let back = Kits::parse(&text).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(back, k);
assert_eq!(
back.bash_named("hub").map(|b| b.kits.as_slice()),
Some(["f".to_owned(), "g".to_owned()].as_slice())
);
assert!(back.bash_named("nope").is_none(), "an undeclared name resolves to nothing");
}
#[test]
fn a_binding_that_states_no_bash_at_all_is_refused_by_name() {
let silent = r#"(version: 3, bind: [(namespace: "f", dir: "f")], authoring: Some("f"))"#;
let e = Kits::parse(silent).err().unwrap_or_default();
assert!(e.contains("bash"), "the refusal names the field: {e}");
}
#[test]
fn a_bash_naming_an_unbound_kit_is_refused() {
let text = r#"(version: 3, bind: [(namespace: "f", dir: "f")], authoring: Some("f"),
bash: [(name: "hub", kits: ["f", "nope"])])"#;
let e = Kits::parse(text).err().unwrap_or_default();
assert!(e.contains("nope") && e.contains("does not bind"), "{e}");
}
#[test]
fn a_bash_that_cannot_be_named_unambiguously_is_refused() {
let bad = |bash: &str| {
let text = format!(
r#"(version: 3, bind: [(namespace: "f", dir: "f"), (namespace: "g", dir: "g")],
authoring: Some("f"), bash: [{bash}])"#
);
Kits::parse(&text).err().unwrap_or_default()
};
assert!(
bad(r#"(name: "Hub Kit", kits: ["f"])"#).contains("not a usable bash name"),
"a name that is not one snake_case segment"
);
assert!(
bad(r#"(name: "hub", kits: ["f"]), (name: "hub", kits: ["g"])"#)
.contains("declared twice"),
"two bashes answering to one name"
);
assert!(
bad(r#"(name: "hub", kits: [])"#).contains("names no kits"),
"a bash that offers nothing"
);
assert!(
bad(r#"(name: "hub", kits: ["f", "f"])"#).contains("names `f` twice"),
"one kit named twice in one bash"
);
}
}