1use 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
13pub 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
25pub 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
50pub 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
68pub 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
99pub 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
111pub 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; 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}