Skip to main content

lark_webhook_notify/
builder.rs

1use serde_json::Value;
2
3use crate::blocks::{
4    self, Card, CollapsiblePanel, Column, ColumnSet, ColumnWidth, HeaderBlock, Markdown, TextAlign,
5    TextSize, TextTag,
6};
7use crate::templates::{ColorTheme, GenericCardTemplate, LanguageCode};
8
9/// Fluent builder for fully custom Lark interactive cards.
10///
11/// Produces a [`GenericCardTemplate`] via [`CardBuilder::build`].
12///
13/// # Example
14///
15/// ```
16/// use lark_webhook_notify::{CardBuilder, ColorTheme, TextAlign, TextSize};
17///
18/// let card = CardBuilder::new()
19///     .header("Deploy Complete", Some("success"), Some(ColorTheme::Green), None)
20///     .markdown("**Service:** api v1.4", TextAlign::Left, TextSize::Normal)
21///     .columns()
22///         .column("Env", Some("prod"), "auto", 1)
23///         .column("Region", Some("us-east-1"), "weighted", 1)
24///     .end_columns()
25///     .collapsible("Logs", "Deploy OK", false)
26///     .build();
27/// ```
28///
29/// # Panics
30///
31/// [`build`](CardBuilder::build) panics if [`columns`](CardBuilder::columns) was
32/// called without a matching [`end_columns`](CardBuilder::end_columns).
33pub struct CardBuilder {
34    /// Language carried for consumers (e.g. `create_custom_template`).
35    /// Translations are performed by callers (WorkflowTemplates, etc.) before
36    /// passing strings into the builder methods — builder methods accept raw strings.
37    language: LanguageCode,
38    header: Option<Value>,
39    elements: Vec<Value>,
40    column_stack: Vec<Vec<Value>>,
41}
42
43impl CardBuilder {
44    pub fn new() -> Self {
45        Self {
46            language: LanguageCode::Zh,
47            header: None,
48            elements: Vec::new(),
49            column_stack: Vec::new(),
50        }
51    }
52
53    /// Override the language carried by this builder (default: `Zh`).
54    pub fn language(mut self, lang: LanguageCode) -> Self {
55        self.language = lang;
56        self
57    }
58
59    /// Set the card header. Status auto-detects color if color is None:
60    ///   "running"|"submitted" → Wathet, "success"|"completed" → Green,
61    ///   "failed"|"error" → Red, "warning" → Orange, _ → Blue
62    pub fn header(
63        mut self,
64        title: &str,
65        status: Option<&str>,
66        color: Option<ColorTheme>,
67        subtitle: Option<&str>,
68    ) -> Self {
69        let resolved_color = match color {
70            Some(c) => c,
71            None => match status.map(|s| s.to_lowercase()).as_deref() {
72                Some("running") | Some("submitted") => ColorTheme::Wathet,
73                Some("success") | Some("completed") => ColorTheme::Green,
74                Some("failed") | Some("error") => ColorTheme::Red,
75                Some("warning") => ColorTheme::Orange,
76                _ => ColorTheme::Blue,
77            },
78        };
79        let text_tag_list = status.map(|s| {
80            vec![Value::from(TextTag {
81                text: s.into(),
82                color: resolved_color.as_str().into(),
83            })]
84        });
85        self.header = Some(
86            HeaderBlock {
87                title: title.into(),
88                template: resolved_color.as_str().into(),
89                subtitle: subtitle.or(Some("")).map(|s| s.into()),
90                text_tag_list,
91                padding: Some("12px 8px 12px 8px".into()),
92            }
93            .into(),
94        );
95        self
96    }
97
98    /// Add a single `**Label:** value` metadata line as a markdown block.
99    pub fn metadata(mut self, label: &str, value: &str) -> Self {
100        self.elements
101            .push(blocks::markdown(format!("**{label}:** {value}")).into());
102        self
103    }
104
105    /// Add multiple `**Label:** value` lines as a single markdown block.
106    pub fn metadata_block(mut self, fields: &[(&str, &str)]) -> Self {
107        let lines: Vec<String> = fields
108            .iter()
109            .map(|(k, v)| format!("**{k}:** {v}"))
110            .collect();
111        self.elements
112            .push(blocks::markdown(lines.join("\n")).into());
113        self
114    }
115
116    /// Add a markdown text block with explicit alignment and size.
117    pub fn markdown(mut self, content: &str, text_align: TextAlign, text_size: TextSize) -> Self {
118        self.elements.push(
119            Markdown {
120                content: content.into(),
121                text_align,
122                text_size,
123                ..Default::default()
124            }
125            .into(),
126        );
127        self
128    }
129
130    /// Begin a column group. Must be closed with [`end_columns`](CardBuilder::end_columns).
131    pub fn columns(mut self) -> Self {
132        self.column_stack.push(Vec::new());
133        self
134    }
135
136    /// Add a column to the current column group.
137    ///
138    /// `width`: `"auto"` for content-sized or `"weighted"` for proportional.
139    /// `weight` is only used when `width = "weighted"`.
140    pub fn column(mut self, label: &str, value: Option<&str>, width: &str, weight: u32) -> Self {
141        let margin = if width == "auto" {
142            "0px 4px 0px 4px"
143        } else {
144            "0px 0px 0px 0px"
145        };
146        let col_content: Value = Markdown {
147            content: match value {
148                Some(v) => format!("**{label}**\n{v}"),
149                None => label.to_string(),
150            },
151            text_align: TextAlign::Center,
152            text_size: TextSize::NormalV2,
153            margin: margin.into(),
154        }
155        .into();
156        let col_block: Value = Column {
157            elements: vec![col_content],
158            width: if width == "weighted" {
159                ColumnWidth::Weighted
160            } else {
161                ColumnWidth::Auto
162            },
163            weight: if width == "weighted" {
164                Some(weight)
165            } else {
166                None
167            },
168            ..Default::default()
169        }
170        .into();
171        self.column_stack
172            .last_mut()
173            .expect("call .columns() first")
174            .push(col_block);
175        self
176    }
177
178    /// Close the current column group and add a `column_set` block to the card body.
179    pub fn end_columns(mut self) -> Self {
180        let cols = self.column_stack.pop().expect("no open column context");
181        self.elements.push(
182            ColumnSet {
183                columns: cols,
184                ..Default::default()
185            }
186            .into(),
187        );
188        self
189    }
190
191    /// Add a collapsible panel with a markdown body.
192    ///
193    /// `expanded = true` renders the panel open by default.
194    pub fn collapsible(mut self, title: &str, content: &str, expanded: bool) -> Self {
195        self.elements.push(
196            CollapsiblePanel {
197                title_markdown: format!("**<font color='grey-800'>{title}</font>**"),
198                elements: vec![
199                    Markdown {
200                        content: content.into(),
201                        text_size: TextSize::NormalV2,
202                        ..Default::default()
203                    }
204                    .into(),
205                ],
206                expanded,
207                ..Default::default()
208            }
209            .into(),
210        );
211        self
212    }
213
214    /// Add a horizontal divider (`---`).
215    pub fn divider(mut self) -> Self {
216        self.elements.push(blocks::markdown("---").into());
217        self
218    }
219
220    /// Add any block that implements `Into<serde_json::Value>` directly.
221    ///
222    /// Use this to insert raw block structs from the [`blocks`](crate::blocks) module
223    /// or hand-crafted JSON values.
224    pub fn add_block(mut self, block: impl Into<Value>) -> Self {
225        self.elements.push(block.into());
226        self
227    }
228
229    /// Finalize and return the card as a [`GenericCardTemplate`].
230    ///
231    /// # Panics
232    ///
233    /// Panics if any column group opened with [`columns`](CardBuilder::columns)
234    /// was not closed with [`end_columns`](CardBuilder::end_columns).
235    pub fn build(self) -> GenericCardTemplate {
236        assert!(
237            self.column_stack.is_empty(),
238            "unclosed column context: call .end_columns()"
239        );
240        let card: Value = Card {
241            elements: self.elements,
242            header: self.header.unwrap_or(Value::Null),
243            config: Some(blocks::config_textsize_normal_v2()),
244            ..Default::default()
245        }
246        .into();
247        GenericCardTemplate { content: card }
248    }
249}
250
251impl Default for CardBuilder {
252    fn default() -> Self {
253        Self::new()
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use crate::templates::LarkTemplate;
261
262    #[test]
263    fn test_basic_card() {
264        let t = CardBuilder::new()
265            .header("My Title", Some("running"), None, None)
266            .markdown("Hello world", TextAlign::Left, TextSize::Normal)
267            .build();
268        let card = t.generate();
269        assert_eq!(card["schema"], "2.0");
270        assert_eq!(card["header"]["title"]["content"], "My Title");
271        assert_eq!(card["header"]["template"], "wathet"); // auto-detected from "running"
272        assert_eq!(card["body"]["elements"][0]["content"], "Hello world");
273    }
274
275    #[test]
276    fn test_explicit_color_overrides_auto() {
277        let t = CardBuilder::new()
278            .header("T", Some("running"), Some(ColorTheme::Red), None)
279            .build();
280        let card = t.generate();
281        assert_eq!(card["header"]["template"], "red");
282    }
283
284    #[test]
285    fn test_metadata() {
286        let t = CardBuilder::new()
287            .header("H", None, None, None)
288            .metadata("Key", "Value")
289            .build();
290        let card = t.generate();
291        let content = card["body"]["elements"][0]["content"].as_str().unwrap();
292        assert!(content.contains("**Key:**"));
293        assert!(content.contains("Value"));
294    }
295
296    #[test]
297    fn test_metadata_block() {
298        let t = CardBuilder::new()
299            .header("H", None, None, None)
300            .metadata_block(&[("Name", "foo"), ("Age", "42")])
301            .build();
302        let card = t.generate();
303        let content = card["body"]["elements"][0]["content"].as_str().unwrap();
304        assert!(content.contains("**Name:**"));
305        assert!(content.contains("**Age:**"));
306    }
307
308    #[test]
309    fn test_columns() {
310        let t = CardBuilder::new()
311            .header("H", None, None, None)
312            .columns()
313            .column("Group", Some("grp1"), "auto", 1)
314            .column("Prefix", Some("p/"), "weighted", 1)
315            .end_columns()
316            .build();
317        let card = t.generate();
318        let elements = card["body"]["elements"].as_array().unwrap();
319        assert_eq!(elements.len(), 1);
320        assert_eq!(elements[0]["tag"], "column_set");
321    }
322
323    #[test]
324    #[should_panic]
325    fn test_build_panics_with_open_columns() {
326        let _ = CardBuilder::new()
327            .header("H", None, None, None)
328            .columns()
329            // forgot .end_columns()
330            .build();
331    }
332
333    #[test]
334    fn test_collapsible() {
335        let t = CardBuilder::new()
336            .header("H", None, None, None)
337            .collapsible("Details", "some content", false)
338            .build();
339        let card = t.generate();
340        let elements = card["body"]["elements"].as_array().unwrap();
341        assert_eq!(elements[0]["tag"], "collapsible_panel");
342    }
343
344    #[test]
345    fn test_divider() {
346        let t = CardBuilder::new()
347            .header("H", None, None, None)
348            .divider()
349            .build();
350        let card = t.generate();
351        let content = card["body"]["elements"][0]["content"].as_str().unwrap();
352        assert_eq!(content, "---");
353    }
354
355    #[test]
356    fn test_has_config() {
357        let t = CardBuilder::new().header("H", None, None, None).build();
358        let card = t.generate();
359        assert!(card.get("config").is_some());
360    }
361}