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, 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}