Skip to main content

git_paw/broker/
messages.rs

1//! Broker message types, validation, and branch slug conversion.
2//!
3//! Defines [`BrokerMessage`] -- the envelope type for all inter-agent
4//! communication -- along with its payload structs and helper methods.
5
6use std::fmt;
7
8use serde::{Deserialize, Serialize};
9
10/// Validation errors for broker messages.
11#[derive(Debug, thiserror::Error)]
12pub enum MessageError {
13    /// The `agent_id` field is empty or whitespace-only.
14    #[error("agent_id must not be empty")]
15    EmptyAgentId,
16
17    /// The `agent_id` field contains characters outside `[a-z0-9-_]`.
18    #[error("agent_id contains invalid characters — only [a-z0-9-_] allowed")]
19    InvalidAgentIdChars,
20
21    /// The `status` field is empty or whitespace-only.
22    #[error("status field must not be empty")]
23    EmptyStatusField,
24
25    /// The `needs` field is empty or whitespace-only.
26    #[error("needs field must not be empty")]
27    EmptyNeedsField,
28
29    /// The `from` field is empty or whitespace-only.
30    #[error("from field must not be empty")]
31    EmptyFromField,
32
33    /// The `verified_by` field is empty or whitespace-only.
34    #[error("verified_by field must not be empty")]
35    EmptyVerifiedBy,
36
37    /// The `errors` list is empty.
38    #[error("errors list must not be empty")]
39    EmptyErrors,
40
41    /// The `question` field is empty or whitespace-only.
42    #[error("question field must not be empty")]
43    EmptyQuestionField,
44
45    /// JSON deserialization failed.
46    #[error("invalid message JSON: {0}")]
47    Deserialize(#[from] serde_json::Error),
48}
49
50/// Payload for `agent.status` messages.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct StatusPayload {
53    /// Current status label (e.g. `"working"`, `"idle"`).
54    pub status: String,
55    /// List of files modified by the agent.
56    pub modified_files: Vec<String>,
57    /// Optional human-readable message.
58    pub message: Option<String>,
59}
60
61/// Payload for `agent.artifact` messages.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct ArtifactPayload {
64    /// Current status label (e.g. `"done"`).
65    pub status: String,
66    /// List of exported symbols or public API items.
67    pub exports: Vec<String>,
68    /// List of files modified by the agent.
69    pub modified_files: Vec<String>,
70}
71
72/// Payload for `agent.blocked` messages.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct BlockedPayload {
75    /// What the agent needs to proceed.
76    pub needs: String,
77    /// Agent ID of the agent that can unblock the sender.
78    pub from: String,
79}
80
81/// Payload for `agent.verified` messages.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct VerifiedPayload {
84    /// Agent ID of the verifier (typically `"supervisor"`).
85    pub verified_by: String,
86    /// Optional human-readable summary of the verification result.
87    pub message: Option<String>,
88}
89
90/// Payload for `agent.question` messages.
91///
92/// Wire format: `{"type": "agent.question", "agent_id": "<slug>", "payload": {"question": "<text>"}}`.
93/// The `question` field MUST NOT be empty. Question messages are routed to the
94/// `"supervisor"` inbox by the broker delivery layer.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct QuestionPayload {
97    /// The question text the agent is asking.
98    pub question: String,
99}
100
101/// Payload for `agent.feedback` messages.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct FeedbackPayload {
104    /// Agent ID of the sender (typically `"supervisor"`).
105    pub from: String,
106    /// List of error messages the target agent should address.
107    pub errors: Vec<String>,
108}
109
110/// Envelope for all inter-agent messages.
111///
112/// The wire format uses JSON with an internally tagged `"type"` discriminator
113/// whose values are `"agent.status"`, `"agent.artifact"`, and `"agent.blocked"`.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(tag = "type")]
116pub enum BrokerMessage {
117    /// Status heartbeat -- not routed to inboxes.
118    #[serde(rename = "agent.status")]
119    Status {
120        /// Sender agent ID (slugified branch name).
121        agent_id: String,
122        /// Status payload.
123        payload: StatusPayload,
124    },
125    /// Artifact announcement -- broadcast to all peers.
126    #[serde(rename = "agent.artifact")]
127    Artifact {
128        /// Sender agent ID.
129        agent_id: String,
130        /// Artifact payload.
131        payload: ArtifactPayload,
132    },
133    /// Blocked notification -- sent to the target agent.
134    #[serde(rename = "agent.blocked")]
135    Blocked {
136        /// Sender agent ID.
137        agent_id: String,
138        /// Blocked payload (contains `from` -- the unblocking agent).
139        payload: BlockedPayload,
140    },
141    /// Verification acknowledgement -- broadcast to all peers.
142    #[serde(rename = "agent.verified")]
143    Verified {
144        /// Target agent ID (the agent whose work was verified).
145        agent_id: String,
146        /// Verified payload (contains `verified_by` -- the sender).
147        payload: VerifiedPayload,
148    },
149    /// Feedback from a verifier -- delivered to the target agent only.
150    #[serde(rename = "agent.feedback")]
151    Feedback {
152        /// Target agent ID (the agent receiving feedback).
153        agent_id: String,
154        /// Feedback payload (contains `from` -- the sender).
155        payload: FeedbackPayload,
156    },
157    /// Agent question -- delivered to the `"supervisor"` inbox for human reply.
158    #[serde(rename = "agent.question")]
159    Question {
160        /// Sender agent ID (the agent asking the question).
161        agent_id: String,
162        /// Question payload.
163        payload: QuestionPayload,
164    },
165}
166
167impl BrokerMessage {
168    /// Deserializes and validates a broker message from a JSON string.
169    ///
170    /// Returns [`MessageError`] if the JSON is malformed or the `agent_id` is
171    /// invalid.
172    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    /// Returns the `agent_id` field from whichever variant.
179    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    /// Returns a short status label for the message.
191    ///
192    /// - `Status` returns `payload.status` (e.g. `"working"`)
193    /// - `Artifact` returns `payload.status` (e.g. `"done"`)
194    /// - `Blocked` returns `"blocked"`
195    /// - `Verified` returns `"verified"`
196    /// - `Feedback` returns `"feedback"`
197    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    /// Validates all fields according to the broker message spec.
209    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
328/// Converts a git branch name into a stable broker `agent_id` slug.
329///
330/// Applies a 5-step normalization algorithm:
331///
332/// 1. Convert to ASCII lowercase
333/// 2. Replace any character not in `[a-z0-9_]` with `-`
334/// 3. Collapse consecutive `-` into a single `-`
335/// 4. Trim leading and trailing `-`
336/// 5. If the result is empty, return `"agent"`
337///
338/// # Examples
339///
340/// - `"feat/http-broker"` → `"feat-http-broker"`
341/// - `"a/b/c"` → `"a-b-c"`
342/// - `"FEAT/X"` → `"feat-x"`
343/// - `""` → `"agent"`
344/// - `"---"` → `"agent"`
345pub fn slugify_branch(name: &str) -> String {
346    // Step 1: to ASCII lowercase
347    let lowered = name.to_ascii_lowercase();
348
349    // Step 2: replace non-[a-z0-9_] with -
350    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    // Step 3: collapse consecutive - to single -
362    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    // Step 4: trim leading/trailing -
377    let trimmed = collapsed.trim_matches('-');
378
379    // Step 5: if empty, return "agent"
380    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}