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
9pub struct CardBuilder {
34 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 pub fn language(mut self, lang: LanguageCode) -> Self {
55 self.language = lang;
56 self
57 }
58
59 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 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 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 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 pub fn columns(mut self) -> Self {
132 self.column_stack.push(Vec::new());
133 self
134 }
135
136 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 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 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 pub fn divider(mut self) -> Self {
216 self.elements.push(blocks::markdown("---").into());
217 self
218 }
219
220 pub fn add_block(mut self, block: impl Into<Value>) -> Self {
225 self.elements.push(block.into());
226 self
227 }
228
229 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"); 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 .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}