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("verified_by field must not be empty")]
35 EmptyVerifiedBy,
36
37 #[error("errors list must not be empty")]
39 EmptyErrors,
40
41 #[error("question field must not be empty")]
43 EmptyQuestionField,
44
45 #[error("invalid message JSON: {0}")]
47 Deserialize(#[from] serde_json::Error),
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct StatusPayload {
53 pub status: String,
55 pub modified_files: Vec<String>,
57 pub message: Option<String>,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct ArtifactPayload {
64 pub status: String,
66 pub exports: Vec<String>,
68 pub modified_files: Vec<String>,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct BlockedPayload {
75 pub needs: String,
77 pub from: String,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct VerifiedPayload {
84 pub verified_by: String,
86 pub message: Option<String>,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct QuestionPayload {
97 pub question: String,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct FeedbackPayload {
104 pub from: String,
106 pub errors: Vec<String>,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(tag = "type")]
116pub enum BrokerMessage {
117 #[serde(rename = "agent.status")]
119 Status {
120 agent_id: String,
122 payload: StatusPayload,
124 },
125 #[serde(rename = "agent.artifact")]
127 Artifact {
128 agent_id: String,
130 payload: ArtifactPayload,
132 },
133 #[serde(rename = "agent.blocked")]
135 Blocked {
136 agent_id: String,
138 payload: BlockedPayload,
140 },
141 #[serde(rename = "agent.verified")]
143 Verified {
144 agent_id: String,
146 payload: VerifiedPayload,
148 },
149 #[serde(rename = "agent.feedback")]
151 Feedback {
152 agent_id: String,
154 payload: FeedbackPayload,
156 },
157 #[serde(rename = "agent.question")]
159 Question {
160 agent_id: String,
162 payload: QuestionPayload,
164 },
165}
166
167impl BrokerMessage {
168 pub fn from_json(input: &str) -> Result<Self, MessageError> {
173 let msg: Self = serde_json::from_str(input)?;
174 msg.validate()?;
175 Ok(msg)
176 }
177
178 pub fn agent_id(&self) -> &str {
180 match self {
181 Self::Status { agent_id, .. }
182 | Self::Artifact { agent_id, .. }
183 | Self::Blocked { agent_id, .. }
184 | Self::Verified { agent_id, .. }
185 | Self::Feedback { agent_id, .. }
186 | Self::Question { agent_id, .. } => agent_id,
187 }
188 }
189
190 pub fn status_label(&self) -> &str {
198 match self {
199 Self::Status { payload, .. } => &payload.status,
200 Self::Artifact { payload, .. } => &payload.status,
201 Self::Blocked { .. } => "blocked",
202 Self::Verified { .. } => "verified",
203 Self::Feedback { .. } => "feedback",
204 Self::Question { .. } => "question",
205 }
206 }
207
208 fn validate(&self) -> Result<(), MessageError> {
210 let id = self.agent_id();
211 if id.trim().is_empty() {
212 return Err(MessageError::EmptyAgentId);
213 }
214 if !id
215 .chars()
216 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
217 {
218 return Err(MessageError::InvalidAgentIdChars);
219 }
220 match self {
221 Self::Status { payload, .. } => {
222 if payload.status.trim().is_empty() {
223 return Err(MessageError::EmptyStatusField);
224 }
225 }
226 Self::Artifact { payload, .. } => {
227 if payload.status.trim().is_empty() {
228 return Err(MessageError::EmptyStatusField);
229 }
230 }
231 Self::Blocked { payload, .. } => {
232 if payload.needs.trim().is_empty() {
233 return Err(MessageError::EmptyNeedsField);
234 }
235 if payload.from.trim().is_empty() {
236 return Err(MessageError::EmptyFromField);
237 }
238 }
239 Self::Verified { payload, .. } => {
240 if payload.verified_by.trim().is_empty() {
241 return Err(MessageError::EmptyVerifiedBy);
242 }
243 }
244 Self::Feedback { payload, .. } => {
245 if payload.from.trim().is_empty() {
246 return Err(MessageError::EmptyFromField);
247 }
248 if payload.errors.is_empty() {
249 return Err(MessageError::EmptyErrors);
250 }
251 }
252 Self::Question { payload, .. } => {
253 if payload.question.trim().is_empty() {
254 return Err(MessageError::EmptyQuestionField);
255 }
256 }
257 }
258 Ok(())
259 }
260}
261
262impl fmt::Display for BrokerMessage {
263 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
264 match self {
265 Self::Status { agent_id, payload } => {
266 write!(
267 f,
268 "[{agent_id}] status: {} ({} files modified)",
269 payload.status,
270 payload.modified_files.len()
271 )
272 }
273 Self::Artifact {
274 agent_id, payload, ..
275 } => {
276 if payload.exports.is_empty() {
277 write!(f, "[{agent_id}] artifact: {}", payload.status)
278 } else {
279 write!(
280 f,
281 "[{agent_id}] artifact: {} \u{2014} exports: {}",
282 payload.status,
283 payload.exports.join(", ")
284 )
285 }
286 }
287 Self::Blocked {
288 agent_id, payload, ..
289 } => {
290 write!(
291 f,
292 "[{agent_id}] blocked: needs {} from {}",
293 payload.needs, payload.from
294 )
295 }
296 Self::Verified {
297 agent_id, payload, ..
298 } => {
299 if let Some(message) = &payload.message {
300 write!(
301 f,
302 "[{agent_id}] verified by {} \u{2014} {message}",
303 payload.verified_by
304 )
305 } else {
306 write!(f, "[{agent_id}] verified by {}", payload.verified_by)
307 }
308 }
309 Self::Feedback {
310 agent_id, payload, ..
311 } => {
312 write!(
313 f,
314 "[{agent_id}] feedback from {}: {} errors",
315 payload.from,
316 payload.errors.len()
317 )
318 }
319 Self::Question {
320 agent_id, payload, ..
321 } => {
322 write!(f, "[{agent_id}] question: {}", payload.question)
323 }
324 }
325 }
326}
327
328pub fn slugify_branch(name: &str) -> String {
346 let lowered = name.to_ascii_lowercase();
348
349 let replaced: String = lowered
351 .chars()
352 .map(|c| {
353 if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' {
354 c
355 } else {
356 '-'
357 }
358 })
359 .collect();
360
361 let mut collapsed = String::with_capacity(replaced.len());
363 let mut prev_dash = false;
364 for c in replaced.chars() {
365 if c == '-' {
366 if !prev_dash {
367 collapsed.push('-');
368 }
369 prev_dash = true;
370 } else {
371 collapsed.push(c);
372 prev_dash = false;
373 }
374 }
375
376 let trimmed = collapsed.trim_matches('-');
378
379 if trimmed.is_empty() {
381 "agent".to_string()
382 } else {
383 trimmed.to_string()
384 }
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 fn make_status(agent_id: &str, status: &str) -> BrokerMessage {
392 BrokerMessage::Status {
393 agent_id: agent_id.to_string(),
394 payload: StatusPayload {
395 status: status.to_string(),
396 modified_files: vec![],
397 message: None,
398 },
399 }
400 }
401
402 fn make_artifact(agent_id: &str, status: &str, exports: &[&str]) -> BrokerMessage {
403 BrokerMessage::Artifact {
404 agent_id: agent_id.to_string(),
405 payload: ArtifactPayload {
406 status: status.to_string(),
407 exports: exports.iter().map(|s| (*s).to_string()).collect(),
408 modified_files: vec!["src/main.rs".to_string()],
409 },
410 }
411 }
412
413 fn make_blocked(agent_id: &str, needs: &str, from: &str) -> BrokerMessage {
414 BrokerMessage::Blocked {
415 agent_id: agent_id.to_string(),
416 payload: BlockedPayload {
417 needs: needs.to_string(),
418 from: from.to_string(),
419 },
420 }
421 }
422
423 #[test]
424 fn slugify_branch_replaces_slashes() {
425 assert_eq!(slugify_branch("feat/errors"), "feat-errors");
426 assert_eq!(slugify_branch("main"), "main");
427 assert_eq!(slugify_branch("a/b/c"), "a-b-c");
428 }
429
430 #[test]
431 fn slugify_branch_lowercases() {
432 assert_eq!(slugify_branch("FEAT/X"), "feat-x");
433 }
434
435 #[test]
436 fn slugify_branch_empty_returns_agent() {
437 assert_eq!(slugify_branch(""), "agent");
438 }
439
440 #[test]
441 fn slugify_branch_only_dashes_returns_agent() {
442 assert_eq!(slugify_branch("---"), "agent");
443 }
444
445 #[test]
446 fn slugify_branch_collapses_consecutive_dashes() {
447 assert_eq!(slugify_branch("feat//x"), "feat-x");
448 }
449
450 #[test]
451 fn slugify_branch_trims_leading_trailing_dashes() {
452 assert_eq!(slugify_branch("/feat/x/"), "feat-x");
453 }
454
455 #[test]
456 fn agent_id_status() {
457 let msg = make_status("feat-x", "working");
458 assert_eq!(msg.agent_id(), "feat-x");
459 }
460
461 #[test]
462 fn agent_id_artifact() {
463 let msg = make_artifact("feat-y", "done", &["auth"]);
464 assert_eq!(msg.agent_id(), "feat-y");
465 }
466
467 #[test]
468 fn agent_id_blocked() {
469 let msg = make_blocked("feat-config", "error types", "feat-errors");
470 assert_eq!(msg.agent_id(), "feat-config");
471 }
472
473 #[test]
474 fn status_label_status_variant() {
475 let msg = make_status("feat-x", "working");
476 assert_eq!(msg.status_label(), "working");
477 }
478
479 #[test]
480 fn status_label_artifact_variant() {
481 let msg = make_artifact("feat-x", "done", &[]);
482 assert_eq!(msg.status_label(), "done");
483 }
484
485 #[test]
486 fn status_label_blocked_variant() {
487 let msg = make_blocked("feat-config", "error types", "feat-errors");
488 assert_eq!(msg.status_label(), "blocked");
489 }
490
491 #[test]
492 fn display_status() {
493 let msg = make_status("feat-x", "working");
494 assert_eq!(
495 msg.to_string(),
496 "[feat-x] status: working (0 files modified)"
497 );
498 }
499
500 #[test]
501 fn display_status_with_files() {
502 let msg = BrokerMessage::Status {
503 agent_id: "feat-x".to_string(),
504 payload: StatusPayload {
505 status: "working".to_string(),
506 modified_files: vec!["a.rs".to_string(), "b.rs".to_string()],
507 message: None,
508 },
509 };
510 assert_eq!(
511 msg.to_string(),
512 "[feat-x] status: working (2 files modified)"
513 );
514 }
515
516 #[test]
517 fn display_artifact_no_exports() {
518 let msg = make_artifact("feat-x", "done", &[]);
519 assert_eq!(msg.to_string(), "[feat-x] artifact: done");
520 }
521
522 #[test]
523 fn display_artifact_with_exports() {
524 let msg = make_artifact("feat-x", "done", &["PawError", "Config"]);
525 assert_eq!(
526 msg.to_string(),
527 "[feat-x] artifact: done \u{2014} exports: PawError, Config"
528 );
529 }
530
531 #[test]
532 fn display_blocked() {
533 let msg = make_blocked("feat-config", "error types", "feat-errors");
534 assert_eq!(
535 msg.to_string(),
536 "[feat-config] blocked: needs error types from feat-errors"
537 );
538 }
539
540 #[test]
541 fn from_json_valid_status() {
542 let json = r#"{"type":"agent.status","agent_id":"feat-x","payload":{"status":"working","modified_files":[],"message":null}}"#;
543 let msg = BrokerMessage::from_json(json).unwrap();
544 assert_eq!(msg.agent_id(), "feat-x");
545 assert_eq!(msg.status_label(), "working");
546 }
547
548 #[test]
549 fn from_json_empty_agent_id_rejected() {
550 let json = r#"{"type":"agent.status","agent_id":"","payload":{"status":"working","modified_files":[]}}"#;
551 let err = BrokerMessage::from_json(json).unwrap_err();
552 assert!(matches!(err, MessageError::EmptyAgentId));
553 }
554
555 #[test]
556 fn from_json_invalid_agent_id_chars_rejected() {
557 let json = r#"{"type":"agent.status","agent_id":"feat/x","payload":{"status":"working","modified_files":[]}}"#;
558 let err = BrokerMessage::from_json(json).unwrap_err();
559 assert!(matches!(err, MessageError::InvalidAgentIdChars));
560 }
561
562 #[test]
563 fn from_json_empty_status_rejected() {
564 let json = r#"{"type":"agent.status","agent_id":"feat-x","payload":{"status":"","modified_files":[]}}"#;
565 let err = BrokerMessage::from_json(json).unwrap_err();
566 assert!(matches!(err, MessageError::EmptyStatusField));
567 }
568
569 #[test]
570 fn from_json_empty_artifact_status_rejected() {
571 let json = r#"{"type":"agent.artifact","agent_id":"feat-x","payload":{"status":"","exports":[],"modified_files":[]}}"#;
572 let err = BrokerMessage::from_json(json).unwrap_err();
573 assert!(matches!(err, MessageError::EmptyStatusField));
574 }
575
576 #[test]
577 fn from_json_empty_needs_rejected() {
578 let json = r#"{"type":"agent.blocked","agent_id":"feat-x","payload":{"needs":"","from":"feat-y"}}"#;
579 let err = BrokerMessage::from_json(json).unwrap_err();
580 assert!(matches!(err, MessageError::EmptyNeedsField));
581 }
582
583 #[test]
584 fn from_json_empty_from_rejected() {
585 let json =
586 r#"{"type":"agent.blocked","agent_id":"feat-x","payload":{"needs":"types","from":""}}"#;
587 let err = BrokerMessage::from_json(json).unwrap_err();
588 assert!(matches!(err, MessageError::EmptyFromField));
589 }
590
591 #[test]
592 fn from_json_invalid_json_rejected() {
593 let err = BrokerMessage::from_json("not json").unwrap_err();
594 assert!(matches!(err, MessageError::Deserialize(_)));
595 }
596
597 #[test]
598 fn serde_roundtrip_status() {
599 let msg = make_status("feat-x", "working");
600 let json = serde_json::to_string(&msg).unwrap();
601 let back: BrokerMessage = serde_json::from_str(&json).unwrap();
602 assert_eq!(back.agent_id(), "feat-x");
603 assert_eq!(back.status_label(), "working");
604 }
605
606 #[test]
607 fn serde_roundtrip_artifact() {
608 let msg = make_artifact("feat-x", "done", &["PawError"]);
609 let json = serde_json::to_string(&msg).unwrap();
610 let back: BrokerMessage = serde_json::from_str(&json).unwrap();
611 assert_eq!(back.agent_id(), "feat-x");
612 assert_eq!(back.status_label(), "done");
613 }
614
615 #[test]
616 fn serde_roundtrip_blocked() {
617 let msg = make_blocked("a", "types", "b");
618 let json = serde_json::to_string(&msg).unwrap();
619 let back: BrokerMessage = serde_json::from_str(&json).unwrap();
620 assert_eq!(back.agent_id(), "a");
621 assert_eq!(back.status_label(), "blocked");
622 }
623
624 #[test]
625 fn from_json_whitespace_agent_id_rejected() {
626 let json = r#"{"type":"agent.status","agent_id":" ","payload":{"status":"working","modified_files":[],"message":null}}"#;
627 assert!(BrokerMessage::from_json(json).is_err());
628 }
629
630 #[test]
631 fn slugify_branch_preserves_underscores() {
632 assert_eq!(slugify_branch("feat/my_feature"), "feat-my_feature");
633 }
634
635 #[test]
636 fn slugify_branch_replaces_non_ascii() {
637 let result = slugify_branch("feat/日本語");
638 assert!(result.is_ascii());
639 assert_eq!(result, "feat");
640 }
641
642 fn make_verified(agent_id: &str, verified_by: &str, message: Option<&str>) -> BrokerMessage {
643 BrokerMessage::Verified {
644 agent_id: agent_id.to_string(),
645 payload: VerifiedPayload {
646 verified_by: verified_by.to_string(),
647 message: message.map(str::to_string),
648 },
649 }
650 }
651
652 fn make_feedback(agent_id: &str, from: &str, errors: &[&str]) -> BrokerMessage {
653 BrokerMessage::Feedback {
654 agent_id: agent_id.to_string(),
655 payload: FeedbackPayload {
656 from: from.to_string(),
657 errors: errors.iter().map(|s| (*s).to_string()).collect(),
658 },
659 }
660 }
661
662 #[test]
663 fn serde_roundtrip_verified_with_message() {
664 let msg = make_verified("feat-errors", "supervisor", Some("all 12 tests pass"));
665 let json = serde_json::to_string(&msg).unwrap();
666 assert!(json.contains("\"type\":\"agent.verified\""));
667 assert!(json.contains("all 12 tests pass"));
668 let back: BrokerMessage = serde_json::from_str(&json).unwrap();
669 assert_eq!(back, msg);
670 }
671
672 #[test]
673 fn serde_roundtrip_verified_without_message() {
674 let msg = make_verified("feat-errors", "supervisor", None);
675 let json = serde_json::to_string(&msg).unwrap();
676 let back: BrokerMessage = serde_json::from_str(&json).unwrap();
677 assert_eq!(back, msg);
678 }
679
680 #[test]
681 fn serde_roundtrip_feedback() {
682 let msg = make_feedback(
683 "feat-errors",
684 "supervisor",
685 &["test failed", "missing doc comment"],
686 );
687 let json = serde_json::to_string(&msg).unwrap();
688 assert!(json.contains("\"type\":\"agent.feedback\""));
689 let back: BrokerMessage = serde_json::from_str(&json).unwrap();
690 assert_eq!(back, msg);
691 }
692
693 #[test]
694 fn from_json_empty_verified_by_rejected() {
695 let json = r#"{"type":"agent.verified","agent_id":"feat-errors","payload":{"verified_by":"","message":null}}"#;
696 let err = BrokerMessage::from_json(json).unwrap_err();
697 assert!(matches!(err, MessageError::EmptyVerifiedBy));
698 }
699
700 #[test]
701 fn from_json_empty_feedback_from_rejected() {
702 let json = r#"{"type":"agent.feedback","agent_id":"feat-errors","payload":{"from":"","errors":["e1"]}}"#;
703 let err = BrokerMessage::from_json(json).unwrap_err();
704 assert!(matches!(err, MessageError::EmptyFromField));
705 }
706
707 #[test]
708 fn from_json_empty_feedback_errors_rejected() {
709 let json = r#"{"type":"agent.feedback","agent_id":"feat-errors","payload":{"from":"supervisor","errors":[]}}"#;
710 let err = BrokerMessage::from_json(json).unwrap_err();
711 assert!(matches!(err, MessageError::EmptyErrors));
712 }
713
714 #[test]
715 fn display_verified_without_message() {
716 let msg = make_verified("feat-errors", "supervisor", None);
717 assert_eq!(msg.to_string(), "[feat-errors] verified by supervisor");
718 }
719
720 #[test]
721 fn display_verified_with_message() {
722 let msg = make_verified("feat-errors", "supervisor", Some("all tests pass"));
723 assert_eq!(
724 msg.to_string(),
725 "[feat-errors] verified by supervisor \u{2014} all tests pass"
726 );
727 }
728
729 #[test]
730 fn display_feedback_with_three_errors() {
731 let msg = make_feedback("feat-errors", "supervisor", &["e1", "e2", "e3"]);
732 assert_eq!(
733 msg.to_string(),
734 "[feat-errors] feedback from supervisor: 3 errors"
735 );
736 }
737
738 #[test]
739 fn status_label_verified() {
740 let msg = make_verified("feat-x", "supervisor", None);
741 assert_eq!(msg.status_label(), "verified");
742 }
743
744 #[test]
745 fn status_label_feedback() {
746 let msg = make_feedback("feat-x", "supervisor", &["e"]);
747 assert_eq!(msg.status_label(), "feedback");
748 }
749
750 #[test]
751 fn agent_id_verified() {
752 let msg = make_verified("feat-x", "supervisor", None);
753 assert_eq!(msg.agent_id(), "feat-x");
754 }
755
756 #[test]
757 fn agent_id_feedback() {
758 let msg = make_feedback("feat-x", "supervisor", &["e"]);
759 assert_eq!(msg.agent_id(), "feat-x");
760 }
761
762 fn make_question(agent_id: &str, question: &str) -> BrokerMessage {
763 BrokerMessage::Question {
764 agent_id: agent_id.to_string(),
765 payload: QuestionPayload {
766 question: question.to_string(),
767 },
768 }
769 }
770
771 #[test]
772 fn question_empty_field_rejected() {
773 let json =
774 r#"{"type":"agent.question","agent_id":"feat-config","payload":{"question":""}}"#;
775 let err = BrokerMessage::from_json(json).unwrap_err();
776 assert!(matches!(err, MessageError::EmptyQuestionField));
777 }
778
779 #[test]
780 fn serde_roundtrip_question() {
781 let msg = make_question("feat-config", "Should I skip tests?");
782 let json = serde_json::to_string(&msg).unwrap();
783 assert!(json.contains("\"type\":\"agent.question\""));
784 assert!(json.contains("\"agent_id\":\"feat-config\""));
785 let back: BrokerMessage = serde_json::from_str(&json).unwrap();
786 assert_eq!(back, msg);
787 }
788
789 #[test]
790 fn display_question() {
791 let msg = make_question("feat-config", "Should I add a config field?");
792 let s = msg.to_string();
793 assert_eq!(s, "[feat-config] question: Should I add a config field?");
794 assert!(!s.contains('\n'));
795 }
796
797 #[test]
798 fn status_label_question() {
799 let msg = make_question("feat-config", "anything?");
800 assert_eq!(msg.status_label(), "question");
801 }
802
803 #[test]
804 fn agent_id_question() {
805 let msg = make_question("feat-config", "anything?");
806 assert_eq!(msg.agent_id(), "feat-config");
807 }
808
809 #[test]
810 fn from_json_unknown_type_rejected() {
811 let json = r#"{"type":"agent.unknown","agent_id":"x","payload":{}}"#;
812 assert!(BrokerMessage::from_json(json).is_err());
813 }
814
815 #[test]
816 fn slugify_branch_deterministic() {
817 let a = slugify_branch("feat/http-broker");
818 let b = slugify_branch("feat/http-broker");
819 assert_eq!(a, b);
820 }
821}