use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct BrandSpec {
#[serde(default)]
pub name: String,
#[serde(default)]
pub auto_layout: AutoLayout,
#[serde(default)]
pub palette: BTreeMap<String, String>,
#[serde(default)]
pub fonts: BTreeMap<String, String>,
#[serde(default)]
pub logo: Option<LogoSpec>,
}
#[cfg(feature = "toml")]
impl BrandSpec {
pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
toml::from_str(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LogoSpec {
pub key: String,
#[serde(default)]
pub position: LogoPosition,
pub width: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum LogoPosition {
#[default]
TopRight,
TopLeft,
BottomRight,
BottomLeft,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AutoLayout {
pub first: Option<String>,
pub last: Option<String>,
pub default: String,
#[serde(default)]
pub rules: Vec<AutoRule>,
}
impl Default for AutoLayout {
fn default() -> Self {
Self {
first: Some("hero".to_string()),
last: Some("thanks".to_string()),
default: "content".to_string(),
rules: vec![
AutoRule {
when: ShapePredicate::SingleH1Only,
class: "section-divider".to_string(),
},
AutoRule {
when: ShapePredicate::SingleImageOnly,
class: "image-full".to_string(),
},
AutoRule {
when: ShapePredicate::SingleBlockquoteOnly,
class: "callout".to_string(),
},
],
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AutoRule {
pub when: ShapePredicate,
pub class: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ShapePredicate {
Empty,
SingleH1Only,
SingleImageOnly,
SingleBlockquoteOnly,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn auto_layout_default_rule_set() {
let auto = AutoLayout::default();
assert_eq!(auto.first, Some("hero".to_string()));
assert_eq!(auto.last, Some("thanks".to_string()));
assert_eq!(auto.default, "content");
assert_eq!(
auto.rules,
vec![
AutoRule {
when: ShapePredicate::SingleH1Only,
class: "section-divider".to_string(),
},
AutoRule {
when: ShapePredicate::SingleImageOnly,
class: "image-full".to_string(),
},
AutoRule {
when: ShapePredicate::SingleBlockquoteOnly,
class: "callout".to_string(),
},
]
);
}
#[test]
fn brand_spec_round_trips_through_json() {
let spec = BrandSpec {
name: "Acme".to_string(),
auto_layout: AutoLayout::default(),
palette: BTreeMap::from([("primary".to_string(), "#112233".to_string())]),
fonts: BTreeMap::from([("body".to_string(), "Inter".to_string())]),
logo: Some(LogoSpec {
key: "img/logo.svg".to_string(),
position: LogoPosition::BottomLeft,
width: Some("120px".to_string()),
}),
};
let json = serde_json::to_string(&spec).unwrap();
assert_eq!(serde_json::from_str::<BrandSpec>(&json).unwrap(), spec);
}
#[test]
fn brand_spec_deserializes_from_an_empty_object() {
let spec: BrandSpec = serde_json::from_str("{}").expect("every field defaults");
assert_eq!(spec, BrandSpec::default());
assert!(spec.logo.is_none());
}
#[test]
fn logo_position_serializes_kebab_case() {
let json = serde_json::to_string(&LogoPosition::BottomRight).unwrap();
assert_eq!(json, "\"bottom-right\"");
assert_eq!(
serde_json::from_str::<LogoPosition>(&json).unwrap(),
LogoPosition::BottomRight
);
}
#[test]
fn shape_predicate_serializes_snake_case() {
let json = serde_json::to_string(&ShapePredicate::SingleH1Only).unwrap();
assert_eq!(json, "\"single_h1_only\"");
assert_eq!(
serde_json::from_str::<ShapePredicate>(&json).unwrap(),
ShapePredicate::SingleH1Only
);
}
#[cfg(feature = "toml")]
#[test]
fn from_toml_parses_a_full_brand_toml_without_mdcast() {
let toml = r##"
name = "Acme"
[auto_layout]
first = "hero"
last = "thanks"
default = "content"
[palette]
primary = "#112233"
[fonts]
body = "Inter"
[logo]
key = "img/logo.svg"
position = "bottom-left"
width = "120px"
"##;
let spec = BrandSpec::from_toml(toml).unwrap();
assert_eq!(spec.name, "Acme");
assert_eq!(spec.auto_layout.first, Some("hero".to_string()));
assert_eq!(spec.palette.get("primary"), Some(&"#112233".to_string()));
assert_eq!(spec.fonts.get("body"), Some(&"Inter".to_string()));
let logo = spec.logo.expect("logo table should parse");
assert_eq!(logo.key, "img/logo.svg");
assert_eq!(logo.position, LogoPosition::BottomLeft);
assert_eq!(logo.width.as_deref(), Some("120px"));
}
#[cfg(feature = "toml")]
#[test]
fn from_toml_defaults_missing_fields() {
let spec = BrandSpec::from_toml("name = \"Acme\"").unwrap();
assert_eq!(
spec,
BrandSpec {
name: "Acme".to_string(),
..BrandSpec::default()
}
);
}
#[cfg(feature = "toml")]
#[test]
fn from_toml_rejects_invalid_syntax() {
assert!(BrandSpec::from_toml("not = [valid toml").is_err());
}
}