1use std::collections::BTreeMap;
12
13use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
16pub struct BrandSpec {
17 #[serde(default)]
18 pub name: String,
19 #[serde(default)]
20 pub auto_layout: AutoLayout,
21 #[serde(default)]
22 pub palette: BTreeMap<String, String>,
23 #[serde(default)]
24 pub fonts: BTreeMap<String, String>,
25 #[serde(default)]
31 pub logo: Option<LogoSpec>,
32}
33
34#[cfg(feature = "toml")]
35impl BrandSpec {
36 pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
45 toml::from_str(s)
46 }
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct LogoSpec {
56 pub key: String,
57 #[serde(default)]
58 pub position: LogoPosition,
59 pub width: Option<String>,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
64#[serde(rename_all = "kebab-case")]
65pub enum LogoPosition {
66 #[default]
67 TopRight,
68 TopLeft,
69 BottomRight,
70 BottomLeft,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct AutoLayout {
75 pub first: Option<String>,
77 pub last: Option<String>,
79 pub default: String,
81 #[serde(default)]
83 pub rules: Vec<AutoRule>,
84}
85
86impl Default for AutoLayout {
87 fn default() -> Self {
88 Self {
89 first: Some("hero".to_string()),
90 last: Some("thanks".to_string()),
91 default: "content".to_string(),
92 rules: vec![
93 AutoRule {
94 when: ShapePredicate::SingleH1Only,
95 class: "section-divider".to_string(),
96 },
97 AutoRule {
98 when: ShapePredicate::SingleImageOnly,
99 class: "image-full".to_string(),
100 },
101 AutoRule {
102 when: ShapePredicate::SingleBlockquoteOnly,
103 class: "callout".to_string(),
104 },
105 ],
106 }
107 }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct AutoRule {
112 pub when: ShapePredicate,
113 pub class: String,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "snake_case")]
120pub enum ShapePredicate {
121 Empty,
122 SingleH1Only,
123 SingleImageOnly,
124 SingleBlockquoteOnly,
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130
131 #[test]
132 fn auto_layout_default_rule_set() {
133 let auto = AutoLayout::default();
134
135 assert_eq!(auto.first, Some("hero".to_string()));
136 assert_eq!(auto.last, Some("thanks".to_string()));
137 assert_eq!(auto.default, "content");
138 assert_eq!(
139 auto.rules,
140 vec![
141 AutoRule {
142 when: ShapePredicate::SingleH1Only,
143 class: "section-divider".to_string(),
144 },
145 AutoRule {
146 when: ShapePredicate::SingleImageOnly,
147 class: "image-full".to_string(),
148 },
149 AutoRule {
150 when: ShapePredicate::SingleBlockquoteOnly,
151 class: "callout".to_string(),
152 },
153 ]
154 );
155 }
156
157 #[test]
158 fn brand_spec_round_trips_through_json() {
159 let spec = BrandSpec {
160 name: "Acme".to_string(),
161 auto_layout: AutoLayout::default(),
162 palette: BTreeMap::from([("primary".to_string(), "#112233".to_string())]),
163 fonts: BTreeMap::from([("body".to_string(), "Inter".to_string())]),
164 logo: Some(LogoSpec {
165 key: "img/logo.svg".to_string(),
166 position: LogoPosition::BottomLeft,
167 width: Some("120px".to_string()),
168 }),
169 };
170
171 let json = serde_json::to_string(&spec).unwrap();
172 assert_eq!(serde_json::from_str::<BrandSpec>(&json).unwrap(), spec);
173 }
174
175 #[test]
176 fn brand_spec_deserializes_from_an_empty_object() {
177 let spec: BrandSpec = serde_json::from_str("{}").expect("every field defaults");
178 assert_eq!(spec, BrandSpec::default());
179 assert!(spec.logo.is_none());
180 }
181
182 #[test]
183 fn logo_position_serializes_kebab_case() {
184 let json = serde_json::to_string(&LogoPosition::BottomRight).unwrap();
185 assert_eq!(json, "\"bottom-right\"");
186 assert_eq!(
187 serde_json::from_str::<LogoPosition>(&json).unwrap(),
188 LogoPosition::BottomRight
189 );
190 }
191
192 #[test]
193 fn shape_predicate_serializes_snake_case() {
194 let json = serde_json::to_string(&ShapePredicate::SingleH1Only).unwrap();
195 assert_eq!(json, "\"single_h1_only\"");
196 assert_eq!(
197 serde_json::from_str::<ShapePredicate>(&json).unwrap(),
198 ShapePredicate::SingleH1Only
199 );
200 }
201
202 #[cfg(feature = "toml")]
203 #[test]
204 fn from_toml_parses_a_full_brand_toml_without_mdcast() {
205 let toml = r##"
206 name = "Acme"
207
208 [auto_layout]
209 first = "hero"
210 last = "thanks"
211 default = "content"
212
213 [palette]
214 primary = "#112233"
215
216 [fonts]
217 body = "Inter"
218
219 [logo]
220 key = "img/logo.svg"
221 position = "bottom-left"
222 width = "120px"
223 "##;
224
225 let spec = BrandSpec::from_toml(toml).unwrap();
226
227 assert_eq!(spec.name, "Acme");
228 assert_eq!(spec.auto_layout.first, Some("hero".to_string()));
229 assert_eq!(spec.palette.get("primary"), Some(&"#112233".to_string()));
230 assert_eq!(spec.fonts.get("body"), Some(&"Inter".to_string()));
231 let logo = spec.logo.expect("logo table should parse");
232 assert_eq!(logo.key, "img/logo.svg");
233 assert_eq!(logo.position, LogoPosition::BottomLeft);
234 assert_eq!(logo.width.as_deref(), Some("120px"));
235 }
236
237 #[cfg(feature = "toml")]
238 #[test]
239 fn from_toml_defaults_missing_fields() {
240 let spec = BrandSpec::from_toml("name = \"Acme\"").unwrap();
241 assert_eq!(
242 spec,
243 BrandSpec {
244 name: "Acme".to_string(),
245 ..BrandSpec::default()
246 }
247 );
248 }
249
250 #[cfg(feature = "toml")]
251 #[test]
252 fn from_toml_rejects_invalid_syntax() {
253 assert!(BrandSpec::from_toml("not = [valid toml").is_err());
254 }
255}