ironflow_engine/notify/formatter.rs
1//! [`MessageFormatter`] trait -- platform-specific event formatting.
2//!
3//! Subscribers can accept an optional [`MessageFormatter`] to customise
4//! how domain events are rendered for their target platform. This lets
5//! the same subscriber struct (e.g. a future `SlackSubscriber`) produce
6//! Slack Block Kit, Discord Embeds, or Telegram Markdown without
7//! changing its delivery logic.
8
9use super::Event;
10
11/// A formatted message ready to be sent to an external platform.
12///
13/// The `body` field contains the platform-specific payload (JSON string,
14/// Markdown text, etc.). `content_type` tells the subscriber which
15/// `Content-Type` header to use when delivering.
16///
17/// # Examples
18///
19/// ```
20/// use ironflow_engine::notify::FormattedMessage;
21///
22/// let message = FormattedMessage::json(r#"{"text":"hello"}"#);
23/// assert_eq!(message.content_type(), "application/json");
24/// ```
25#[derive(Debug, Clone)]
26pub struct FormattedMessage {
27 body: String,
28 content_type: &'static str,
29}
30
31impl FormattedMessage {
32 /// Create a message with an explicit content type.
33 ///
34 /// # Examples
35 ///
36 /// ```
37 /// use ironflow_engine::notify::FormattedMessage;
38 ///
39 /// let msg = FormattedMessage::new("hello", "text/plain");
40 /// assert_eq!(msg.body(), "hello");
41 /// assert_eq!(msg.content_type(), "text/plain");
42 /// ```
43 pub fn new(body: &str, content_type: &'static str) -> Self {
44 Self {
45 body: body.to_string(),
46 content_type,
47 }
48 }
49
50 /// Create a JSON message (`application/json`).
51 ///
52 /// # Examples
53 ///
54 /// ```
55 /// use ironflow_engine::notify::FormattedMessage;
56 ///
57 /// let msg = FormattedMessage::json(r#"{"text":"hello"}"#);
58 /// assert_eq!(msg.content_type(), "application/json");
59 /// ```
60 pub fn json(body: &str) -> Self {
61 Self::new(body, "application/json")
62 }
63
64 /// Create a plain text message (`text/plain`).
65 ///
66 /// # Examples
67 ///
68 /// ```
69 /// use ironflow_engine::notify::FormattedMessage;
70 ///
71 /// let msg = FormattedMessage::text("hello");
72 /// assert_eq!(msg.content_type(), "text/plain");
73 /// ```
74 pub fn text(body: &str) -> Self {
75 Self::new(body, "text/plain")
76 }
77
78 /// The message body.
79 pub fn body(&self) -> &str {
80 &self.body
81 }
82
83 /// The MIME content type.
84 pub fn content_type(&self) -> &'static str {
85 self.content_type
86 }
87}
88
89/// Converts domain events into platform-specific messages.
90///
91/// Implement this trait to control how events appear on Slack, Discord,
92/// Telegram, or any other messaging platform. The formatter is decoupled
93/// from delivery: subscribers handle retries and HTTP, formatters handle
94/// presentation.
95///
96/// Return `None` from [`format`](MessageFormatter::format) to silently
97/// skip events that the formatter does not care about.
98///
99/// # Examples
100///
101/// ```
102/// use ironflow_engine::notify::{Event, FormattedMessage, MessageFormatter};
103///
104/// struct PlainTextFormatter;
105///
106/// impl MessageFormatter for PlainTextFormatter {
107/// fn name(&self) -> &str { "plain-text" }
108///
109/// fn format(&self, event: &Event) -> Option<FormattedMessage> {
110/// let text = format!("[{}] event fired", event.event_type());
111/// Some(FormattedMessage::text(&text))
112/// }
113/// }
114///
115/// let formatter = PlainTextFormatter;
116/// let event = Event::RunCreated {
117/// run_id: uuid::Uuid::now_v7(),
118/// workflow_name: "deploy".to_string(),
119/// at: chrono::Utc::now(),
120/// };
121/// let msg = formatter.format(&event).unwrap();
122/// assert!(msg.body().contains("run_created"));
123/// ```
124pub trait MessageFormatter: Send + Sync {
125 /// A short identifier for this formatter (used in logs).
126 fn name(&self) -> &str;
127
128 /// Convert an event into a platform-specific message.
129 ///
130 /// Return `None` to skip the event silently.
131 fn format(&self, event: &Event) -> Option<FormattedMessage>;
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137 use chrono::Utc;
138 use uuid::Uuid;
139
140 #[test]
141 fn formatted_message_json() {
142 let msg = FormattedMessage::json(r#"{"text":"hello"}"#);
143 assert_eq!(msg.body(), r#"{"text":"hello"}"#);
144 assert_eq!(msg.content_type(), "application/json");
145 }
146
147 #[test]
148 fn formatted_message_text() {
149 let msg = FormattedMessage::text("hello world");
150 assert_eq!(msg.body(), "hello world");
151 assert_eq!(msg.content_type(), "text/plain");
152 }
153
154 #[test]
155 fn formatted_message_custom_content_type() {
156 let msg = FormattedMessage::new("<b>bold</b>", "text/html");
157 assert_eq!(msg.body(), "<b>bold</b>");
158 assert_eq!(msg.content_type(), "text/html");
159 }
160
161 struct TestFormatter;
162
163 impl MessageFormatter for TestFormatter {
164 fn name(&self) -> &str {
165 "test"
166 }
167
168 fn format(&self, event: &Event) -> Option<FormattedMessage> {
169 match event {
170 Event::RunCreated { workflow_name, .. } => {
171 let body = format!(r#"{{"text":"Run created for {}"}}"#, workflow_name);
172 Some(FormattedMessage::json(&body))
173 }
174 _ => None,
175 }
176 }
177 }
178
179 #[test]
180 fn formatter_formats_matching_event() {
181 let formatter = TestFormatter;
182 let event = Event::RunCreated {
183 run_id: Uuid::now_v7(),
184 workflow_name: "deploy".to_string(),
185 at: Utc::now(),
186 };
187
188 let msg = formatter.format(&event);
189 assert!(msg.is_some());
190 let msg = msg.unwrap();
191 assert!(msg.body().contains("deploy"));
192 assert_eq!(msg.content_type(), "application/json");
193 }
194
195 #[test]
196 fn formatter_skips_non_matching_event() {
197 let formatter = TestFormatter;
198 let event = Event::UserSignedIn {
199 user_id: Uuid::now_v7(),
200 username: "alice".to_string(),
201 at: Utc::now(),
202 };
203
204 assert!(formatter.format(&event).is_none());
205 }
206
207 #[test]
208 fn formatter_name() {
209 let formatter = TestFormatter;
210 assert_eq!(formatter.name(), "test");
211 }
212}