Skip to main content

qail_core/
wire.rs

1//! QAIL wire codec (parser-friendly, serde-free for AST payloads).
2//!
3//! This module provides compact encodings that round-trip through the
4//! canonical QAIL text formatter + parser, so consumers can transport AST
5//! commands without requiring serde derives on AST types.
6
7use crate::ast::Qail;
8
9const CMD_TEXT_MAGIC: &str = "QAIL-CMD/1";
10const CMDS_TEXT_MAGIC: &str = "QAIL-CMDS/1";
11const CMD_BIN_MAGIC: [u8; 4] = *b"QWB1";
12
13/// Encode one command into versioned text wire format.
14pub fn encode_cmd_text(cmd: &Qail) -> String {
15    let payload = cmd.to_string();
16    let mut out = String::with_capacity(CMD_TEXT_MAGIC.len() + payload.len() + 32);
17    out.push_str(CMD_TEXT_MAGIC);
18    out.push('\n');
19    out.push_str(&payload.len().to_string());
20    out.push('\n');
21    out.push_str(&payload);
22    out
23}
24
25/// Decode one command from text wire format.
26///
27/// Also accepts raw QAIL query text as fallback for convenience.
28pub fn decode_cmd_text(input: &str) -> Result<Qail, String> {
29    let bytes = input.as_bytes();
30    let mut idx = 0usize;
31
32    let Ok(magic) = read_line(bytes, &mut idx) else {
33        return crate::parse(input).map_err(|e| e.to_string());
34    };
35
36    if magic != CMD_TEXT_MAGIC {
37        return crate::parse(input).map_err(|e| e.to_string());
38    }
39
40    let len_line = read_line(bytes, &mut idx)?;
41    let payload_len = parse_usize("payload length", len_line)?;
42    let payload = read_exact_utf8(bytes, &mut idx, payload_len)?;
43    if idx != bytes.len() {
44        return Err("trailing bytes after command payload".to_string());
45    }
46
47    crate::parse(payload).map_err(|e| e.to_string())
48}
49
50/// Encode multiple commands into versioned text wire format.
51pub fn encode_cmds_text(cmds: &[Qail]) -> String {
52    let mut out = String::new();
53    out.push_str(CMDS_TEXT_MAGIC);
54    out.push('\n');
55    out.push_str(&cmds.len().to_string());
56    out.push('\n');
57
58    for cmd in cmds {
59        let payload = cmd.to_string();
60        out.push_str(&payload.len().to_string());
61        out.push('\n');
62        out.push_str(&payload);
63    }
64
65    out
66}
67
68/// Decode multiple commands from text wire format.
69pub fn decode_cmds_text(input: &str) -> Result<Vec<Qail>, String> {
70    let bytes = input.as_bytes();
71    let mut idx = 0usize;
72
73    let magic = read_line(bytes, &mut idx)?;
74    if magic != CMDS_TEXT_MAGIC {
75        return Err(format!(
76            "invalid wire magic: expected {CMDS_TEXT_MAGIC}, got {magic}"
77        ));
78    }
79
80    let count_line = read_line(bytes, &mut idx)?;
81    let count = parse_usize("command count", count_line)?;
82    let mut out = Vec::with_capacity(count);
83
84    for _ in 0..count {
85        let len_line = read_line(bytes, &mut idx)?;
86        let payload_len = parse_usize("payload length", len_line)?;
87        let payload = read_exact_utf8(bytes, &mut idx, payload_len)?;
88        let cmd = crate::parse(payload).map_err(|e| e.to_string())?;
89        out.push(cmd);
90    }
91
92    if idx != bytes.len() {
93        return Err("trailing bytes after batch payload".to_string());
94    }
95
96    Ok(out)
97}
98
99/// Encode one command into compact binary wire format.
100pub fn encode_cmd_binary(cmd: &Qail) -> Vec<u8> {
101    let payload = cmd.to_string();
102    let payload_bytes = payload.as_bytes();
103
104    let mut out = Vec::with_capacity(8 + payload_bytes.len());
105    out.extend_from_slice(&CMD_BIN_MAGIC);
106    out.extend_from_slice(&(payload_bytes.len() as u32).to_be_bytes());
107    out.extend_from_slice(payload_bytes);
108    out
109}
110
111/// Decode one command from compact binary wire format.
112///
113/// Also accepts raw UTF-8 QAIL query text as fallback.
114pub fn decode_cmd_binary(input: &[u8]) -> Result<Qail, String> {
115    if input.len() < 8 {
116        let text = std::str::from_utf8(input).map_err(|_| "invalid wire header".to_string())?;
117        return crate::parse(text).map_err(|e| e.to_string());
118    }
119
120    if input[0..4] != CMD_BIN_MAGIC {
121        let text = std::str::from_utf8(input).map_err(|_| "invalid wire header".to_string())?;
122        return crate::parse(text).map_err(|e| e.to_string());
123    }
124
125    let len = u32::from_be_bytes([input[4], input[5], input[6], input[7]]) as usize;
126    if input.len() != 8 + len {
127        return Err(format!(
128            "invalid payload length: header={len}, actual={}",
129            input.len().saturating_sub(8)
130        ));
131    }
132
133    let payload =
134        std::str::from_utf8(&input[8..]).map_err(|_| "payload is not valid UTF-8".to_string())?;
135    crate::parse(payload).map_err(|e| e.to_string())
136}
137
138fn read_line<'a>(bytes: &'a [u8], idx: &mut usize) -> Result<&'a str, String> {
139    if *idx >= bytes.len() {
140        return Err("unexpected EOF".to_string());
141    }
142
143    let start = *idx;
144    while *idx < bytes.len() && bytes[*idx] != b'\n' {
145        *idx += 1;
146    }
147
148    if *idx >= bytes.len() {
149        return Err("unterminated header line".to_string());
150    }
151
152    let line =
153        std::str::from_utf8(&bytes[start..*idx]).map_err(|_| "header is not UTF-8".to_string())?;
154    *idx += 1; // consume '\n'
155    Ok(line)
156}
157
158fn parse_usize(field: &str, line: &str) -> Result<usize, String> {
159    line.parse::<usize>()
160        .map_err(|_| format!("invalid {field}: {line}"))
161}
162
163fn read_exact_utf8<'a>(bytes: &'a [u8], idx: &mut usize, len: usize) -> Result<&'a str, String> {
164    if *idx + len > bytes.len() {
165        return Err("payload truncated".to_string());
166    }
167    let start = *idx;
168    *idx += len;
169    std::str::from_utf8(&bytes[start..start + len]).map_err(|_| "payload is not UTF-8".to_string())
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn cmd_text_roundtrip() {
178        let cmd = crate::ast::Qail::get("users")
179            .columns(["id", "email"])
180            .where_eq("active", true)
181            .limit(10);
182
183        let encoded = encode_cmd_text(&cmd);
184        let decoded = decode_cmd_text(&encoded).unwrap();
185        assert_eq!(decoded.to_string(), cmd.to_string());
186    }
187
188    #[test]
189    fn cmd_binary_roundtrip() {
190        let cmd = crate::ast::Qail::set("users")
191            .set_value("active", true)
192            .where_eq("id", 7);
193
194        let encoded = encode_cmd_binary(&cmd);
195        let decoded = decode_cmd_binary(&encoded).unwrap();
196        assert_eq!(decoded.to_string(), cmd.to_string());
197    }
198
199    #[test]
200    fn cmds_text_roundtrip() {
201        let cmds = vec![
202            crate::ast::Qail::get("users").columns(["id", "email"]),
203            crate::ast::Qail::get("users").limit(1),
204            crate::ast::Qail::del("users").where_eq("id", 99),
205        ];
206
207        let encoded = encode_cmds_text(&cmds);
208        let decoded = decode_cmds_text(&encoded).unwrap();
209        assert_eq!(decoded.len(), cmds.len());
210        for (lhs, rhs) in decoded.iter().zip(cmds.iter()) {
211            assert_eq!(lhs.to_string(), rhs.to_string());
212        }
213    }
214
215    #[test]
216    fn decode_cmd_text_falls_back_to_raw_qail() {
217        let decoded = decode_cmd_text("get users limit 1").unwrap();
218        assert_eq!(decoded.action, crate::ast::Action::Get);
219        assert_eq!(decoded.table, "users");
220        assert!(
221            decoded
222                .cages
223                .iter()
224                .any(|c| matches!(c.kind, crate::ast::CageKind::Limit(1)))
225        );
226    }
227}