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, RunCreatedEvent};
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(RunCreatedEvent {
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 crate::notify::{RunCreatedEvent, UserSignedInEvent};
138 use chrono::Utc;
139 use uuid::Uuid;
140
141 #[test]
142 fn formatted_message_json() {
143 let msg = FormattedMessage::json(r#"{"text":"hello"}"#);
144 assert_eq!(msg.body(), r#"{"text":"hello"}"#);
145 assert_eq!(msg.content_type(), "application/json");
146 }
147
148 #[test]
149 fn formatted_message_text() {
150 let msg = FormattedMessage::text("hello world");
151 assert_eq!(msg.body(), "hello world");
152 assert_eq!(msg.content_type(), "text/plain");
153 }
154
155 #[test]
156 fn formatted_message_custom_content_type() {
157 let msg = FormattedMessage::new("<b>bold</b>", "text/html");
158 assert_eq!(msg.body(), "<b>bold</b>");
159 assert_eq!(msg.content_type(), "text/html");
160 }
161
162 struct TestFormatter;
163
164 impl MessageFormatter for TestFormatter {
165 fn name(&self) -> &str {
166 "test"
167 }
168
169 fn format(&self, event: &Event) -> Option<FormattedMessage> {
170 match event {
171 Event::RunCreated(e) => {
172 let body = format!(r#"{{"text":"Run created for {}"}}"#, e.workflow_name);
173 Some(FormattedMessage::json(&body))
174 }
175 _ => None,
176 }
177 }
178 }
179
180 #[test]
181 fn formatter_formats_matching_event() {
182 let formatter = TestFormatter;
183 let event = Event::RunCreated(RunCreatedEvent {
184 run_id: Uuid::now_v7(),
185 workflow_name: "deploy".to_string(),
186 at: Utc::now(),
187 });
188
189 let msg = formatter.format(&event);
190 assert!(msg.is_some());
191 let msg = msg.unwrap();
192 assert!(msg.body().contains("deploy"));
193 assert_eq!(msg.content_type(), "application/json");
194 }
195
196 #[test]
197 fn formatter_skips_non_matching_event() {
198 let formatter = TestFormatter;
199 let event = Event::UserSignedIn(UserSignedInEvent {
200 user_id: Uuid::now_v7(),
201 username: "alice".to_string(),
202 at: Utc::now(),
203 });
204
205 assert!(formatter.format(&event).is_none());
206 }
207
208 #[test]
209 fn formatter_name() {
210 let formatter = TestFormatter;
211 assert_eq!(formatter.name(), "test");
212 }
213}