Skip to main content

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}