Skip to main content

lark_webhook_notify/
templates.rs

1use chrono::Local;
2use serde_json::Value;
3
4use crate::blocks;
5use crate::blocks::{
6    Card, CollapsiblePanel, Column, ColumnSet, ColumnWidth, HeaderBlock, Markdown, TextAlign,
7    TextSize, TextTag,
8};
9
10/// A fully-rendered Lark card as a `serde_json::Value`.
11///
12/// Produced by [`LarkTemplate::generate`] and consumed by
13/// [`LarkWebhookNotifier::send_raw_content`].
14pub type CardContent = serde_json::Value;
15
16/// Language for translated field labels in card bodies.
17///
18/// User-supplied strings (task names, descriptions, error messages) are always
19/// passed through unchanged. Only built-in label keys (e.g. "Job Name",
20/// "Start Time") are translated.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum LanguageCode {
23    /// Simplified Chinese (`zh`)
24    Zh,
25    /// English (`en`)
26    En,
27}
28
29/// Severity of an alert notification, controlling header color and icon.
30///
31/// | Level | Color | Icon |
32/// |-------|-------|------|
33/// | `Info` | Blue | `:InfoCircle:` |
34/// | `Warning` | Orange | `:WarningTriangle:` |
35/// | `Error` | Red | `:CrossMark:` |
36/// | `Critical` | Red | `:Fire:` |
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum SeverityLevel {
39    Info,
40    Warning,
41    Error,
42    Critical,
43}
44
45/// Card header background color / status tag color.
46///
47/// Maps directly to the Lark card API `"template"` field values.
48/// [`CardBuilder::header`] can auto-detect the color from a status string —
49/// supply `None` for `color` to use that behavior.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum ColorTheme {
52    /// `"blue"` — informational / neutral
53    Blue,
54    /// `"green"` — success / completed
55    Green,
56    /// `"red"` — failure / error
57    Red,
58    /// `"orange"` — warning / caution
59    Orange,
60    /// `"wathet"` — in-progress / running
61    Wathet,
62    /// `"purple"`
63    Purple,
64    /// `"grey"`
65    Grey,
66}
67
68impl ColorTheme {
69    pub fn as_str(&self) -> &'static str {
70        match self {
71            ColorTheme::Blue => "blue",
72            ColorTheme::Green => "green",
73            ColorTheme::Red => "red",
74            ColorTheme::Orange => "orange",
75            ColorTheme::Wathet => "wathet",
76            ColorTheme::Purple => "purple",
77            ColorTheme::Grey => "grey",
78        }
79    }
80}
81
82impl std::fmt::Display for ColorTheme {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        write!(f, "{}", self.as_str())
85    }
86}
87
88impl std::str::FromStr for ColorTheme {
89    type Err = String;
90
91    fn from_str(s: &str) -> Result<Self, Self::Err> {
92        match s.to_lowercase().as_str() {
93            "blue" => Ok(ColorTheme::Blue),
94            "green" => Ok(ColorTheme::Green),
95            "red" => Ok(ColorTheme::Red),
96            "orange" => Ok(ColorTheme::Orange),
97            "wathet" => Ok(ColorTheme::Wathet),
98            "purple" => Ok(ColorTheme::Purple),
99            "grey" | "gray" => Ok(ColorTheme::Grey),
100            _ => Err(format!("unknown color: {s}")),
101        }
102    }
103}
104
105impl SeverityLevel {
106    pub fn as_str(&self) -> &'static str {
107        match self {
108            SeverityLevel::Info => "info",
109            SeverityLevel::Warning => "warning",
110            SeverityLevel::Error => "error",
111            SeverityLevel::Critical => "critical",
112        }
113    }
114
115    pub fn color(&self) -> &'static str {
116        match self {
117            SeverityLevel::Info => "blue",
118            SeverityLevel::Warning => "orange",
119            SeverityLevel::Error | SeverityLevel::Critical => "red",
120        }
121    }
122
123    pub fn icon(&self) -> &'static str {
124        match self {
125            SeverityLevel::Info => ":InfoCircle:",
126            SeverityLevel::Warning => ":WarningTriangle:",
127            SeverityLevel::Error => ":CrossMark:",
128            SeverityLevel::Critical => ":Fire:",
129        }
130    }
131}
132
133impl std::fmt::Display for SeverityLevel {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        write!(f, "{}", self.as_str())
136    }
137}
138
139impl std::str::FromStr for SeverityLevel {
140    type Err = String;
141
142    fn from_str(s: &str) -> Result<Self, Self::Err> {
143        match s.to_lowercase().as_str() {
144            "info" => Ok(SeverityLevel::Info),
145            "warning" => Ok(SeverityLevel::Warning),
146            "error" => Ok(SeverityLevel::Error),
147            "critical" => Ok(SeverityLevel::Critical),
148            _ => Err(format!("unknown severity: {s}")),
149        }
150    }
151}
152
153impl std::fmt::Display for LanguageCode {
154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        match self {
156            LanguageCode::Zh => write!(f, "zh"),
157            LanguageCode::En => write!(f, "en"),
158        }
159    }
160}
161
162impl std::str::FromStr for LanguageCode {
163    type Err = String;
164
165    fn from_str(s: &str) -> Result<Self, Self::Err> {
166        match s {
167            "zh" => Ok(LanguageCode::Zh),
168            "en" => Ok(LanguageCode::En),
169            _ => Err(format!("unknown language: {s}")),
170        }
171    }
172}
173
174pub fn get_translation(key: &str, lang: LanguageCode) -> String {
175    let s = match lang {
176        LanguageCode::Zh => zh_translation(key),
177        LanguageCode::En => en_translation(key),
178    };
179    s.map(|s| s.to_owned()).unwrap_or_else(|| key.to_owned())
180}
181
182fn zh_translation(key: &str) -> Option<&'static str> {
183    Some(match key {
184        "task_name" => "作业名称",
185        "start_time" => "开始时间",
186        "completion_time" => "完成时间",
187        "task_description" => "作业描述",
188        "estimated_duration" => "预计用时",
189        "execution_duration" => "执行时长",
190        "execution_status" => "执行状态",
191        "result_storage" => "结果存储",
192        "storage_prefix" => "存储前缀",
193        "result_overview" => "结果概览",
194        "running_overview" => "运行概览",
195        "state_overview" => "状态概览",
196        "metadata_overview" => "元数据概览",
197        "running" => "正在运行",
198        "completed" => "已成功完成",
199        "failed" => "失败",
200        "success" => "已完成",
201        "failure" => "失败",
202        "task_notification" => "作业运行情况通知",
203        "task_completion_notification" => "作业完成情况通知",
204        "task_failure_notification" => "作业失败情况通知",
205        "no_description" => "*No description provided*",
206        "timestamp" => "时间",
207        "unknown_task" => "未知任务",
208        "return_code" => "返回值",
209        "group" => "归属组别",
210        "network_submission_started" => "网络提交已开始",
211        "network_submission_complete" => "网络提交已完成",
212        "network_submission_failed" => "网络提交失败",
213        "network_set_name" => "网络集名称",
214        "network_type" => "网络类型",
215        "expected_count" => "预期数量",
216        "submitted_count" => "提交总数",
217        "submitted" => "已提交",
218        "config_uploaded" => "配置已上传",
219        "config_name" => "配置名称",
220        "files_uploaded" => "已上传文件数",
221        "description" => "配置描述",
222        "uploaded_files" => "已上传文件的标签",
223        "task_submission_started" => "任务提交已开始",
224        "task_submission_complete" => "任务提交已完成",
225        "task_submission_failed" => "任务提交失败",
226        "task_set_name" => "任务集名称",
227        "iterations" => "迭代次数",
228        "duration" => "持续时间",
229        "submission_overview" => "提交概览",
230        "status" => "状态",
231        "successfully_completed" => "已成功完成",
232        "submitted_before_failure" => "失败前已提交",
233        "error_details" => "错误详情",
234        "task_set_complete" => "任务集已完成",
235        "task_set_failed" => "任务集失败",
236        "task_set_progress" => "任务集进度",
237        "task_set_count" => "任务集数量",
238        "result_collection_started" => "结果收集已开始",
239        "result_collection_complete" => "结果收集已完成",
240        "task_sets" => "任务集",
241        "rows" => "行数",
242        "columns" => "列数",
243        "comparison_complete" => "比较已完成",
244        "comparison_name" => "比较名称",
245        "task_sets_compared" => "已比较任务集",
246        "common_networks" => "公共网络数",
247        "result_rows" => "结果行数",
248        "result_columns" => "结果列数",
249        "comparison_results" => "比较结果",
250        "total_items" => "总项目数",
251        "summary" => "摘要",
252        "items" => "项目",
253        _ => return None,
254    })
255}
256
257fn en_translation(key: &str) -> Option<&'static str> {
258    Some(match key {
259        "task_name" => "Job Name",
260        "start_time" => "Start Time",
261        "completion_time" => "Completion Time",
262        "task_description" => "Job Description",
263        "estimated_duration" => "Estimated Duration",
264        "execution_duration" => "Execution Duration",
265        "execution_status" => "Execution Status",
266        "result_storage" => "Result Storage",
267        "storage_prefix" => "Storage Prefix",
268        "result_overview" => "Result Overview",
269        "running_overview" => "Running Overview",
270        "state_overview" => "State Overview",
271        "metadata_overview" => "Metadata Overview",
272        "running" => "Running Now",
273        "completed" => "Successfully Completed",
274        "failed" => "Failed",
275        "success" => "Completed",
276        "failure" => "Failed",
277        "task_notification" => "Job Status Notification",
278        "task_completion_notification" => "Job Completion Notification",
279        "task_failure_notification" => "Job Failure Notification",
280        "no_description" => "*No description provided*",
281        "timestamp" => "Timestamp",
282        "unknown_task" => "Unknown Task",
283        "return_code" => "Return Status",
284        "group" => "Group",
285        "network_submission_started" => "Network Submission Started",
286        "network_submission_complete" => "Network Submission Complete",
287        "network_submission_failed" => "Network Submission Failed",
288        "network_set_name" => "Network Set Name",
289        "network_type" => "Network Type",
290        "expected_count" => "Expected Count",
291        "submitted_count" => "Total Count Submitted",
292        "submitted" => "Submitted",
293        "config_uploaded" => "Configuration Uploaded",
294        "config_name" => "Config Name",
295        "files_uploaded" => "Files Uploaded",
296        "description" => "Config Description",
297        "uploaded_files" => "Labels of Uploaded Files",
298        "task_submission_started" => "Task Submission Started",
299        "task_submission_complete" => "Task Submission Complete",
300        "task_submission_failed" => "Task Submission Failed",
301        "task_set_name" => "Task Set Name",
302        "iterations" => "Iterations",
303        "duration" => "Duration",
304        "submission_overview" => "Submission Overview",
305        "status" => "Status",
306        "successfully_completed" => "Successfully Completed",
307        "submitted_before_failure" => "Submitted Before Failure",
308        "error_details" => "Error Details",
309        "task_set_complete" => "Task Set Complete",
310        "task_set_failed" => "Task Set Failed",
311        "task_set_progress" => "Task Set Progress",
312        "task_set_count" => "Task Set Count",
313        "result_collection_started" => "Result Collection Started",
314        "result_collection_complete" => "Result Collection Complete",
315        "task_sets" => "Task Sets",
316        "rows" => "Rows",
317        "columns" => "Columns",
318        "comparison_complete" => "Comparison Complete",
319        "comparison_name" => "Comparison Name",
320        "task_sets_compared" => "Task Sets Compared",
321        "common_networks" => "Common Networks",
322        "result_rows" => "Result Rows",
323        "result_columns" => "Result Columns",
324        "comparison_results" => "Comparison Results",
325        "total_items" => "Total Items",
326        "summary" => "Summary",
327        "items" => "Items",
328        _ => return None,
329    })
330}
331
332/// A type that can render itself into a Lark card JSON payload.
333///
334/// Implement this trait to define custom templates that work with
335/// [`LarkWebhookNotifier::send_template`].
336///
337/// All pre-built template structs and the output of [`CardBuilder::build`]
338/// implement this trait.
339pub trait LarkTemplate {
340    /// Render this template into a [`CardContent`] (a `serde_json::Value`).
341    fn generate(&self) -> CardContent;
342}
343
344/// Legacy task notification using a fixed Lark template reference ID.
345///
346/// Sends a card built from a Lark-hosted template (`template_id = "AAqz08XD5HCzP"`)
347/// with variables injected at render time. Use [`StartTaskTemplate`] or
348/// [`ReportTaskResultTemplate`] for fully custom cards instead.
349pub struct LegacyTaskTemplate {
350    pub task_name: String,
351    pub status: Option<i32>,
352    pub group: String,
353    pub prefix: String,
354    pub task_summary: String,
355    pub language: LanguageCode,
356}
357
358impl LegacyTaskTemplate {
359    fn t(&self, key: &str) -> String {
360        get_translation(key, self.language)
361    }
362}
363
364impl LarkTemplate for LegacyTaskTemplate {
365    fn generate(&self) -> CardContent {
366        let task_time = Local::now().format("%Y-%m-%d %H:%M").to_string();
367        let task_status = match self.status {
368            Some(s) if s != 0 => format!(
369                "<font color='red'> :CrossMark: {}: {} {s}</font>",
370                self.t("failed"),
371                self.t("return_code")
372            ),
373            _ => format!(
374                "<font color='green'> :CheckMark: {}</font>",
375                self.t("completed")
376            ),
377        };
378        blocks::template_reference(
379            "AAqz08XD5HCzP",
380            "1.0.3",
381            serde_json::json!({
382                "task_name": self.task_name,
383                "task_time": task_time,
384                "attachment_group": self.group,
385                "attachment_prefix": self.prefix,
386                "task_summary": self.task_summary,
387                "task_status": task_status,
388            }),
389        )
390        .into()
391    }
392}
393
394/// Card template for notifying that a job or task has started.
395///
396/// Generates a wathet (light blue) header card with the task name, start time,
397/// optional description/duration, running status, and optionally a storage
398/// column pair and collapsible running-overview panel.
399pub struct StartTaskTemplate {
400    pub task_name: String,
401    pub desc: Option<String>,
402    pub group: Option<String>,
403    pub prefix: Option<String>,
404    pub msg: Option<String>,
405    pub estimated_duration: Option<String>,
406    pub language: LanguageCode,
407}
408
409impl StartTaskTemplate {
410    fn t(&self, key: &str) -> String {
411        get_translation(key, self.language)
412    }
413}
414
415impl LarkTemplate for StartTaskTemplate {
416    fn generate(&self) -> CardContent {
417        let task_time = Local::now().format("%Y-%m-%d %H:%M").to_string();
418        let no_desc = get_translation("no_description", self.language);
419        let desc = self.desc.as_deref().unwrap_or(&no_desc);
420        let duration_text = self
421            .estimated_duration
422            .as_ref()
423            .map(|d| format!("\n**{}:** {d}", self.t("estimated_duration")))
424            .unwrap_or_default();
425        let task_status = format!(
426            "<font color='wathet-400'> :StatusInFlight: {}</font>",
427            self.t("running")
428        );
429        let main_text = format!(
430            "**{}:** {}\n**{}:** {}\n**{}:** {}{}\n**{}:** {}",
431            self.t("task_name"),
432            self.task_name,
433            self.t("start_time"),
434            task_time,
435            self.t("task_description"),
436            desc,
437            duration_text,
438            self.t("execution_status"),
439            task_status,
440        );
441
442        let mut elements = vec![blocks::markdown(&main_text).into()];
443
444        if self.group.is_some() || self.prefix.is_some() {
445            elements.push(storage_columns(
446                &self.t("result_storage"),
447                self.group.as_deref().unwrap_or(""),
448                &self.t("storage_prefix"),
449                self.prefix.as_deref().unwrap_or(""),
450            ));
451        }
452
453        if let Some(msg) = &self.msg {
454            elements.push(overview_panel(&self.t("running_overview"), msg));
455        }
456
457        let hdr: Value = HeaderBlock {
458            title: self.t("task_notification"),
459            template: "wathet".into(),
460            subtitle: Some("".into()),
461            text_tag_list: Some(vec![
462                TextTag {
463                    text: self.t("running"),
464                    color: "wathet".into(),
465                }
466                .into(),
467            ]),
468            padding: Some("12px 8px 12px 8px".into()),
469        }
470        .into();
471        Card {
472            elements,
473            header: hdr,
474            config: Some(blocks::config_textsize_normal_v2()),
475            ..Default::default()
476        }
477        .into()
478    }
479}
480
481/// Card template for reporting that a job completed successfully (green header).
482///
483/// Shows task name, completion time, optional description, duration, and
484/// optionally a storage column pair and result-overview panel.
485/// For failure notifications use [`ReportFailureTaskTemplate`].
486pub struct ReportTaskResultTemplate {
487    pub task_name: String,
488    pub status: i32,
489    pub group: Option<String>,
490    pub prefix: Option<String>,
491    pub desc: Option<String>,
492    pub msg: Option<String>,
493    pub duration: Option<String>,
494    pub title: Option<String>,
495    pub language: LanguageCode,
496}
497
498impl ReportTaskResultTemplate {
499    fn t(&self, key: &str) -> String {
500        get_translation(key, self.language)
501    }
502}
503
504impl LarkTemplate for ReportTaskResultTemplate {
505    fn generate(&self) -> CardContent {
506        let task_time = Local::now().format("%Y-%m-%d %H:%M").to_string();
507        let task_status = format!(
508            "<font color='green'> :CheckMark: {}</font>",
509            self.t("completed")
510        );
511        let desc_text = self
512            .desc
513            .as_ref()
514            .map(|d| format!("\n**{}:** {d}", self.t("task_description")))
515            .unwrap_or_default();
516        let dur_text = self
517            .duration
518            .as_ref()
519            .map(|d| format!("\n**{}:** {d}", self.t("execution_duration")))
520            .unwrap_or_default();
521        let main_text = format!(
522            "**{}:** {}\n**{}:** {}{}{}\n**{}:** {}",
523            self.t("task_name"),
524            self.task_name,
525            self.t("completion_time"),
526            task_time,
527            desc_text,
528            dur_text,
529            self.t("execution_status"),
530            task_status,
531        );
532
533        let mut elements = vec![blocks::markdown(&main_text).into()];
534        if self.group.is_some() || self.prefix.is_some() {
535            elements.push(storage_columns(
536                &self.t("group"),
537                self.group.as_deref().unwrap_or(""),
538                &self.t("storage_prefix"),
539                self.prefix.as_deref().unwrap_or(""),
540            ));
541        }
542        if let Some(msg) = &self.msg {
543            elements.push(overview_panel(&self.t("result_overview"), msg));
544        }
545
546        let default_title = self.t("task_completion_notification");
547        let card_title = self.title.as_deref().unwrap_or(&default_title);
548        let hdr: Value = HeaderBlock {
549            title: card_title.into(),
550            template: "green".into(),
551            subtitle: Some("".into()),
552            text_tag_list: Some(vec![
553                TextTag {
554                    text: self.t("success"),
555                    color: "green".into(),
556                }
557                .into(),
558            ]),
559            padding: Some("12px 8px 12px 8px".into()),
560        }
561        .into();
562        Card {
563            elements,
564            header: hdr,
565            config: Some(blocks::config_textsize_normal_v2()),
566            ..Default::default()
567        }
568        .into()
569    }
570}
571
572/// Card template for reporting a job failure (red header).
573///
574/// Shows task name, completion time, non-zero exit `status`, optional
575/// description/duration, and optionally a storage column pair and
576/// result-overview panel.
577pub struct ReportFailureTaskTemplate {
578    pub task_name: String,
579    /// Non-zero process exit code. Displayed in the status line.
580    pub status: i32,
581    pub group: Option<String>,
582    pub prefix: Option<String>,
583    pub desc: Option<String>,
584    pub msg: Option<String>,
585    pub duration: Option<String>,
586    pub title: Option<String>,
587    pub language: LanguageCode,
588}
589
590impl ReportFailureTaskTemplate {
591    fn t(&self, key: &str) -> String {
592        get_translation(key, self.language)
593    }
594}
595
596impl LarkTemplate for ReportFailureTaskTemplate {
597    fn generate(&self) -> CardContent {
598        let task_time = Local::now().format("%Y-%m-%d %H:%M").to_string();
599        let status = self.status;
600        let task_status = format!(
601            "<font color='red'> :CrossMark: {}: {status}</font>",
602            self.t("failed")
603        );
604        let desc_text = self
605            .desc
606            .as_ref()
607            .map(|d| format!("\n**{}:** {d}", self.t("task_description")))
608            .unwrap_or_default();
609        let dur_text = self
610            .duration
611            .as_ref()
612            .map(|d| format!("\n**{}:** {d}", self.t("execution_duration")))
613            .unwrap_or_default();
614        let main_text = format!(
615            "**{}:** {}\n**{}:** {}{}{}\n**{}:** {}",
616            self.t("task_name"),
617            self.task_name,
618            self.t("completion_time"),
619            task_time,
620            desc_text,
621            dur_text,
622            self.t("execution_status"),
623            task_status,
624        );
625
626        let mut elements = vec![blocks::markdown(&main_text).into()];
627        if self.group.is_some() || self.prefix.is_some() {
628            elements.push(storage_columns(
629                &self.t("group"),
630                self.group.as_deref().unwrap_or(""),
631                &self.t("storage_prefix"),
632                self.prefix.as_deref().unwrap_or(""),
633            ));
634        }
635        if let Some(msg) = &self.msg {
636            elements.push(overview_panel(&self.t("result_overview"), msg));
637        }
638
639        let default_title = self.t("task_failure_notification");
640        let card_title = self.title.as_deref().unwrap_or(&default_title);
641        let hdr: Value = HeaderBlock {
642            title: card_title.into(),
643            template: "red".into(),
644            subtitle: Some("".into()),
645            text_tag_list: Some(vec![
646                TextTag {
647                    text: self.t("failure"),
648                    color: "red".into(),
649                }
650                .into(),
651            ]),
652            padding: Some("12px 8px 12px 8px".into()),
653        }
654        .into();
655        Card {
656            elements,
657            header: hdr,
658            config: Some(blocks::config_textsize_normal_v2()),
659            ..Default::default()
660        }
661        .into()
662    }
663}
664
665/// A simple one-block card with a title, a color, and a markdown body.
666pub struct SimpleMessageTemplate {
667    pub title: String,
668    pub content: String,
669    pub color: ColorTheme,
670    pub language: LanguageCode,
671}
672
673impl LarkTemplate for SimpleMessageTemplate {
674    fn generate(&self) -> CardContent {
675        let hdr: Value = HeaderBlock {
676            title: self.title.clone(),
677            template: self.color.as_str().into(),
678            ..Default::default()
679        }
680        .into();
681        Card {
682            elements: vec![blocks::markdown(&self.content).into()],
683            header: hdr,
684            ..Default::default()
685        }
686        .into()
687    }
688}
689
690/// Alert notification card. Header color and icon are driven by [`SeverityLevel`].
691///
692/// Use the [`send_alert`](crate::send_alert) convenience function to send one
693/// without instantiating this struct directly.
694pub struct AlertTemplate {
695    pub title: String,
696    pub message: String,
697    pub severity: SeverityLevel,
698    /// Timestamp string to display in the card body (caller-formatted).
699    pub timestamp: String,
700    pub language: LanguageCode,
701}
702
703impl AlertTemplate {
704    fn t(&self, key: &str) -> String {
705        get_translation(key, self.language)
706    }
707}
708
709impl LarkTemplate for AlertTemplate {
710    fn generate(&self) -> CardContent {
711        let color = self.severity.color();
712        let icon = self.severity.icon();
713        let severity_str = self.severity.as_str().to_uppercase();
714        let body_text = format!(
715            "{icon} **{}**\n\n**{}:** {}",
716            self.message,
717            self.t("timestamp"),
718            self.timestamp,
719        );
720        let hdr: Value = HeaderBlock {
721            title: self.title.clone(),
722            template: color.into(),
723            subtitle: Some(severity_str.clone()),
724            text_tag_list: Some(vec![
725                TextTag {
726                    text: severity_str,
727                    color: color.into(),
728                }
729                .into(),
730            ]),
731            ..Default::default()
732        }
733        .into();
734        Card {
735            elements: vec![blocks::markdown(&body_text).into()],
736            header: hdr,
737            ..Default::default()
738        }
739        .into()
740    }
741}
742
743/// Pass-through template that wraps a pre-built [`CardContent`] value.
744///
745/// Useful when you have a `serde_json::Value` from an external source and want
746/// to send it through [`LarkWebhookNotifier::send_template`].
747pub struct RawContentTemplate {
748    pub content: CardContent,
749    pub language: LanguageCode,
750}
751
752impl LarkTemplate for RawContentTemplate {
753    fn generate(&self) -> CardContent {
754        self.content.clone()
755    }
756}
757
758/// Output type of [`CardBuilder::build`] and all workflow functions.
759///
760/// Implements [`LarkTemplate`]; pass directly to [`LarkWebhookNotifier::send_template`].
761pub struct GenericCardTemplate {
762    pub(crate) content: CardContent,
763}
764
765impl LarkTemplate for GenericCardTemplate {
766    fn generate(&self) -> CardContent {
767        self.content.clone()
768    }
769}
770
771pub(crate) fn storage_columns(
772    group_label: &str,
773    group_value: &str,
774    prefix_label: &str,
775    prefix_value: &str,
776) -> Value {
777    let col1: Value = Column {
778        elements: vec![
779            Markdown {
780                content: format!("**{group_label}**\n{group_value}"),
781                text_align: TextAlign::Center,
782                text_size: TextSize::NormalV2,
783                margin: "0px 4px 0px 4px".into(),
784            }
785            .into(),
786        ],
787        width: ColumnWidth::Auto,
788        ..Default::default()
789    }
790    .into();
791    let col2: Value = Column {
792        elements: vec![
793            Markdown {
794                content: format!("**{prefix_label}**\n{prefix_value}"),
795                text_align: TextAlign::Center,
796                text_size: TextSize::NormalV2,
797                ..Default::default()
798            }
799            .into(),
800        ],
801        width: ColumnWidth::Weighted,
802        weight: Some(1),
803        ..Default::default()
804    }
805    .into();
806    ColumnSet {
807        columns: vec![col1, col2],
808        ..Default::default()
809    }
810    .into()
811}
812
813pub(crate) fn overview_panel(title: &str, content: &str) -> Value {
814    CollapsiblePanel {
815        title_markdown: format!("**<font color='grey-800'>{title}</font>**"),
816        elements: vec![
817            Markdown {
818                content: content.into(),
819                text_size: TextSize::NormalV2,
820                ..Default::default()
821            }
822            .into(),
823        ],
824        expanded: false,
825        ..Default::default()
826    }
827    .into()
828}
829
830#[cfg(test)]
831mod tests {
832    use super::*;
833
834    #[test]
835    fn test_translation_zh() {
836        assert_eq!(get_translation("task_name", LanguageCode::Zh), "作业名称");
837        assert_eq!(get_translation("running", LanguageCode::Zh), "正在运行");
838    }
839
840    #[test]
841    fn test_translation_en() {
842        assert_eq!(get_translation("task_name", LanguageCode::En), "Job Name");
843        assert_eq!(get_translation("running", LanguageCode::En), "Running Now");
844    }
845
846    #[test]
847    fn test_translation_fallback() {
848        let result = get_translation("nonexistent_key_xyz", LanguageCode::Zh);
849        assert_eq!(result, "nonexistent_key_xyz");
850    }
851
852    #[test]
853    fn test_simple_message_generate() {
854        let t = SimpleMessageTemplate {
855            title: "Hello".to_owned(),
856            content: "World".to_owned(),
857            color: ColorTheme::Blue,
858            language: LanguageCode::Zh,
859        };
860        let card = t.generate();
861        assert_eq!(card["schema"], "2.0");
862        assert_eq!(card["header"]["title"]["content"], "Hello");
863        assert_eq!(card["header"]["template"], "blue");
864        assert_eq!(card["body"]["elements"][0]["content"], "World");
865        assert!(card.get("config").is_none());
866    }
867
868    #[test]
869    fn test_alert_template_warning() {
870        let t = AlertTemplate {
871            title: "Alert!".to_owned(),
872            message: "High CPU".to_owned(),
873            severity: SeverityLevel::Warning,
874            timestamp: "2026-01-01 00:00:00".to_owned(),
875            language: LanguageCode::En,
876        };
877        let card = t.generate();
878        assert_eq!(card["header"]["template"], "orange");
879        let body_content = card["body"]["elements"][0]["content"].as_str().unwrap();
880        assert!(body_content.contains("High CPU"));
881    }
882
883    #[test]
884    fn test_raw_content_passthrough() {
885        let content = serde_json::json!({"schema": "2.0", "custom": true});
886        let t = RawContentTemplate {
887            content: content.clone(),
888            language: LanguageCode::Zh,
889        };
890        assert_eq!(t.generate(), content);
891    }
892
893    #[test]
894    fn test_legacy_task_template() {
895        let t = LegacyTaskTemplate {
896            task_name: "my-task".to_owned(),
897            status: Some(0),
898            group: "g1".to_owned(),
899            prefix: "p/".to_owned(),
900            task_summary: "summary".to_owned(),
901            language: LanguageCode::Zh,
902        };
903        let card = t.generate();
904        assert_eq!(card["type"], "template");
905        assert_eq!(card["data"]["template_id"], "AAqz08XD5HCzP");
906        assert_eq!(card["data"]["template_variable"]["task_name"], "my-task");
907        assert!(
908            card["data"]["template_variable"]["task_status"]
909                .as_str()
910                .unwrap()
911                .contains("CheckMark")
912        );
913    }
914
915    #[test]
916    fn test_start_task_template_structure() {
917        let t = StartTaskTemplate {
918            task_name: "build".to_owned(),
919            desc: None,
920            group: None,
921            prefix: None,
922            msg: None,
923            estimated_duration: None,
924            language: LanguageCode::Zh,
925        };
926        let card = t.generate();
927        assert_eq!(card["schema"], "2.0");
928        assert_eq!(card["header"]["template"], "wathet");
929        assert!(card.get("config").is_some());
930    }
931
932    #[test]
933    fn test_report_task_result_green() {
934        let t = ReportTaskResultTemplate {
935            task_name: "build".to_owned(),
936            status: 0,
937            group: None,
938            prefix: None,
939            desc: None,
940            msg: None,
941            duration: None,
942            title: None,
943            language: LanguageCode::Zh,
944        };
945        let card = t.generate();
946        assert_eq!(card["header"]["template"], "green");
947    }
948
949    #[test]
950    fn test_report_failure_task_red() {
951        let t = ReportFailureTaskTemplate {
952            task_name: "build".to_owned(),
953            status: 1,
954            group: None,
955            prefix: None,
956            desc: None,
957            msg: None,
958            duration: None,
959            title: None,
960            language: LanguageCode::Zh,
961        };
962        let card = t.generate();
963        assert_eq!(card["header"]["template"], "red");
964    }
965
966    #[test]
967    fn test_report_failure_body_contains_status_code() {
968        let t = ReportFailureTaskTemplate {
969            task_name: "myjob".to_owned(),
970            status: 42,
971            group: None,
972            prefix: None,
973            desc: None,
974            msg: None,
975            duration: None,
976            title: None,
977            language: LanguageCode::Zh,
978        };
979        let card = t.generate();
980        let body_text = card["body"]["elements"][0]["content"].as_str().unwrap();
981        assert!(
982            body_text.contains("42"),
983            "body should contain the return code"
984        );
985        assert!(body_text.contains("myjob"), "body should contain task name");
986    }
987
988    #[test]
989    fn test_start_task_template_body_element_count_no_group() {
990        let t = StartTaskTemplate {
991            task_name: "t".to_owned(),
992            desc: None,
993            group: None,
994            prefix: None,
995            msg: None,
996            estimated_duration: None,
997            language: LanguageCode::Zh,
998        };
999        let card = t.generate();
1000        let elements = card["body"]["elements"].as_array().unwrap();
1001        assert_eq!(
1002            elements.len(),
1003            1,
1004            "only markdown block when no group/prefix/msg"
1005        );
1006    }
1007
1008    #[test]
1009    fn test_start_task_template_with_group_prefix_has_column_set() {
1010        let t = StartTaskTemplate {
1011            task_name: "t".to_owned(),
1012            desc: None,
1013            group: Some("grp".to_owned()),
1014            prefix: Some("pfx/".to_owned()),
1015            msg: None,
1016            estimated_duration: None,
1017            language: LanguageCode::Zh,
1018        };
1019        let card = t.generate();
1020        let elements = card["body"]["elements"].as_array().unwrap();
1021        assert_eq!(
1022            elements.len(),
1023            2,
1024            "markdown + column_set when group/prefix set"
1025        );
1026        assert_eq!(elements[1]["tag"], "column_set");
1027    }
1028
1029    #[test]
1030    fn test_start_task_template_with_group_prefix_and_msg() {
1031        let t = StartTaskTemplate {
1032            task_name: "t".to_owned(),
1033            desc: None,
1034            group: Some("grp".to_owned()),
1035            prefix: Some("pfx/".to_owned()),
1036            msg: Some("running details".to_owned()),
1037            estimated_duration: None,
1038            language: LanguageCode::Zh,
1039        };
1040        let card = t.generate();
1041        let elements = card["body"]["elements"].as_array().unwrap();
1042        assert_eq!(elements.len(), 3, "markdown + column_set + collapsible");
1043        assert_eq!(elements[2]["tag"], "collapsible_panel");
1044    }
1045
1046    #[test]
1047    fn test_language_switching_body_text() {
1048        let zh_t = SimpleMessageTemplate {
1049            title: "T".to_owned(),
1050            content: "C".to_owned(),
1051            color: ColorTheme::Blue,
1052            language: LanguageCode::Zh,
1053        };
1054        let en_t = SimpleMessageTemplate {
1055            title: "T".to_owned(),
1056            content: "C".to_owned(),
1057            color: ColorTheme::Blue,
1058            language: LanguageCode::En,
1059        };
1060        // body content is the same (caller-supplied), but header title translations differ
1061        let zh_card = zh_t.generate();
1062        let en_card = en_t.generate();
1063        assert_eq!(zh_card["header"]["title"]["content"], "T");
1064        assert_eq!(en_card["header"]["title"]["content"], "T");
1065        // schema and structure identical
1066        assert_eq!(zh_card["schema"], en_card["schema"]);
1067    }
1068
1069    #[test]
1070    fn test_start_task_language_switching_zh_en() {
1071        let make = |lang| StartTaskTemplate {
1072            task_name: "job".to_owned(),
1073            desc: None,
1074            group: None,
1075            prefix: None,
1076            msg: None,
1077            estimated_duration: None,
1078            language: lang,
1079        };
1080        let zh_body = make(LanguageCode::Zh).generate();
1081        let en_body = make(LanguageCode::En).generate();
1082        let zh_text = zh_body["body"]["elements"][0]["content"].as_str().unwrap();
1083        let en_text = en_body["body"]["elements"][0]["content"].as_str().unwrap();
1084        // zh and en produce different label text in the body
1085        assert_ne!(zh_text, en_text, "zh and en body text should differ");
1086        assert!(zh_text.contains("job"), "both should contain the task name");
1087        assert!(en_text.contains("job"));
1088    }
1089
1090    #[test]
1091    fn test_report_task_result_body_contains_task_name() {
1092        let t = ReportTaskResultTemplate {
1093            task_name: "myjob".to_owned(),
1094            status: 0,
1095            group: Some("g".to_owned()),
1096            prefix: Some("p/".to_owned()),
1097            desc: None,
1098            msg: None,
1099            duration: None,
1100            title: None,
1101            language: LanguageCode::Zh,
1102        };
1103        let card = t.generate();
1104        let elements = card["body"]["elements"].as_array().unwrap();
1105        assert_eq!(
1106            elements.len(),
1107            2,
1108            "markdown + column_set when group/prefix set"
1109        );
1110        let body_text = elements[0]["content"].as_str().unwrap();
1111        assert!(body_text.contains("myjob"));
1112        assert_eq!(elements[1]["tag"], "column_set");
1113    }
1114
1115    #[test]
1116    fn test_generic_card_template_passthrough() {
1117        use crate::builder::CardBuilder;
1118        let card_val = CardBuilder::new()
1119            .header("My Card", None, None, None)
1120            .markdown(
1121                "hello",
1122                crate::blocks::TextAlign::Left,
1123                crate::blocks::TextSize::Normal,
1124            )
1125            .build()
1126            .generate();
1127        assert_eq!(card_val["schema"], "2.0");
1128        assert_eq!(card_val["header"]["title"]["content"], "My Card");
1129        assert_eq!(card_val["body"]["elements"][0]["content"], "hello");
1130    }
1131}