1use std::fmt;
7
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, thiserror::Error)]
12pub enum MessageError {
13 #[error("agent_id must not be empty")]
15 EmptyAgentId,
16
17 #[error("agent_id contains invalid characters — only [a-z0-9-_] allowed")]
19 InvalidAgentIdChars,
20
21 #[error("status field must not be empty")]
23 EmptyStatusField,
24
25 #[error("needs field must not be empty")]
27 EmptyNeedsField,
28
29 #[error("from field must not be empty")]
31 EmptyFromField,
32
33 #[error("invalid message JSON: {0}")]
35 Deserialize(#[from] serde_json::Error),
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct StatusPayload {
41 pub status: String,
43 pub modified_files: Vec<String>,
45 pub message: Option<String>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct ArtifactPayload {
52 pub status: String,
54 pub exports: Vec<String>,
56 pub modified_files: Vec<String>,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct BlockedPayload {
63 pub needs: String,
65 pub from: String,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(tag = "type")]
75pub enum BrokerMessage {
76 #[serde(rename = "agent.status")]
78 Status {
79 agent_id: String,
81 payload: StatusPayload,
83 },
84 #[serde(rename = "agent.artifact")]
86 Artifact {
87 agent_id: String,
89 payload: ArtifactPayload,
91 },
92 #[serde(rename = "agent.blocked")]
94 Blocked {
95 agent_id: String,
97 payload: BlockedPayload,
99 },
100}
101
102impl BrokerMessage {
103 pub fn from_json(input: &str) -> Result<Self, MessageError> {
108 let msg: Self = serde_json::from_str(input)?;
109 msg.validate()?;
110 Ok(msg)
111 }
112
113 pub fn agent_id(&self) -> &str {
115 match self {
116 Self::Status { agent_id, .. }
117 | Self::Artifact { agent_id, .. }
118 | Self::Blocked { agent_id, .. } => agent_id,
119 }
120 }
121
122 pub fn status_label(&self) -> &str {
128 match self {
129 Self::Status { payload, .. } => &payload.status,
130 Self::Artifact { payload, .. } => &payload.status,
131 Self::Blocked { .. } => "blocked",
132 }
133 }
134
135 fn validate(&self) -> Result<(), MessageError> {
137 let id = self.agent_id();
138 if id.trim().is_empty() {
139 return Err(MessageError::EmptyAgentId);
140 }
141 if !id
142 .chars()
143 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
144 {
145 return Err(MessageError::InvalidAgentIdChars);
146 }
147 match self {
148 Self::Status { payload, .. } => {
149 if payload.status.trim().is_empty() {
150 return Err(MessageError::EmptyStatusField);
151 }
152 }
153 Self::Artifact { payload, .. } => {
154 if payload.status.trim().is_empty() {
155 return Err(MessageError::EmptyStatusField);
156 }
157 }
158 Self::Blocked { payload, .. } => {
159 if payload.needs.trim().is_empty() {
160 return Err(MessageError::EmptyNeedsField);
161 }
162 if payload.from.trim().is_empty() {
163 return Err(MessageError::EmptyFromField);
164 }
165 }
166 }
167 Ok(())
168 }
169}
170
171impl fmt::Display for BrokerMessage {
172 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173 match self {
174 Self::Status { agent_id, payload } => {
175 write!(
176 f,
177 "[{agent_id}] status: {} ({} files modified)",
178 payload.status,
179 payload.modified_files.len()
180 )
181 }
182 Self::Artifact {
183 agent_id, payload, ..
184 } => {
185 if payload.exports.is_empty() {
186 write!(f, "[{agent_id}] artifact: {}", payload.status)
187 } else {
188 write!(
189 f,
190 "[{agent_id}] artifact: {} \u{2014} exports: {}",
191 payload.status,
192 payload.exports.join(", ")
193 )
194 }
195 }
196 Self::Blocked {
197 agent_id, payload, ..
198 } => {
199 write!(
200 f,
201 "[{agent_id}] blocked: needs {} from {}",
202 payload.needs, payload.from
203 )
204 }
205 }
206 }
207}
208
209pub fn slugify_branch(name: &str) -> String {
227 let lowered = name.to_ascii_lowercase();
229
230 let replaced: String = lowered
232 .chars()
233 .map(|c| {
234 if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' {
235 c
236 } else {
237 '-'
238 }
239 })
240 .collect();
241
242 let mut collapsed = String::with_capacity(replaced.len());
244 let mut prev_dash = false;
245 for c in replaced.chars() {
246 if c == '-' {
247 if !prev_dash {
248 collapsed.push('-');
249 }
250 prev_dash = true;
251 } else {
252 collapsed.push(c);
253 prev_dash = false;
254 }
255 }
256
257 let trimmed = collapsed.trim_matches('-');
259
260 if trimmed.is_empty() {
262 "agent".to_string()
263 } else {
264 trimmed.to_string()
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 fn make_status(agent_id: &str, status: &str) -> BrokerMessage {
273 BrokerMessage::Status {
274 agent_id: agent_id.to_string(),
275 payload: StatusPayload {
276 status: status.to_string(),
277 modified_files: vec![],
278 message: None,
279 },
280 }
281 }
282
283 fn make_artifact(agent_id: &str, status: &str, exports: &[&str]) -> BrokerMessage {
284 BrokerMessage::Artifact {
285 agent_id: agent_id.to_string(),
286 payload: ArtifactPayload {
287 status: status.to_string(),
288 exports: exports.iter().map(|s| (*s).to_string()).collect(),
289 modified_files: vec!["src/main.rs".to_string()],
290 },
291 }
292 }
293
294 fn make_blocked(agent_id: &str, needs: &str, from: &str) -> BrokerMessage {
295 BrokerMessage::Blocked {
296 agent_id: agent_id.to_string(),
297 payload: BlockedPayload {
298 needs: needs.to_string(),
299 from: from.to_string(),
300 },
301 }
302 }
303
304 #[test]
305 fn slugify_branch_replaces_slashes() {
306 assert_eq!(slugify_branch("feat/errors"), "feat-errors");
307 assert_eq!(slugify_branch("main"), "main");
308 assert_eq!(slugify_branch("a/b/c"), "a-b-c");
309 }
310
311 #[test]
312 fn slugify_branch_lowercases() {
313 assert_eq!(slugify_branch("FEAT/X"), "feat-x");
314 }
315
316 #[test]
317 fn slugify_branch_empty_returns_agent() {
318 assert_eq!(slugify_branch(""), "agent");
319 }
320
321 #[test]
322 fn slugify_branch_only_dashes_returns_agent() {
323 assert_eq!(slugify_branch("---"), "agent");
324 }
325
326 #[test]
327 fn slugify_branch_collapses_consecutive_dashes() {
328 assert_eq!(slugify_branch("feat//x"), "feat-x");
329 }
330
331 #[test]
332 fn slugify_branch_trims_leading_trailing_dashes() {
333 assert_eq!(slugify_branch("/feat/x/"), "feat-x");
334 }
335
336 #[test]
337 fn agent_id_status() {
338 let msg = make_status("feat-x", "working");
339 assert_eq!(msg.agent_id(), "feat-x");
340 }
341
342 #[test]
343 fn agent_id_artifact() {
344 let msg = make_artifact("feat-y", "done", &["auth"]);
345 assert_eq!(msg.agent_id(), "feat-y");
346 }
347
348 #[test]
349 fn agent_id_blocked() {
350 let msg = make_blocked("feat-config", "error types", "feat-errors");
351 assert_eq!(msg.agent_id(), "feat-config");
352 }
353
354 #[test]
355 fn status_label_status_variant() {
356 let msg = make_status("feat-x", "working");
357 assert_eq!(msg.status_label(), "working");
358 }
359
360 #[test]
361 fn status_label_artifact_variant() {
362 let msg = make_artifact("feat-x", "done", &[]);
363 assert_eq!(msg.status_label(), "done");
364 }
365
366 #[test]
367 fn status_label_blocked_variant() {
368 let msg = make_blocked("feat-config", "error types", "feat-errors");
369 assert_eq!(msg.status_label(), "blocked");
370 }
371
372 #[test]
373 fn display_status() {
374 let msg = make_status("feat-x", "working");
375 assert_eq!(
376 msg.to_string(),
377 "[feat-x] status: working (0 files modified)"
378 );
379 }
380
381 #[test]
382 fn display_status_with_files() {
383 let msg = BrokerMessage::Status {
384 agent_id: "feat-x".to_string(),
385 payload: StatusPayload {
386 status: "working".to_string(),
387 modified_files: vec!["a.rs".to_string(), "b.rs".to_string()],
388 message: None,
389 },
390 };
391 assert_eq!(
392 msg.to_string(),
393 "[feat-x] status: working (2 files modified)"
394 );
395 }
396
397 #[test]
398 fn display_artifact_no_exports() {
399 let msg = make_artifact("feat-x", "done", &[]);
400 assert_eq!(msg.to_string(), "[feat-x] artifact: done");
401 }
402
403 #[test]
404 fn display_artifact_with_exports() {
405 let msg = make_artifact("feat-x", "done", &["PawError", "Config"]);
406 assert_eq!(
407 msg.to_string(),
408 "[feat-x] artifact: done \u{2014} exports: PawError, Config"
409 );
410 }
411
412 #[test]
413 fn display_blocked() {
414 let msg = make_blocked("feat-config", "error types", "feat-errors");
415 assert_eq!(
416 msg.to_string(),
417 "[feat-config] blocked: needs error types from feat-errors"
418 );
419 }
420
421 #[test]
422 fn from_json_valid_status() {
423 let json = r#"{"type":"agent.status","agent_id":"feat-x","payload":{"status":"working","modified_files":[],"message":null}}"#;
424 let msg = BrokerMessage::from_json(json).unwrap();
425 assert_eq!(msg.agent_id(), "feat-x");
426 assert_eq!(msg.status_label(), "working");
427 }
428
429 #[test]
430 fn from_json_empty_agent_id_rejected() {
431 let json = r#"{"type":"agent.status","agent_id":"","payload":{"status":"working","modified_files":[]}}"#;
432 let err = BrokerMessage::from_json(json).unwrap_err();
433 assert!(matches!(err, MessageError::EmptyAgentId));
434 }
435
436 #[test]
437 fn from_json_invalid_agent_id_chars_rejected() {
438 let json = r#"{"type":"agent.status","agent_id":"feat/x","payload":{"status":"working","modified_files":[]}}"#;
439 let err = BrokerMessage::from_json(json).unwrap_err();
440 assert!(matches!(err, MessageError::InvalidAgentIdChars));
441 }
442
443 #[test]
444 fn from_json_empty_status_rejected() {
445 let json = r#"{"type":"agent.status","agent_id":"feat-x","payload":{"status":"","modified_files":[]}}"#;
446 let err = BrokerMessage::from_json(json).unwrap_err();
447 assert!(matches!(err, MessageError::EmptyStatusField));
448 }
449
450 #[test]
451 fn from_json_empty_artifact_status_rejected() {
452 let json = r#"{"type":"agent.artifact","agent_id":"feat-x","payload":{"status":"","exports":[],"modified_files":[]}}"#;
453 let err = BrokerMessage::from_json(json).unwrap_err();
454 assert!(matches!(err, MessageError::EmptyStatusField));
455 }
456
457 #[test]
458 fn from_json_empty_needs_rejected() {
459 let json = r#"{"type":"agent.blocked","agent_id":"feat-x","payload":{"needs":"","from":"feat-y"}}"#;
460 let err = BrokerMessage::from_json(json).unwrap_err();
461 assert!(matches!(err, MessageError::EmptyNeedsField));
462 }
463
464 #[test]
465 fn from_json_empty_from_rejected() {
466 let json =
467 r#"{"type":"agent.blocked","agent_id":"feat-x","payload":{"needs":"types","from":""}}"#;
468 let err = BrokerMessage::from_json(json).unwrap_err();
469 assert!(matches!(err, MessageError::EmptyFromField));
470 }
471
472 #[test]
473 fn from_json_invalid_json_rejected() {
474 let err = BrokerMessage::from_json("not json").unwrap_err();
475 assert!(matches!(err, MessageError::Deserialize(_)));
476 }
477
478 #[test]
479 fn serde_roundtrip_status() {
480 let msg = make_status("feat-x", "working");
481 let json = serde_json::to_string(&msg).unwrap();
482 let back: BrokerMessage = serde_json::from_str(&json).unwrap();
483 assert_eq!(back.agent_id(), "feat-x");
484 assert_eq!(back.status_label(), "working");
485 }
486
487 #[test]
488 fn serde_roundtrip_artifact() {
489 let msg = make_artifact("feat-x", "done", &["PawError"]);
490 let json = serde_json::to_string(&msg).unwrap();
491 let back: BrokerMessage = serde_json::from_str(&json).unwrap();
492 assert_eq!(back.agent_id(), "feat-x");
493 assert_eq!(back.status_label(), "done");
494 }
495
496 #[test]
497 fn serde_roundtrip_blocked() {
498 let msg = make_blocked("a", "types", "b");
499 let json = serde_json::to_string(&msg).unwrap();
500 let back: BrokerMessage = serde_json::from_str(&json).unwrap();
501 assert_eq!(back.agent_id(), "a");
502 assert_eq!(back.status_label(), "blocked");
503 }
504
505 #[test]
506 fn from_json_whitespace_agent_id_rejected() {
507 let json = r#"{"type":"agent.status","agent_id":" ","payload":{"status":"working","modified_files":[],"message":null}}"#;
508 assert!(BrokerMessage::from_json(json).is_err());
509 }
510
511 #[test]
512 fn slugify_branch_preserves_underscores() {
513 assert_eq!(slugify_branch("feat/my_feature"), "feat-my_feature");
514 }
515
516 #[test]
517 fn slugify_branch_replaces_non_ascii() {
518 let result = slugify_branch("feat/日本語");
519 assert!(result.is_ascii());
520 assert_eq!(result, "feat");
521 }
522
523 #[test]
524 fn slugify_branch_deterministic() {
525 let a = slugify_branch("feat/http-broker");
526 let b = slugify_branch("feat/http-broker");
527 assert_eq!(a, b);
528 }
529}