Skip to main content

kevy_resp_client/
pubsub_event.rs

1//! The pubsub frame vocabulary shared by every kevy client crate:
2//! [`PubsubEvent`] (one received frame, acks and deliveries alike)
3//! and [`classify_pubsub`] (RESP reply → event, handling both RESP2
4//! `*N` arrays and RESP3 `>N` push frames).
5
6use std::io;
7
8use kevy_resp::Reply;
9
10/// One pubsub frame received from the bus or the wire.
11///
12/// `Unsubscribe` / `Punsubscribe`'s `channel` / `pattern` is `None`
13/// when the server acknowledges "unsubscribed from everything" with a
14/// nil bulk — matching the Redis wire shape.
15#[non_exhaustive]
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum PubsubEvent {
18    /// `SUBSCRIBE` ack per channel.
19    Subscribe {
20        /// Channel that was just subscribed.
21        channel: Vec<u8>,
22        /// Total channels + patterns subscribed.
23        count: i64,
24    },
25    /// `PSUBSCRIBE` ack per pattern.
26    Psubscribe {
27        /// Pattern that was just subscribed.
28        pattern: Vec<u8>,
29        /// Total channels + patterns subscribed.
30        count: i64,
31    },
32    /// `UNSUBSCRIBE` ack.
33    Unsubscribe {
34        /// Channel just unsubscribed (`None` for "all"/"none" nil bulk).
35        channel: Option<Vec<u8>>,
36        /// Total channels + patterns still subscribed.
37        count: i64,
38    },
39    /// `PUNSUBSCRIBE` ack.
40    Punsubscribe {
41        /// Pattern just unsubscribed (`None` for "all"/"none" nil bulk).
42        pattern: Option<Vec<u8>>,
43        /// Total channels + patterns still subscribed.
44        count: i64,
45    },
46    /// Plain `PUBLISH` delivery on a subscribed channel.
47    Message {
48        /// Channel the publish was made to.
49        channel: Vec<u8>,
50        /// Raw payload bytes.
51        payload: Vec<u8>,
52    },
53    /// Pattern-match delivery.
54    Pmessage {
55        /// Pattern the channel matched.
56        pattern: Vec<u8>,
57        /// Channel the publish was made to.
58        channel: Vec<u8>,
59        /// Raw payload bytes.
60        payload: Vec<u8>,
61    },
62}
63
64/// Turn a RESP reply into a [`PubsubEvent`]. Handles both RESP2
65/// (`*N\r\n…` arrays) and RESP3 (`>N\r\n…` push frames).
66// LOC-WAIVER: data-driven pubsub-kind match table — one flat frame-destructure arm per kind.
67pub fn classify_pubsub(reply: Reply) -> io::Result<PubsubEvent> {
68    let items = match reply {
69        Reply::Array(v) | Reply::Push(v) => v,
70        Reply::Error(e) => return Err(io::Error::other(String::from_utf8_lossy(&e).into_owned())),
71        other => {
72            return Err(invalid(format!("pubsub: expected array/push, got {}", shape(&other))));
73        }
74    };
75
76    let mut it = items.into_iter();
77    let kind = take_bulk(it.next().ok_or_else(|| invalid("pubsub: empty frame"))?, "kind")?;
78
79    match kind.as_slice() {
80        b"subscribe" => {
81            let channel = take_bulk(
82                it.next().ok_or_else(|| invalid("subscribe: missing channel"))?,
83                "channel",
84            )?;
85            let count =
86                take_int(it.next().ok_or_else(|| invalid("subscribe: missing count"))?, "count")?;
87            Ok(PubsubEvent::Subscribe { channel, count })
88        }
89        b"psubscribe" => {
90            let pattern = take_bulk(
91                it.next().ok_or_else(|| invalid("psubscribe: missing pattern"))?,
92                "pattern",
93            )?;
94            let count =
95                take_int(it.next().ok_or_else(|| invalid("psubscribe: missing count"))?, "count")?;
96            Ok(PubsubEvent::Psubscribe { pattern, count })
97        }
98        b"unsubscribe" => {
99            let channel = take_bulk_or_nil(
100                it.next().ok_or_else(|| invalid("unsubscribe: missing channel"))?,
101                "channel",
102            )?;
103            let count =
104                take_int(it.next().ok_or_else(|| invalid("unsubscribe: missing count"))?, "count")?;
105            Ok(PubsubEvent::Unsubscribe { channel, count })
106        }
107        b"punsubscribe" => {
108            let pattern = take_bulk_or_nil(
109                it.next().ok_or_else(|| invalid("punsubscribe: missing pattern"))?,
110                "pattern",
111            )?;
112            let count = take_int(
113                it.next().ok_or_else(|| invalid("punsubscribe: missing count"))?,
114                "count",
115            )?;
116            Ok(PubsubEvent::Punsubscribe { pattern, count })
117        }
118        b"message" => {
119            let channel = take_bulk(
120                it.next().ok_or_else(|| invalid("message: missing channel"))?,
121                "channel",
122            )?;
123            let payload = take_bulk(
124                it.next().ok_or_else(|| invalid("message: missing payload"))?,
125                "payload",
126            )?;
127            Ok(PubsubEvent::Message { channel, payload })
128        }
129        b"pmessage" => {
130            let pattern = take_bulk(
131                it.next().ok_or_else(|| invalid("pmessage: missing pattern"))?,
132                "pattern",
133            )?;
134            let channel = take_bulk(
135                it.next().ok_or_else(|| invalid("pmessage: missing channel"))?,
136                "channel",
137            )?;
138            let payload = take_bulk(
139                it.next().ok_or_else(|| invalid("pmessage: missing payload"))?,
140                "payload",
141            )?;
142            Ok(PubsubEvent::Pmessage { pattern, channel, payload })
143        }
144        other => Err(invalid(format!("unknown pubsub kind: {}", String::from_utf8_lossy(other)))),
145    }
146}
147
148fn take_bulk(r: Reply, field: &str) -> io::Result<Vec<u8>> {
149    match r {
150        Reply::Bulk(v) | Reply::Simple(v) => Ok(v),
151        other => {
152            Err(invalid(format!("pubsub field {field}: expected bulk, got {}", shape(&other))))
153        }
154    }
155}
156
157fn take_bulk_or_nil(r: Reply, field: &str) -> io::Result<Option<Vec<u8>>> {
158    match r {
159        Reply::Bulk(v) | Reply::Simple(v) => Ok(Some(v)),
160        Reply::Nil | Reply::Null => Ok(None),
161        other => {
162            Err(invalid(format!("pubsub field {field}: expected bulk/nil, got {}", shape(&other))))
163        }
164    }
165}
166
167fn take_int(r: Reply, field: &str) -> io::Result<i64> {
168    match r {
169        Reply::Int(n) => Ok(n),
170        other => Err(invalid(format!("pubsub field {field}: expected int, got {}", shape(&other)))),
171    }
172}
173
174fn shape(r: &Reply) -> &'static str {
175    match r {
176        Reply::Simple(_) => "simple",
177        Reply::Error(_) => "error",
178        Reply::Int(_) => "int",
179        Reply::Bulk(_) => "bulk",
180        Reply::Nil | Reply::Null => "nil",
181        Reply::Array(_) => "array",
182        Reply::Map(_) => "map",
183        Reply::Set(_) => "set",
184        Reply::Double(_) => "double",
185        Reply::Boolean(_) => "boolean",
186        Reply::Verbatim { .. } => "verbatim",
187        Reply::BigNumber(_) => "bignumber",
188        Reply::Push(_) => "push",
189        Reply::BlobError(_) => "bloberror",
190    }
191}
192
193fn invalid(msg: impl Into<String>) -> io::Error {
194    io::Error::new(io::ErrorKind::InvalidData, msg.into())
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn classify_subscribe_ack() {
203        let r = Reply::Array(vec![
204            Reply::Bulk(b"subscribe".to_vec()),
205            Reply::Bulk(b"chan".to_vec()),
206            Reply::Int(1),
207        ]);
208        assert_eq!(
209            classify_pubsub(r).unwrap(),
210            PubsubEvent::Subscribe { channel: b"chan".to_vec(), count: 1 }
211        );
212    }
213
214    #[test]
215    fn classify_message_event() {
216        let r = Reply::Array(vec![
217            Reply::Bulk(b"message".to_vec()),
218            Reply::Bulk(b"news".to_vec()),
219            Reply::Bulk(b"hello".to_vec()),
220        ]);
221        assert_eq!(
222            classify_pubsub(r).unwrap(),
223            PubsubEvent::Message { channel: b"news".to_vec(), payload: b"hello".to_vec() }
224        );
225    }
226
227    #[test]
228    fn classify_pmessage_event() {
229        let r = Reply::Array(vec![
230            Reply::Bulk(b"pmessage".to_vec()),
231            Reply::Bulk(b"news.*".to_vec()),
232            Reply::Bulk(b"news.tech".to_vec()),
233            Reply::Bulk(b"hi".to_vec()),
234        ]);
235        assert_eq!(
236            classify_pubsub(r).unwrap(),
237            PubsubEvent::Pmessage {
238                pattern: b"news.*".to_vec(),
239                channel: b"news.tech".to_vec(),
240                payload: b"hi".to_vec(),
241            }
242        );
243    }
244
245    #[test]
246    fn classify_unsubscribe_with_nil_channel() {
247        let r = Reply::Array(vec![Reply::Bulk(b"unsubscribe".to_vec()), Reply::Nil, Reply::Int(0)]);
248        assert_eq!(
249            classify_pubsub(r).unwrap(),
250            PubsubEvent::Unsubscribe { channel: None, count: 0 }
251        );
252    }
253
254    #[test]
255    fn classify_accepts_push_frame() {
256        // RESP3 servers wrap the same shape in a `>N` push frame.
257        let r = Reply::Push(vec![
258            Reply::Bulk(b"message".to_vec()),
259            Reply::Bulk(b"c".to_vec()),
260            Reply::Bulk(b"p".to_vec()),
261        ]);
262        assert_eq!(
263            classify_pubsub(r).unwrap(),
264            PubsubEvent::Message { channel: b"c".to_vec(), payload: b"p".to_vec() }
265        );
266    }
267
268    #[test]
269    fn classify_accepts_simple_string_fields() {
270        // `take_bulk` accepts `Simple` as well as `Bulk` — a server may
271        // send the kind/channel as simple strings.
272        let r = Reply::Array(vec![
273            Reply::Simple(b"subscribe".to_vec()),
274            Reply::Simple(b"chan".to_vec()),
275            Reply::Int(2),
276        ]);
277        assert_eq!(
278            classify_pubsub(r).unwrap(),
279            PubsubEvent::Subscribe { channel: b"chan".to_vec(), count: 2 }
280        );
281    }
282
283    #[test]
284    fn classify_rejects_unknown_kind() {
285        let r = Reply::Array(vec![
286            Reply::Bulk(b"bogus".to_vec()),
287            Reply::Bulk(b"x".to_vec()),
288            Reply::Int(0),
289        ]);
290        assert!(classify_pubsub(r).is_err());
291    }
292
293    #[test]
294    fn classify_rejects_wrong_arity() {
295        let r = Reply::Array(vec![Reply::Bulk(b"subscribe".to_vec()), Reply::Bulk(b"x".to_vec())]);
296        assert!(classify_pubsub(r).is_err());
297    }
298}