use std::fmt::Write;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CardElement {
pub title: Option<String>,
pub children: Vec<CardChild>,
pub fallback_text: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum CardChild {
Section(SectionElement),
Actions(ActionsElement),
Divider,
Image(ImageElement),
Fields(FieldsElement),
Text(TextElement),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SectionElement {
pub text: Option<String>,
pub accessory: Option<Box<CardChild>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionsElement {
pub elements: Vec<ActionElement>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ActionElement {
Button(ButtonElement),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ButtonElement {
pub id: String,
pub text: String,
pub value: Option<String>,
pub style: ButtonStyle,
pub url: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum ButtonStyle {
#[default]
Default,
Primary,
Danger,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageElement {
pub url: String,
pub alt_text: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldsElement {
pub fields: Vec<FieldElement>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldElement {
pub label: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TextElement {
pub text: String,
pub style: TextStyle,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum TextStyle {
#[default]
Plain,
Markdown,
}
impl CardElement {
#[must_use]
pub fn new() -> Self {
Self { title: None, children: Vec::new(), fallback_text: None }
}
#[must_use]
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
#[must_use]
pub fn section(mut self, section: SectionElement) -> Self {
self.children.push(CardChild::Section(section));
self
}
#[must_use]
pub fn actions(mut self, actions: ActionsElement) -> Self {
self.children.push(CardChild::Actions(actions));
self
}
#[must_use]
pub fn divider(mut self) -> Self {
self.children.push(CardChild::Divider);
self
}
#[must_use]
pub fn image(mut self, image: ImageElement) -> Self {
self.children.push(CardChild::Image(image));
self
}
#[must_use]
pub fn fields(mut self, fields: FieldsElement) -> Self {
self.children.push(CardChild::Fields(fields));
self
}
#[must_use]
pub fn text(mut self, text: TextElement) -> Self {
self.children.push(CardChild::Text(text));
self
}
#[must_use]
pub fn fallback_text(mut self, text: impl Into<String>) -> Self {
self.fallback_text = Some(text.into());
self
}
}
impl Default for CardElement {
fn default() -> Self {
Self::new()
}
}
impl ButtonElement {
#[must_use]
pub fn new(id: impl Into<String>, text: impl Into<String>) -> Self {
Self {
id: id.into(),
text: text.into(),
value: None,
style: ButtonStyle::Default,
url: None,
}
}
#[must_use]
pub fn value(mut self, value: impl Into<String>) -> Self {
self.value = Some(value.into());
self
}
#[must_use]
pub fn style(mut self, style: ButtonStyle) -> Self {
self.style = style;
self
}
#[must_use]
pub fn url(mut self, url: impl Into<String>) -> Self {
self.url = Some(url.into());
self
}
}
#[must_use]
pub fn render_card_as_text(card: &CardElement) -> String {
if let Some(ref fallback) = card.fallback_text {
return fallback.clone();
}
let mut buf = String::new();
if let Some(ref title) = card.title {
let _ = writeln!(buf, "**{title}**");
}
for child in &card.children {
render_child(&mut buf, child);
}
while buf.ends_with('\n') {
buf.pop();
}
buf
}
fn render_child(buf: &mut String, child: &CardChild) {
match child {
CardChild::Section(section) => {
if let Some(ref text) = section.text {
let _ = writeln!(buf, "{text}");
}
if let Some(ref accessory) = section.accessory {
render_child(buf, accessory);
}
}
CardChild::Actions(actions) => {
for action in &actions.elements {
match action {
ActionElement::Button(button) => {
let _ = writeln!(buf, "[Button: {}]", button.text);
}
}
}
}
CardChild::Divider => {
let _ = writeln!(buf, "---");
}
CardChild::Image(image) => {
let _ = writeln!(buf, "[{}]", image.alt_text);
}
CardChild::Fields(fields) => {
for field in &fields.fields {
let _ = writeln!(buf, "{}: {}", field.label, field.value);
}
}
CardChild::Text(text) => {
let _ = writeln!(buf, "{}", text.text);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builder_constructs_correct_tree() {
let card = CardElement::new()
.title("Deploy Report")
.section(SectionElement { text: Some("All services healthy.".into()), accessory: None })
.divider()
.fields(FieldsElement {
fields: vec![
FieldElement { label: "Region".into(), value: "us-east-1".into() },
FieldElement { label: "Status".into(), value: "OK".into() },
],
})
.actions(ActionsElement {
elements: vec![ActionElement::Button(
ButtonElement::new("approve", "Approve")
.style(ButtonStyle::Primary)
.value("yes"),
)],
})
.image(ImageElement {
url: "https://example.com/img.png".into(),
alt_text: "dashboard screenshot".into(),
})
.text(TextElement { text: "Footer note".into(), style: TextStyle::Plain });
assert_eq!(card.title.as_deref(), Some("Deploy Report"));
assert_eq!(card.children.len(), 6);
assert!(matches!(card.children[0], CardChild::Section(_)));
assert!(matches!(card.children[1], CardChild::Divider));
assert!(matches!(card.children[2], CardChild::Fields(_)));
assert!(matches!(card.children[3], CardChild::Actions(_)));
assert!(matches!(card.children[4], CardChild::Image(_)));
assert!(matches!(card.children[5], CardChild::Text(_)));
}
#[test]
fn render_card_as_text_full() {
let card = CardElement::new()
.title("Status")
.section(SectionElement { text: Some("Everything is fine.".into()), accessory: None })
.divider()
.fields(FieldsElement {
fields: vec![FieldElement { label: "Uptime".into(), value: "99.9%".into() }],
})
.actions(ActionsElement {
elements: vec![ActionElement::Button(ButtonElement::new("ack", "Acknowledge"))],
});
let text = render_card_as_text(&card);
assert!(text.contains("**Status**"));
assert!(text.contains("Everything is fine."));
assert!(text.contains("---"));
assert!(text.contains("Uptime: 99.9%"));
assert!(text.contains("[Button: Acknowledge]"));
}
#[test]
fn render_card_as_text_returns_fallback() {
let card = CardElement::new().title("Ignored").fallback_text("custom fallback");
assert_eq!(render_card_as_text(&card), "custom fallback");
}
#[test]
fn serde_roundtrip() {
let card = CardElement::new()
.title("RT")
.section(SectionElement { text: Some("sec".into()), accessory: None })
.divider()
.actions(ActionsElement {
elements: vec![ActionElement::Button(
ButtonElement::new("b1", "Click")
.value("v")
.style(ButtonStyle::Danger)
.url("https://example.com"),
)],
})
.image(ImageElement { url: "https://img.test/a.png".into(), alt_text: "alt".into() })
.fields(FieldsElement {
fields: vec![FieldElement { label: "k".into(), value: "v".into() }],
})
.text(TextElement { text: "md".into(), style: TextStyle::Markdown });
let json = serde_json::to_string(&card).expect("serialize");
let back: CardElement = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.title, card.title);
assert_eq!(back.children.len(), card.children.len());
}
#[test]
fn button_builder_defaults() {
let btn = ButtonElement::new("id", "text");
assert_eq!(btn.style, ButtonStyle::Default);
assert!(btn.value.is_none());
assert!(btn.url.is_none());
}
#[test]
fn enum_defaults() {
assert_eq!(ButtonStyle::default(), ButtonStyle::Default);
assert_eq!(TextStyle::default(), TextStyle::Plain);
}
}