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    /// JSON deserialization failed.
34    #[error("invalid message JSON: {0}")]
35    Deserialize(#[from] serde_json::Error),
36}
37
38/// Payload for `agent.status` messages.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct StatusPayload {
41    /// Current status label (e.g. `"working"`, `"idle"`).
42    pub status: String,
43    /// List of files modified by the agent.
44    pub modified_files: Vec<String>,
45    /// Optional human-readable message.
46    pub message: Option<String>,
47}
48
49/// Payload for `agent.artifact` messages.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct ArtifactPayload {
52    /// Current status label (e.g. `"done"`).
53    pub status: String,
54    /// List of exported symbols or public API items.
55    pub exports: Vec<String>,
56    /// List of files modified by the agent.
57    pub modified_files: Vec<String>,
58}
59
60/// Payload for `agent.blocked` messages.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct BlockedPayload {
63    /// What the agent needs to proceed.
64    pub needs: String,
65    /// Agent ID of the agent that can unblock the sender.
66    pub from: String,
67}
68
69/// Envelope for all inter-agent messages.
70///
71/// The wire format uses JSON with an internally tagged `"type"` discriminator
72/// whose values are `"agent.status"`, `"agent.artifact"`, and `"agent.blocked"`.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(tag = "type")]
75pub enum BrokerMessage {
76    /// Status heartbeat -- not routed to inboxes.
77    #[serde(rename = "agent.status")]
78    Status {
79        /// Sender agent ID (slugified branch name).
80        agent_id: String,
81        /// Status payload.
82        payload: StatusPayload,
83    },
84    /// Artifact announcement -- broadcast to all peers.
85    #[serde(rename = "agent.artifact")]
86    Artifact {
87        /// Sender agent ID.
88        agent_id: String,
89        /// Artifact payload.
90        payload: ArtifactPayload,
91    },
92    /// Blocked notification -- sent to the target agent.
93    #[serde(rename = "agent.blocked")]
94    Blocked {
95        /// Sender agent ID.
96        agent_id: String,
97        /// Blocked payload (contains `from` -- the unblocking agent).
98        payload: BlockedPayload,
99    },
100}
101
102impl BrokerMessage {
103    /// Deserializes and validates a broker message from a JSON string.
104    ///
105    /// Returns [`MessageError`] if the JSON is malformed or the `agent_id` is
106    /// invalid.
107    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    /// Returns the `agent_id` field from whichever variant.
114    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    /// Returns a short status label for the message.
123    ///
124    /// - `Status` returns `payload.status` (e.g. `"working"`)
125    /// - `Artifact` returns `payload.status` (e.g. `"done"`)
126    /// - `Blocked` returns `"blocked"`
127    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    /// Validates all fields according to the broker message spec.
136    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
209/// Converts a git branch name into a stable broker `agent_id` slug.
210///
211/// Applies a 5-step normalization algorithm:
212///
213/// 1. Convert to ASCII lowercase
214/// 2. Replace any character not in `[a-z0-9_]` with `-`
215/// 3. Collapse consecutive `-` into a single `-`
216/// 4. Trim leading and trailing `-`
217/// 5. If the result is empty, return `"agent"`
218///
219/// # Examples
220///
221/// - `"feat/http-broker"` → `"feat-http-broker"`
222/// - `"a/b/c"` → `"a-b-c"`
223/// - `"FEAT/X"` → `"feat-x"`
224/// - `""` → `"agent"`
225/// - `"---"` → `"agent"`
226pub fn slugify_branch(name: &str) -> String {
227    // Step 1: to ASCII lowercase
228    let lowered = name.to_ascii_lowercase();
229
230    // Step 2: replace non-[a-z0-9_] with -
231    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    // Step 3: collapse consecutive - to single -
243    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    // Step 4: trim leading/trailing -
258    let trimmed = collapsed.trim_matches('-');
259
260    // Step 5: if empty, return "agent"
261    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}