1use std::fmt::Write;
9
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct CardElement {
19 pub title: Option<String>,
21 pub children: Vec<CardChild>,
23 pub fallback_text: Option<String>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub enum CardChild {
31 Section(SectionElement),
33 Actions(ActionsElement),
35 Divider,
37 Image(ImageElement),
39 Fields(FieldsElement),
41 Text(TextElement),
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct SectionElement {
48 pub text: Option<String>,
50 pub accessory: Option<Box<CardChild>>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct ActionsElement {
57 pub elements: Vec<ActionElement>,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
63pub enum ActionElement {
64 Button(ButtonElement),
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct ButtonElement {
71 pub id: String,
73 pub text: String,
75 pub value: Option<String>,
77 pub style: ButtonStyle,
79 pub url: Option<String>,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
85pub enum ButtonStyle {
86 #[default]
88 Default,
89 Primary,
91 Danger,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct ImageElement {
98 pub url: String,
100 pub alt_text: String,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct FieldsElement {
107 pub fields: Vec<FieldElement>,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct FieldElement {
114 pub label: String,
116 pub value: String,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct TextElement {
123 pub text: String,
125 pub style: TextStyle,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
131pub enum TextStyle {
132 #[default]
134 Plain,
135 Markdown,
137}
138
139impl CardElement {
144 #[must_use]
146 pub fn new() -> Self {
147 Self { title: None, children: Vec::new(), fallback_text: None }
148 }
149
150 #[must_use]
152 pub fn title(mut self, title: impl Into<String>) -> Self {
153 self.title = Some(title.into());
154 self
155 }
156
157 #[must_use]
159 pub fn section(mut self, section: SectionElement) -> Self {
160 self.children.push(CardChild::Section(section));
161 self
162 }
163
164 #[must_use]
166 pub fn actions(mut self, actions: ActionsElement) -> Self {
167 self.children.push(CardChild::Actions(actions));
168 self
169 }
170
171 #[must_use]
173 pub fn divider(mut self) -> Self {
174 self.children.push(CardChild::Divider);
175 self
176 }
177
178 #[must_use]
180 pub fn image(mut self, image: ImageElement) -> Self {
181 self.children.push(CardChild::Image(image));
182 self
183 }
184
185 #[must_use]
187 pub fn fields(mut self, fields: FieldsElement) -> Self {
188 self.children.push(CardChild::Fields(fields));
189 self
190 }
191
192 #[must_use]
194 pub fn text(mut self, text: TextElement) -> Self {
195 self.children.push(CardChild::Text(text));
196 self
197 }
198
199 #[must_use]
201 pub fn fallback_text(mut self, text: impl Into<String>) -> Self {
202 self.fallback_text = Some(text.into());
203 self
204 }
205}
206
207impl Default for CardElement {
208 fn default() -> Self {
209 Self::new()
210 }
211}
212
213impl ButtonElement {
218 #[must_use]
220 pub fn new(id: impl Into<String>, text: impl Into<String>) -> Self {
221 Self {
222 id: id.into(),
223 text: text.into(),
224 value: None,
225 style: ButtonStyle::Default,
226 url: None,
227 }
228 }
229
230 #[must_use]
232 pub fn value(mut self, value: impl Into<String>) -> Self {
233 self.value = Some(value.into());
234 self
235 }
236
237 #[must_use]
239 pub fn style(mut self, style: ButtonStyle) -> Self {
240 self.style = style;
241 self
242 }
243
244 #[must_use]
246 pub fn url(mut self, url: impl Into<String>) -> Self {
247 self.url = Some(url.into());
248 self
249 }
250}
251
252#[must_use]
262pub fn render_card_as_text(card: &CardElement) -> String {
263 if let Some(ref fallback) = card.fallback_text {
264 return fallback.clone();
265 }
266
267 let mut buf = String::new();
268
269 if let Some(ref title) = card.title {
270 let _ = writeln!(buf, "**{title}**");
271 }
272
273 for child in &card.children {
274 render_child(&mut buf, child);
275 }
276
277 while buf.ends_with('\n') {
279 buf.pop();
280 }
281
282 buf
283}
284
285fn render_child(buf: &mut String, child: &CardChild) {
286 match child {
287 CardChild::Section(section) => {
288 if let Some(ref text) = section.text {
289 let _ = writeln!(buf, "{text}");
290 }
291 if let Some(ref accessory) = section.accessory {
292 render_child(buf, accessory);
293 }
294 }
295 CardChild::Actions(actions) => {
296 for action in &actions.elements {
297 match action {
298 ActionElement::Button(button) => {
299 let _ = writeln!(buf, "[Button: {}]", button.text);
300 }
301 }
302 }
303 }
304 CardChild::Divider => {
305 let _ = writeln!(buf, "---");
306 }
307 CardChild::Image(image) => {
308 let _ = writeln!(buf, "[{}]", image.alt_text);
309 }
310 CardChild::Fields(fields) => {
311 for field in &fields.fields {
312 let _ = writeln!(buf, "{}: {}", field.label, field.value);
313 }
314 }
315 CardChild::Text(text) => {
316 let _ = writeln!(buf, "{}", text.text);
317 }
318 }
319}
320
321#[cfg(test)]
326mod tests {
327 use super::*;
328
329 #[test]
330 fn builder_constructs_correct_tree() {
331 let card = CardElement::new()
332 .title("Deploy Report")
333 .section(SectionElement { text: Some("All services healthy.".into()), accessory: None })
334 .divider()
335 .fields(FieldsElement {
336 fields: vec![
337 FieldElement { label: "Region".into(), value: "us-east-1".into() },
338 FieldElement { label: "Status".into(), value: "OK".into() },
339 ],
340 })
341 .actions(ActionsElement {
342 elements: vec![ActionElement::Button(
343 ButtonElement::new("approve", "Approve")
344 .style(ButtonStyle::Primary)
345 .value("yes"),
346 )],
347 })
348 .image(ImageElement {
349 url: "https://example.com/img.png".into(),
350 alt_text: "dashboard screenshot".into(),
351 })
352 .text(TextElement { text: "Footer note".into(), style: TextStyle::Plain });
353
354 assert_eq!(card.title.as_deref(), Some("Deploy Report"));
355 assert_eq!(card.children.len(), 6);
356
357 assert!(matches!(card.children[0], CardChild::Section(_)));
359 assert!(matches!(card.children[1], CardChild::Divider));
360 assert!(matches!(card.children[2], CardChild::Fields(_)));
361 assert!(matches!(card.children[3], CardChild::Actions(_)));
362 assert!(matches!(card.children[4], CardChild::Image(_)));
363 assert!(matches!(card.children[5], CardChild::Text(_)));
364 }
365
366 #[test]
367 fn render_card_as_text_full() {
368 let card = CardElement::new()
369 .title("Status")
370 .section(SectionElement { text: Some("Everything is fine.".into()), accessory: None })
371 .divider()
372 .fields(FieldsElement {
373 fields: vec![FieldElement { label: "Uptime".into(), value: "99.9%".into() }],
374 })
375 .actions(ActionsElement {
376 elements: vec![ActionElement::Button(ButtonElement::new("ack", "Acknowledge"))],
377 });
378
379 let text = render_card_as_text(&card);
380
381 assert!(text.contains("**Status**"));
382 assert!(text.contains("Everything is fine."));
383 assert!(text.contains("---"));
384 assert!(text.contains("Uptime: 99.9%"));
385 assert!(text.contains("[Button: Acknowledge]"));
386 }
387
388 #[test]
389 fn render_card_as_text_returns_fallback() {
390 let card = CardElement::new().title("Ignored").fallback_text("custom fallback");
391
392 assert_eq!(render_card_as_text(&card), "custom fallback");
393 }
394
395 #[test]
396 fn serde_roundtrip() {
397 let card = CardElement::new()
398 .title("RT")
399 .section(SectionElement { text: Some("sec".into()), accessory: None })
400 .divider()
401 .actions(ActionsElement {
402 elements: vec![ActionElement::Button(
403 ButtonElement::new("b1", "Click")
404 .value("v")
405 .style(ButtonStyle::Danger)
406 .url("https://example.com"),
407 )],
408 })
409 .image(ImageElement { url: "https://img.test/a.png".into(), alt_text: "alt".into() })
410 .fields(FieldsElement {
411 fields: vec![FieldElement { label: "k".into(), value: "v".into() }],
412 })
413 .text(TextElement { text: "md".into(), style: TextStyle::Markdown });
414
415 let json = serde_json::to_string(&card).expect("serialize");
416 let back: CardElement = serde_json::from_str(&json).expect("deserialize");
417
418 assert_eq!(back.title, card.title);
419 assert_eq!(back.children.len(), card.children.len());
420 }
421
422 #[test]
423 fn button_builder_defaults() {
424 let btn = ButtonElement::new("id", "text");
425 assert_eq!(btn.style, ButtonStyle::Default);
426 assert!(btn.value.is_none());
427 assert!(btn.url.is_none());
428 }
429
430 #[test]
431 fn enum_defaults() {
432 assert_eq!(ButtonStyle::default(), ButtonStyle::Default);
433 assert_eq!(TextStyle::default(), TextStyle::Plain);
434 }
435}