use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Encoding {
Json,
Msgpack,
}
pub(crate) const SUBPROTOCOL_MSGPACK: &str = "bamboo.v2.msgpack";
pub(crate) const SUBPROTOCOL_JSON: &str = "bamboo.v2";
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum OutFrame {
Text(String),
Binary(Vec<u8>),
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub(crate) struct ServerEnvelope {
pub ch: String,
pub seq: u64,
#[serde(flatten)]
pub body: EnvelopeBody,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(untagged)]
pub(crate) enum EnvelopeBody {
Event {
event: Value,
},
Control {
control: Value,
},
}
impl ServerEnvelope {
pub(crate) fn event(ch: impl Into<String>, seq: u64, event: Value) -> Self {
Self {
ch: ch.into(),
seq,
body: EnvelopeBody::Event { event },
}
}
pub(crate) fn control(ch: impl Into<String>, seq: u64, control: Value) -> Self {
Self {
ch: ch.into(),
seq,
body: EnvelopeBody::Control { control },
}
}
pub(crate) fn to_text(&self) -> Option<String> {
serde_json::to_string(self).ok()
}
pub(crate) fn encode(&self, encoding: Encoding) -> Option<OutFrame> {
match encoding {
Encoding::Json => self.to_text().map(OutFrame::Text),
Encoding::Msgpack => rmp_serde::to_vec_named(self).ok().map(OutFrame::Binary),
}
}
}
pub(crate) fn decode_client_frame(encoding: Encoding, bytes: &[u8]) -> Result<ClientFrame, String> {
match encoding {
Encoding::Json => serde_json::from_slice(bytes).map_err(|e| e.to_string()),
Encoding::Msgpack => rmp_serde::from_slice(bytes).map_err(|e| e.to_string()),
}
}
pub(crate) fn terminal_control(reason: &str) -> Value {
serde_json::json!({ "type": "terminal", "reason": reason })
}
pub(crate) fn feed_reset_control(from_seq: u64) -> Value {
serde_json::json!({ "type": "feed_reset", "from_seq": from_seq })
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum ClientFrame {
Hello {
#[serde(default)]
device_id: Option<String>,
#[serde(default)]
token: Option<String>,
},
Subscribe {
ch: String,
#[serde(default)]
since: Option<u64>,
},
Unsubscribe { ch: String },
Stop { session_id: String },
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum Channel {
Feed,
Agent(String),
}
impl Channel {
pub(crate) fn parse(ch: &str) -> Option<Channel> {
if ch == "feed" {
Some(Channel::Feed)
} else if let Some(sid) = ch.strip_prefix("agent.") {
if sid.is_empty() {
None
} else {
Some(Channel::Agent(sid.to_string()))
}
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn server_envelope_event_shape() {
let env = ServerEnvelope::event(
"agent.sess_abc",
42,
json!({ "type": "token", "content": "Hello" }),
);
let v = serde_json::to_value(&env).unwrap();
assert_eq!(
v,
json!({
"ch": "agent.sess_abc",
"seq": 42,
"event": { "type": "token", "content": "Hello" }
})
);
assert_eq!(v.as_object().unwrap().len(), 3);
}
#[test]
fn server_envelope_control_shape() {
let env = ServerEnvelope::control("agent.sess_abc", 43, terminal_control("complete"));
let v = serde_json::to_value(&env).unwrap();
assert_eq!(
v,
json!({
"ch": "agent.sess_abc",
"seq": 43,
"control": { "type": "terminal", "reason": "complete" }
})
);
}
#[test]
fn feed_reset_control_shape() {
let env = ServerEnvelope::control("feed", 0, feed_reset_control(1006));
let v = serde_json::to_value(&env).unwrap();
assert_eq!(
v["control"],
json!({ "type": "feed_reset", "from_seq": 1006 })
);
}
#[test]
fn client_frame_hello_parses() {
let f: ClientFrame =
serde_json::from_str(r#"{"type":"hello","device_id":"d1","token":"bd1_x"}"#).unwrap();
assert_eq!(
f,
ClientFrame::Hello {
device_id: Some("d1".to_string()),
token: Some("bd1_x".to_string())
}
);
let f: ClientFrame = serde_json::from_str(r#"{"type":"hello"}"#).unwrap();
assert_eq!(
f,
ClientFrame::Hello {
device_id: None,
token: None
}
);
}
#[test]
fn client_frame_subscribe_parses_with_and_without_cursor() {
let f: ClientFrame =
serde_json::from_str(r#"{"type":"subscribe","ch":"feed","since":1006}"#).unwrap();
assert_eq!(
f,
ClientFrame::Subscribe {
ch: "feed".to_string(),
since: Some(1006)
}
);
let f: ClientFrame =
serde_json::from_str(r#"{"type":"subscribe","ch":"agent.s1"}"#).unwrap();
assert_eq!(
f,
ClientFrame::Subscribe {
ch: "agent.s1".to_string(),
since: None
}
);
}
#[test]
fn client_frame_unsubscribe_and_stop_parse() {
let f: ClientFrame =
serde_json::from_str(r#"{"type":"unsubscribe","ch":"agent.s1"}"#).unwrap();
assert_eq!(
f,
ClientFrame::Unsubscribe {
ch: "agent.s1".to_string()
}
);
let f: ClientFrame = serde_json::from_str(r#"{"type":"stop","session_id":"s1"}"#).unwrap();
assert_eq!(
f,
ClientFrame::Stop {
session_id: "s1".to_string()
}
);
}
#[test]
fn unknown_frame_type_maps_to_unknown_not_error() {
let f: ClientFrame =
serde_json::from_str(r#"{"type":"execute","session_id":"s1","message":"hi"}"#).unwrap();
assert_eq!(f, ClientFrame::Unknown);
}
#[test]
fn malformed_json_is_a_parse_error_caught_by_driver() {
let r: Result<ClientFrame, _> = serde_json::from_str("not json{");
assert!(r.is_err());
let r: Result<ClientFrame, _> = serde_json::from_str(r#"{"ch":"feed"}"#);
assert!(r.is_err());
}
#[test]
fn server_envelope_event_msgpack_roundtrips_to_same_schema() {
let env = ServerEnvelope::event(
"agent.sess_abc",
42,
json!({ "type": "token", "content": "Hello" }),
);
let frame = env.encode(Encoding::Msgpack).expect("msgpack encode");
let OutFrame::Binary(bytes) = frame else {
panic!("msgpack encoding must yield a Binary frame");
};
let v: Value = rmp_serde::from_slice(&bytes).expect("msgpack decodes to Value");
assert_eq!(
v,
json!({
"ch": "agent.sess_abc",
"seq": 42,
"event": { "type": "token", "content": "Hello" }
}),
"to_vec_named must preserve flatten + untagged as a {{ch,seq,event}} map"
);
assert_eq!(v.as_object().unwrap().len(), 3, "no wrapper nesting");
}
#[test]
fn server_envelope_control_msgpack_roundtrips_to_same_schema() {
let env = ServerEnvelope::control("agent.sess_abc", 43, terminal_control("complete"));
let frame = env.encode(Encoding::Msgpack).expect("msgpack encode");
let OutFrame::Binary(bytes) = frame else {
panic!("msgpack encoding must yield a Binary frame");
};
let v: Value = rmp_serde::from_slice(&bytes).expect("msgpack decodes to Value");
assert_eq!(
v,
json!({
"ch": "agent.sess_abc",
"seq": 43,
"control": { "type": "terminal", "reason": "complete" }
}),
"the untagged Control arm must round-trip as {{ch,seq,control}}"
);
}
#[test]
fn server_envelope_json_encode_is_unchanged_text() {
let env = ServerEnvelope::event("feed", 7, json!({ "type": "x" }));
let frame = env.encode(Encoding::Json).expect("json encode");
assert_eq!(frame, OutFrame::Text(env.to_text().unwrap()));
}
#[test]
fn client_frame_all_variants_msgpack_roundtrip() {
for original in [
ClientFrame::Hello {
device_id: Some("d1".into()),
token: Some("bd1_x".into()),
},
ClientFrame::Hello {
device_id: None,
token: None,
},
ClientFrame::Subscribe {
ch: "feed".into(),
since: Some(1006),
},
ClientFrame::Subscribe {
ch: "agent.s1".into(),
since: None,
},
ClientFrame::Unsubscribe {
ch: "agent.s1".into(),
},
ClientFrame::Stop {
session_id: "s1".into(),
},
] {
let as_json = match &original {
ClientFrame::Hello { device_id, token } => {
json!({ "type": "hello", "device_id": device_id, "token": token })
}
ClientFrame::Subscribe { ch, since } => {
json!({ "type": "subscribe", "ch": ch, "since": since })
}
ClientFrame::Unsubscribe { ch } => json!({ "type": "unsubscribe", "ch": ch }),
ClientFrame::Stop { session_id } => {
json!({ "type": "stop", "session_id": session_id })
}
ClientFrame::Unknown => unreachable!(),
};
let bytes = rmp_serde::to_vec_named(&as_json).expect("encode client frame as msgpack");
let decoded = decode_client_frame(Encoding::Msgpack, &bytes).expect("decode");
assert_eq!(decoded, original, "msgpack client frame must round-trip");
}
}
#[test]
fn client_frame_unknown_tag_msgpack_maps_to_unknown_not_error() {
let bytes =
rmp_serde::to_vec_named(&json!({ "type": "execute", "session_id": "s1" })).unwrap();
let decoded =
decode_client_frame(Encoding::Msgpack, &bytes).expect("unknown tag is not Err");
assert_eq!(decoded, ClientFrame::Unknown);
}
#[test]
fn client_frame_malformed_msgpack_is_err_not_panic() {
let r = decode_client_frame(Encoding::Msgpack, &[0xc1, 0x00, 0xff, 0x10]);
assert!(r.is_err());
let bytes = rmp_serde::to_vec_named(&json!({ "ch": "feed" })).unwrap();
assert!(decode_client_frame(Encoding::Msgpack, &bytes).is_err());
}
#[test]
fn decode_client_frame_json_matches_serde_json() {
let text = r#"{"type":"subscribe","ch":"feed","since":5}"#;
let decoded = decode_client_frame(Encoding::Json, text.as_bytes()).unwrap();
assert_eq!(
decoded,
ClientFrame::Subscribe {
ch: "feed".into(),
since: Some(5)
}
);
}
#[test]
fn channel_parse() {
assert_eq!(Channel::parse("feed"), Some(Channel::Feed));
assert_eq!(
Channel::parse("agent.sess_abc"),
Some(Channel::Agent("sess_abc".to_string()))
);
assert_eq!(Channel::parse("agent."), None);
assert_eq!(Channel::parse("bogus"), None);
}
}