Skip to main content

kevy_resp/
reply_encode_resp3.rs

1//! RESP3 reply encoders — the additive prefixes that ride on top of the
2//! RESP2 wire (`+ - : $ * $-1 *-1`). Sibling of [`crate::reply_encode`]:
3//! everything here is `out: &mut Vec<u8>` + zero-alloc-past-initial-reserve,
4//! same shape as the RESP2 encoders, so a dispatch path can pick a proto
5//! per connection without changing its calling convention.
6//!
7//! Wire format reference: <https://github.com/antirez/RESP3/blob/master/spec.md>
8//!
9//! The two big-ticket helpers are the **header** encoders
10//! ([`encode_map_header`] / [`encode_set_header`] / [`encode_push_header`]).
11//! Like [`encode_array_len`] in RESP2, they emit only the count prefix —
12//! the caller follows up with the right number of sub-replies (2N for maps,
13//! N for sets / push). This keeps the encoders alloc-free and matches how
14//! dispatch already streams replies into the conn's output buffer.
15
16/// `%<count>\r\n` — a map header. Follow with `count` × 2 sub-replies
17/// (key₁ value₁ key₂ value₂ …). The count is the **pair** count, not
18/// the element count.
19///
20/// Used for replies that RESP2 ships as `*2N` array-of-pairs — `HGETALL`,
21/// `CONFIG GET`, `XINFO STREAM`. The map header is a single byte plus the
22/// count digits; vs RESP2's `*2N` it saves zero header bytes but the
23/// payload typically saves 4 B per pair by allowing simple-string keys.
24pub fn encode_map_header(out: &mut Vec<u8>, count: i64) {
25    out.push(b'%');
26    push_int(out, count);
27    out.extend_from_slice(b"\r\n");
28}
29
30/// `~<count>\r\n` — a set header. Follow with `count` sub-replies; the
31/// receiving client treats them as a set (dedup is its job; the wire
32/// doesn't require it).
33///
34/// Used for `SMEMBERS` / `SINTER` / `SUNION` / `SDIFF` / `SRANDMEMBER COUNT`.
35pub fn encode_set_header(out: &mut Vec<u8>, count: i64) {
36    out.push(b'~');
37    push_int(out, count);
38    out.extend_from_slice(b"\r\n");
39}
40
41/// `><count>\r\n` — an out-of-band push frame header. Follow with
42/// `count` sub-replies. The RESP3 client demultiplexes push frames from
43/// regular replies, so this is what `PUBLISH` / pattern-subscribe
44/// delivery uses when the consumer speaks RESP3.
45pub fn encode_push_header(out: &mut Vec<u8>, count: i64) {
46    out.push(b'>');
47    push_int(out, count);
48    out.extend_from_slice(b"\r\n");
49}
50
51/// `,<value>\r\n` — a double. `inf` / `-inf` / `nan` are valid wire
52/// payloads per spec; we forward Rust's standard float formatting which
53/// emits exactly those tokens.
54///
55/// Vs RESP2's `$<len>\r\n<digits>\r\n` shape this saves ~6 B per value
56/// (no length prefix, no trailing CRLF after the digits — the digits
57/// ARE the CRLF-terminated line). Worth it on `ZSCORE` flood or
58/// `ZRANGE WITHSCORES`.
59pub fn encode_double(out: &mut Vec<u8>, v: f64) {
60    out.push(b',');
61    if v.is_nan() {
62        out.extend_from_slice(b"nan");
63    } else if v.is_infinite() {
64        out.extend_from_slice(if v > 0.0 { b"inf" } else { b"-inf" });
65    } else {
66        // Match the wire shape RESP3 clients expect: an integer-valued
67        // double serialises without a decimal point ("3" not "3.0"),
68        // matching what the parse_double_reply round-trip expects. The
69        // bit-exact compare is the point — we mean "no fractional bits
70        // present", not "approximately integer".
71        #[allow(clippy::float_cmp)]
72        let is_integer_valued = v == v.trunc();
73        if is_integer_valued && v.abs() < 1e17 {
74            push_int(out, v as i64);
75        } else {
76            // Rust's default `{}` for f64 emits a shortest round-trippable
77            // representation — same shape Redis emits for ZSCORE. Format
78            // into a stack buffer (no heap alloc) then extend.
79            use std::io::Write as _;
80            let _ = write!(out, "{v}");
81        }
82    }
83    out.extend_from_slice(b"\r\n");
84}
85
86/// `#t\r\n` / `#f\r\n` — boolean.
87pub fn encode_boolean(out: &mut Vec<u8>, v: bool) {
88    out.extend_from_slice(if v { b"#t\r\n" } else { b"#f\r\n" });
89}
90
91/// `_\r\n` — RESP3 true null. RESP2 fallback is the existing
92/// [`crate::encode_null_bulk`] (`$-1\r\n`).
93pub fn encode_null(out: &mut Vec<u8>) {
94    out.extend_from_slice(b"_\r\n");
95}
96
97/// `(<digits>\r\n` — arbitrary-precision integer carried as its string
98/// representation. We don't ship a bignum type (charter: zero deps), so
99/// the caller hands in pre-formatted digit bytes.
100pub fn encode_big_number(out: &mut Vec<u8>, digits: &[u8]) {
101    out.reserve(digits.len() + 4);
102    out.push(b'(');
103    out.extend_from_slice(digits);
104    out.extend_from_slice(b"\r\n");
105}
106
107/// `=<len>\r\n<fmt>:<data>\r\n` — verbatim string. `fmt` MUST be a
108/// 3-byte format tag (`b"txt"`, `b"mkd"`, `b"raw"`, …); `data` is the
109/// payload after the `:` separator. The wire `len` covers the 3-byte
110/// fmt + `:` + payload (so `len = 4 + data.len()`).
111///
112/// Used for `CLIENT INFO` / `DEBUG OBJECT` style replies where a RESP3
113/// client wants to know "this is markdown, render it as markdown" but
114/// a RESP2 client still gets the raw bytes.
115pub fn encode_verbatim(out: &mut Vec<u8>, fmt: [u8; 3], data: &[u8]) {
116    let total_len = 4 + data.len();
117    out.reserve(total_len + 16);
118    out.push(b'=');
119    push_int(out, total_len as i64);
120    out.extend_from_slice(b"\r\n");
121    out.extend_from_slice(&fmt);
122    out.push(b':');
123    out.extend_from_slice(data);
124    out.extend_from_slice(b"\r\n");
125}
126
127/// `!<len>\r\n<error>\r\n` — length-prefixed error. Use when the error
128/// payload contains CRLF (the simple `-...` shape can't encode it).
129pub fn encode_blob_error(out: &mut Vec<u8>, msg: &[u8]) {
130    out.reserve(msg.len() + 16);
131    out.push(b'!');
132    push_int(out, msg.len() as i64);
133    out.extend_from_slice(b"\r\n");
134    out.extend_from_slice(msg);
135    out.extend_from_slice(b"\r\n");
136}
137
138/// Local copy of [`crate::reply_encode::push_int`] — keeps this file
139/// independent of the RESP2 encoder module's private helpers (so they
140/// can evolve separately) without taking a dep on a public re-export.
141fn push_int(out: &mut Vec<u8>, n: i64) {
142    if n == 0 {
143        out.push(b'0');
144        return;
145    }
146    let mut tmp = [0u8; 20];
147    let mut i = tmp.len();
148    let neg = n < 0;
149    let mut v = n;
150    while v != 0 {
151        let digit = (v % 10).unsigned_abs() as u8;
152        i -= 1;
153        tmp[i] = b'0' + digit;
154        v /= 10;
155    }
156    if neg {
157        out.push(b'-');
158    }
159    out.extend_from_slice(&tmp[i..]);
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::{parse_reply, Reply};
166
167    /// Each encoder pairs with its parse_reply round-trip — byte-exact
168    /// out, structurally-exact back in.
169    #[test]
170    fn map_header_round_trip() {
171        let mut out = Vec::new();
172        encode_map_header(&mut out, 2);
173        // Build a full map: 2 pairs, each (Int, Bulk).
174        crate::encode_integer(&mut out, 1);
175        crate::encode_bulk(&mut out, b"a");
176        crate::encode_integer(&mut out, 2);
177        crate::encode_bulk(&mut out, b"b");
178        assert_eq!(out, b"%2\r\n:1\r\n$1\r\na\r\n:2\r\n$1\r\nb\r\n");
179        let (r, used) = parse_reply(&out).unwrap().unwrap();
180        assert_eq!(used, out.len());
181        assert_eq!(
182            r,
183            Reply::Map(vec![
184                (Reply::Int(1), Reply::Bulk(b"a".to_vec())),
185                (Reply::Int(2), Reply::Bulk(b"b".to_vec())),
186            ])
187        );
188    }
189
190    #[test]
191    fn set_header_round_trip() {
192        let mut out = Vec::new();
193        encode_set_header(&mut out, 3);
194        for i in 1..=3 {
195            crate::encode_integer(&mut out, i);
196        }
197        assert_eq!(out, b"~3\r\n:1\r\n:2\r\n:3\r\n");
198        let (r, _) = parse_reply(&out).unwrap().unwrap();
199        assert_eq!(r, Reply::Set(vec![Reply::Int(1), Reply::Int(2), Reply::Int(3)]));
200    }
201
202    #[test]
203    fn push_header_round_trip() {
204        let mut out = Vec::new();
205        encode_push_header(&mut out, 3);
206        crate::encode_simple_string(&mut out, "message");
207        crate::encode_bulk(&mut out, b"news");
208        crate::encode_bulk(&mut out, b"hello");
209        assert_eq!(out, b">3\r\n+message\r\n$4\r\nnews\r\n$5\r\nhello\r\n");
210        let (r, _) = parse_reply(&out).unwrap().unwrap();
211        assert_eq!(
212            r,
213            Reply::Push(vec![
214                Reply::Simple(b"message".to_vec()),
215                Reply::Bulk(b"news".to_vec()),
216                Reply::Bulk(b"hello".to_vec()),
217            ])
218        );
219    }
220
221    #[test]
222    fn double_round_trip() {
223        let mut out = Vec::new();
224        // 1.5 avoids clippy::approx_constant flagging 3.14 as a PI proxy.
225        encode_double(&mut out, 1.5);
226        assert_eq!(out, b",1.5\r\n");
227        let (r, _) = parse_reply(&out).unwrap().unwrap();
228        assert_eq!(r, Reply::Double(1.5));
229
230        out.clear();
231        encode_double(&mut out, 5.0); // integer-valued: no decimal point
232        assert_eq!(out, b",5\r\n");
233        let (r, _) = parse_reply(&out).unwrap().unwrap();
234        assert_eq!(r, Reply::Double(5.0));
235
236        out.clear();
237        encode_double(&mut out, f64::INFINITY);
238        assert_eq!(out, b",inf\r\n");
239
240        out.clear();
241        encode_double(&mut out, f64::NEG_INFINITY);
242        assert_eq!(out, b",-inf\r\n");
243
244        out.clear();
245        encode_double(&mut out, f64::NAN);
246        assert_eq!(out, b",nan\r\n");
247    }
248
249    #[test]
250    fn boolean_and_null_round_trip() {
251        let mut out = Vec::new();
252        encode_boolean(&mut out, true);
253        assert_eq!(out, b"#t\r\n");
254        let (r, _) = parse_reply(&out).unwrap().unwrap();
255        assert_eq!(r, Reply::Boolean(true));
256
257        out.clear();
258        encode_boolean(&mut out, false);
259        assert_eq!(out, b"#f\r\n");
260        let (r, _) = parse_reply(&out).unwrap().unwrap();
261        assert_eq!(r, Reply::Boolean(false));
262
263        out.clear();
264        encode_null(&mut out);
265        assert_eq!(out, b"_\r\n");
266        let (r, _) = parse_reply(&out).unwrap().unwrap();
267        assert_eq!(r, Reply::Null);
268    }
269
270    #[test]
271    fn verbatim_round_trip() {
272        let mut out = Vec::new();
273        encode_verbatim(&mut out, *b"txt", b"Some string");
274        assert_eq!(out, b"=15\r\ntxt:Some string\r\n");
275        let (r, _) = parse_reply(&out).unwrap().unwrap();
276        assert_eq!(r, Reply::Verbatim { fmt: *b"txt", data: b"Some string".to_vec() });
277    }
278
279    #[test]
280    fn big_number_round_trip() {
281        let mut out = Vec::new();
282        encode_big_number(&mut out, b"170141183460469231731687303715884105727");
283        assert_eq!(out, b"(170141183460469231731687303715884105727\r\n");
284        let (r, _) = parse_reply(&out).unwrap().unwrap();
285        assert_eq!(
286            r,
287            Reply::BigNumber(b"170141183460469231731687303715884105727".to_vec())
288        );
289    }
290
291    #[test]
292    fn blob_error_round_trip() {
293        let mut out = Vec::new();
294        encode_blob_error(&mut out, b"ERR bad thing\nwith newline");
295        let (r, _) = parse_reply(&out).unwrap().unwrap();
296        assert_eq!(r, Reply::BlobError(b"ERR bad thing\nwith newline".to_vec()));
297    }
298}