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}