Skip to main content

midtown/
message.rs

1//! Message types for channel communication
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7/// Types of messages that can be sent through a channel.
8///
9/// # Examples
10///
11/// ```
12/// use midtown::MessageType;
13///
14/// let msg_type = MessageType::default();
15/// assert_eq!(msg_type, MessageType::Text);
16/// ```
17#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
18#[serde(rename_all = "snake_case")]
19pub enum MessageType {
20    /// Regular text message
21    #[default]
22    Text,
23    /// System notification
24    System,
25    /// Command or instruction
26    Command,
27    /// Status update
28    Status,
29    /// Error notification
30    Error,
31    /// Action message (IRC-style /me)
32    Action,
33    /// Insight message (architectural diagram, codebase learning)
34    Insight,
35}
36
37/// A message in the channel log.
38///
39/// Messages are the primary unit of communication in midtown channels.
40/// Each message has a unique ID, timestamp, sender, content, and type.
41///
42/// # Examples
43///
44/// Creating different message types:
45///
46/// ```
47/// use midtown::{Message, MessageType};
48///
49/// // Text message from an agent
50/// let text = Message::text("agent1", "Hello, team!");
51/// assert_eq!(text.from, "agent1");
52/// assert_eq!(text.message_type, MessageType::Text);
53///
54/// // System notification
55/// let sys = Message::system("Build completed");
56/// assert_eq!(sys.from, "system");
57/// assert_eq!(sys.message_type, MessageType::System);
58///
59/// // Status update
60/// let status = Message::status("agent2", "Working on task !42");
61/// assert_eq!(status.message_type, MessageType::Status);
62/// ```
63///
64/// Messages serialize to JSON for storage:
65///
66/// ```
67/// use midtown::Message;
68///
69/// let msg = Message::text("agent1", "Hello");
70/// let json = serde_json::to_string(&msg).unwrap();
71/// assert!(json.contains("agent1"));
72/// assert!(json.contains("Hello"));
73/// ```
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct Message {
76    /// Unique message identifier
77    pub id: String,
78    /// When the message was created
79    pub timestamp: DateTime<Utc>,
80    /// Who sent the message (agent name or role)
81    pub from: String,
82    /// Message content
83    pub content: String,
84    /// Type of message
85    #[serde(rename = "type")]
86    pub message_type: MessageType,
87    /// Channel name (defaults to "midtown" for backward compatibility).
88    /// Stored as Option for backward compat with existing struct literals,
89    /// but always initialized in constructors.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub channel: Option<String>,
92    /// Optional source channel for cross-posts (None if not a cross-post)
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub source_channel: Option<String>,
95    /// Optional Claude session ID for disambiguation when multiple sessions
96    /// share the same coworker name. `None` for messages from system, lead,
97    /// or legacy messages before session tracking was added.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub session_id: Option<String>,
100}
101
102impl Message {
103    /// Create a new message with auto-generated ID and timestamp.
104    ///
105    /// # Examples
106    ///
107    /// ```
108    /// use midtown::{Message, MessageType};
109    ///
110    /// let msg = Message::new("agent1", "Task completed", MessageType::Status);
111    /// assert_eq!(msg.from, "agent1");
112    /// assert_eq!(msg.content, "Task completed");
113    /// assert!(!msg.id.is_empty()); // UUID auto-generated
114    /// ```
115    pub fn new(
116        from: impl Into<String>,
117        content: impl Into<String>,
118        message_type: MessageType,
119    ) -> Self {
120        Self {
121            id: Uuid::new_v4().to_string(),
122            timestamp: Utc::now(),
123            from: from.into(),
124            content: content.into(),
125            message_type,
126            channel: None,
127            source_channel: None,
128            session_id: None,
129        }
130    }
131
132    /// Create a new message for a specific channel.
133    ///
134    /// # Examples
135    ///
136    /// ```
137    /// use midtown::{Message, MessageType};
138    ///
139    /// let msg = Message::for_channel("pr-discussion", "agent1", "Let's review", MessageType::Text);
140    /// assert_eq!(msg.channel_name(), "pr-discussion");
141    /// ```
142    pub fn for_channel(
143        channel: impl Into<String>,
144        from: impl Into<String>,
145        content: impl Into<String>,
146        message_type: MessageType,
147    ) -> Self {
148        Self {
149            id: Uuid::new_v4().to_string(),
150            timestamp: Utc::now(),
151            from: from.into(),
152            content: content.into(),
153            message_type,
154            channel: Some(channel.into()),
155            source_channel: None,
156            session_id: None,
157        }
158    }
159
160    /// Get the channel name (defaults to "midtown" if not set).
161    pub fn channel_name(&self) -> &str {
162        self.channel.as_deref().unwrap_or("midtown")
163    }
164
165    /// Create a text message.
166    ///
167    /// # Examples
168    ///
169    /// ```
170    /// use midtown::{Message, MessageType};
171    ///
172    /// let msg = Message::text("alice", "Hello world");
173    /// assert_eq!(msg.message_type, MessageType::Text);
174    /// ```
175    pub fn text(from: impl Into<String>, content: impl Into<String>) -> Self {
176        Self::new(from, content, MessageType::Text)
177    }
178
179    /// Create a system message.
180    ///
181    /// System messages automatically use "system" as the sender.
182    ///
183    /// # Examples
184    ///
185    /// ```
186    /// use midtown::Message;
187    ///
188    /// let msg = Message::system("Daemon started");
189    /// assert_eq!(msg.from, "system");
190    /// ```
191    pub fn system(content: impl Into<String>) -> Self {
192        Self::new("system", content, MessageType::System)
193    }
194
195    /// Create a command message.
196    ///
197    /// # Examples
198    ///
199    /// ```
200    /// use midtown::{Message, MessageType};
201    ///
202    /// let msg = Message::command("lead", "Build the feature");
203    /// assert_eq!(msg.message_type, MessageType::Command);
204    /// ```
205    pub fn command(from: impl Into<String>, content: impl Into<String>) -> Self {
206        Self::new(from, content, MessageType::Command)
207    }
208
209    /// Create a status message.
210    ///
211    /// # Examples
212    ///
213    /// ```
214    /// use midtown::{Message, MessageType};
215    ///
216    /// let msg = Message::status("worker1", "Compiling...");
217    /// assert_eq!(msg.message_type, MessageType::Status);
218    /// ```
219    pub fn status(from: impl Into<String>, content: impl Into<String>) -> Self {
220        Self::new(from, content, MessageType::Status)
221    }
222
223    /// Create an error message.
224    ///
225    /// # Examples
226    ///
227    /// ```
228    /// use midtown::{Message, MessageType};
229    ///
230    /// let msg = Message::error("worker1", "Build failed");
231    /// assert_eq!(msg.message_type, MessageType::Error);
232    /// ```
233    pub fn error(from: impl Into<String>, content: impl Into<String>) -> Self {
234        Self::new(from, content, MessageType::Error)
235    }
236
237    /// Create an action message (IRC-style /me).
238    ///
239    /// Action messages are displayed as `* name action` in chat,
240    /// following IRC convention.
241    ///
242    /// # Examples
243    ///
244    /// ```
245    /// use midtown::{Message, MessageType};
246    ///
247    /// let msg = Message::action("lexington", "investigating the auth bug");
248    /// assert_eq!(msg.message_type, MessageType::Action);
249    /// assert_eq!(msg.from, "lexington");
250    /// // Displays as: * lexington investigating the auth bug
251    /// ```
252    pub fn action(from: impl Into<String>, content: impl Into<String>) -> Self {
253        Self::new(from, content, MessageType::Action)
254    }
255
256    /// Create an insight message (architectural diagram, codebase learning).
257    ///
258    /// Insight messages contain analysis or visualizations generated by
259    /// specialized headless sessions (architect, clusterer, etc.).
260    ///
261    /// # Examples
262    ///
263    /// ```
264    /// use midtown::{Message, MessageType};
265    ///
266    /// let msg = Message::insight("architect", "```mermaid\ngraph TD\n...");
267    /// assert_eq!(msg.message_type, MessageType::Insight);
268    /// ```
269    pub fn insight(from: impl Into<String>, content: impl Into<String>) -> Self {
270        Self::new(from, content, MessageType::Insight)
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn test_message_creation() {
280        let msg = Message::text("agent1", "Hello, world!");
281        assert_eq!(msg.from, "agent1");
282        assert_eq!(msg.content, "Hello, world!");
283        assert_eq!(msg.message_type, MessageType::Text);
284        assert!(!msg.id.is_empty());
285    }
286
287    #[test]
288    fn test_message_serialization() {
289        let msg = Message::text("agent1", "Hello");
290        let json = serde_json::to_string(&msg).unwrap();
291        let parsed: Message = serde_json::from_str(&json).unwrap();
292        assert_eq!(parsed.from, msg.from);
293        assert_eq!(parsed.content, msg.content);
294    }
295
296    #[test]
297    fn test_system_message() {
298        let msg = Message::system("System initialized");
299        assert_eq!(msg.from, "system");
300        assert_eq!(msg.message_type, MessageType::System);
301    }
302
303    #[test]
304    fn test_action_message() {
305        let msg = Message::action("lexington", "investigating the auth bug");
306        assert_eq!(msg.from, "lexington");
307        assert_eq!(msg.content, "investigating the auth bug");
308        assert_eq!(msg.message_type, MessageType::Action);
309    }
310
311    #[test]
312    fn test_insight_message() {
313        let msg = Message::insight("architect", "```mermaid\ngraph TD\nA-->B");
314        assert_eq!(msg.from, "architect");
315        assert_eq!(msg.message_type, MessageType::Insight);
316        assert!(msg.content.contains("mermaid"));
317    }
318
319    #[test]
320    fn test_channel_defaults_to_midtown() {
321        let msg = Message::text("agent1", "Hello");
322        assert_eq!(msg.channel_name(), "midtown");
323        assert_eq!(msg.source_channel, None);
324    }
325
326    #[test]
327    fn test_for_channel() {
328        let msg =
329            Message::for_channel("pr-discussion", "agent1", "Let's review", MessageType::Text);
330        assert_eq!(msg.channel_name(), "pr-discussion");
331        assert_eq!(msg.from, "agent1");
332    }
333
334    #[test]
335    fn test_backward_compatibility_deserialize() {
336        // Simulate an old message JSON without channel field
337        let old_json = r#"{
338            "id": "test-id",
339            "timestamp": "2026-01-01T00:00:00Z",
340            "from": "agent1",
341            "content": "Hello",
342            "type": "text"
343        }"#;
344
345        let msg: Message = serde_json::from_str(old_json).unwrap();
346        assert_eq!(msg.channel_name(), "midtown"); // Should default
347        assert_eq!(msg.from, "agent1");
348        assert_eq!(msg.content, "Hello");
349    }
350
351    #[test]
352    fn test_backward_compatibility_struct_literal() {
353        // Old code that uses struct literals without channel field should still compile
354        let msg = Message {
355            id: "test".to_string(),
356            timestamp: Utc::now(),
357            from: "agent1".to_string(),
358            content: "Test".to_string(),
359            message_type: MessageType::Text,
360            channel: None, // Explicitly set to None for old code
361            source_channel: None,
362            session_id: None,
363        };
364        assert_eq!(msg.channel_name(), "midtown"); // channel_name() handles None
365    }
366
367    #[test]
368    fn test_new_format_serialize_deserialize() {
369        let msg = Message::for_channel("pr-discussion", "agent1", "Test", MessageType::Text);
370        let json = serde_json::to_string(&msg).unwrap();
371        let parsed: Message = serde_json::from_str(&json).unwrap();
372        assert_eq!(parsed.channel_name(), "pr-discussion");
373        assert_eq!(parsed.from, "agent1");
374    }
375}