Skip to main content

bob_chat/
card.rs

1//! Card element types and builders for interactive rich messages.
2//!
3//! Cards are structured, interactive messages containing buttons, sections,
4//! images, and fields. Platform adapters render them to their native format
5//! (e.g. Slack blocks, Discord embeds). The [`render_card_as_text`] helper
6//! produces a plain-text fallback for adapters that lack rich formatting.
7
8use std::fmt::Write;
9
10use serde::{Deserialize, Serialize};
11
12// ---------------------------------------------------------------------------
13// Core types
14// ---------------------------------------------------------------------------
15
16/// A card is a structured rich message composed of child elements.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct CardElement {
19    /// Optional title displayed at the top of the card.
20    pub title: Option<String>,
21    /// Ordered list of child elements that make up the card body.
22    pub children: Vec<CardChild>,
23    /// When set, [`render_card_as_text`] returns this verbatim instead of
24    /// building text from the element tree.
25    pub fallback_text: Option<String>,
26}
27
28/// A child element inside a [`CardElement`].
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub enum CardChild {
31    /// A text section, optionally with an accessory element.
32    Section(SectionElement),
33    /// A row of interactive action elements (buttons, etc.).
34    Actions(ActionsElement),
35    /// A horizontal divider line.
36    Divider,
37    /// An inline image.
38    Image(ImageElement),
39    /// A set of label/value field pairs.
40    Fields(FieldsElement),
41    /// A block of text with optional styling.
42    Text(TextElement),
43}
44
45/// A section element containing text and an optional accessory.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct SectionElement {
48    /// Primary text content of the section.
49    pub text: Option<String>,
50    /// Optional accessory element displayed alongside the text.
51    pub accessory: Option<Box<CardChild>>,
52}
53
54/// A container for interactive action elements.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct ActionsElement {
57    /// The action elements (buttons, etc.) in this row.
58    pub elements: Vec<ActionElement>,
59}
60
61/// An individual interactive action element.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub enum ActionElement {
64    /// A clickable button.
65    Button(ButtonElement),
66}
67
68/// A clickable button element.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct ButtonElement {
71    /// Unique identifier for the button (used in callbacks).
72    pub id: String,
73    /// Display text on the button.
74    pub text: String,
75    /// Optional payload value attached to the button.
76    pub value: Option<String>,
77    /// Visual style of the button.
78    pub style: ButtonStyle,
79    /// Optional URL the button navigates to.
80    pub url: Option<String>,
81}
82
83/// Visual style for a [`ButtonElement`].
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
85pub enum ButtonStyle {
86    /// Standard / neutral style.
87    #[default]
88    Default,
89    /// Emphasised / call-to-action style.
90    Primary,
91    /// Destructive / warning style.
92    Danger,
93}
94
95/// An image element displayed inline in a card.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct ImageElement {
98    /// URL of the image.
99    pub url: String,
100    /// Alt text describing the image.
101    pub alt_text: String,
102}
103
104/// A set of label/value field pairs displayed in a grid.
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct FieldsElement {
107    /// The individual fields.
108    pub fields: Vec<FieldElement>,
109}
110
111/// A single label/value pair inside a [`FieldsElement`].
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct FieldElement {
114    /// The field label.
115    pub label: String,
116    /// The field value.
117    pub value: String,
118}
119
120/// A styled text block inside a card.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct TextElement {
123    /// The text content.
124    pub text: String,
125    /// Rendering style for the text.
126    pub style: TextStyle,
127}
128
129/// Rendering style for a [`TextElement`].
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
131pub enum TextStyle {
132    /// Render as plain text.
133    #[default]
134    Plain,
135    /// Render as Markdown.
136    Markdown,
137}
138
139// ---------------------------------------------------------------------------
140// Builders – CardElement
141// ---------------------------------------------------------------------------
142
143impl CardElement {
144    /// Create a new empty card.
145    #[must_use]
146    pub fn new() -> Self {
147        Self { title: None, children: Vec::new(), fallback_text: None }
148    }
149
150    /// Set the card title.
151    #[must_use]
152    pub fn title(mut self, title: impl Into<String>) -> Self {
153        self.title = Some(title.into());
154        self
155    }
156
157    /// Append a section child.
158    #[must_use]
159    pub fn section(mut self, section: SectionElement) -> Self {
160        self.children.push(CardChild::Section(section));
161        self
162    }
163
164    /// Append an actions child.
165    #[must_use]
166    pub fn actions(mut self, actions: ActionsElement) -> Self {
167        self.children.push(CardChild::Actions(actions));
168        self
169    }
170
171    /// Append a divider.
172    #[must_use]
173    pub fn divider(mut self) -> Self {
174        self.children.push(CardChild::Divider);
175        self
176    }
177
178    /// Append an image child.
179    #[must_use]
180    pub fn image(mut self, image: ImageElement) -> Self {
181        self.children.push(CardChild::Image(image));
182        self
183    }
184
185    /// Append a fields child.
186    #[must_use]
187    pub fn fields(mut self, fields: FieldsElement) -> Self {
188        self.children.push(CardChild::Fields(fields));
189        self
190    }
191
192    /// Append a text child.
193    #[must_use]
194    pub fn text(mut self, text: TextElement) -> Self {
195        self.children.push(CardChild::Text(text));
196        self
197    }
198
199    /// Set explicit fallback text (used by [`render_card_as_text`]).
200    #[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
213// ---------------------------------------------------------------------------
214// Builders – ButtonElement
215// ---------------------------------------------------------------------------
216
217impl ButtonElement {
218    /// Create a new button with the given identifier and display text.
219    #[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    /// Set the callback payload value.
231    #[must_use]
232    pub fn value(mut self, value: impl Into<String>) -> Self {
233        self.value = Some(value.into());
234        self
235    }
236
237    /// Set the visual style.
238    #[must_use]
239    pub fn style(mut self, style: ButtonStyle) -> Self {
240        self.style = style;
241        self
242    }
243
244    /// Set the navigation URL.
245    #[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// ---------------------------------------------------------------------------
253// Plain-text fallback renderer
254// ---------------------------------------------------------------------------
255
256/// Render a [`CardElement`] as plain text.
257///
258/// If the card has [`CardElement::fallback_text`] set, it is returned
259/// verbatim.  Otherwise a textual representation is built from the element
260/// tree.
261#[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    // Remove trailing newline for a tidy output.
278    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// ---------------------------------------------------------------------------
322// Tests
323// ---------------------------------------------------------------------------
324
325#[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        // Verify child order.
358        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}