godspeed_sdk/record.rs
1// SPDX-License-Identifier: Apache-2.0
2//! Structured records - the typed `Table` that flows through GodspeedOS pipes.
3//!
4//! POSIX pipes carry text, so structured data is flattened to a string and `grep`/`awk`/`cut`
5//! re-parse it back. GodspeedOS keeps the structure: a producer emits a **`Table`** (typed
6//! columns + rows); filters operate on real fields (`where mem>0`), and JSON/YAML/the grid are
7//! *renderings* of the model, not the model itself. This module is that model, lifted into the
8//! SDK so **any** service - not just the shell - can build records, filter them, and render them
9//! to JSON/YAML. See `docs/records.md`.
10//!
11//! ## Building a pipe-friendly service
12//!
13//! A service participates in a record pipe today without any new kernel surface: build a
14//! `Table`, render it with [`Table::to_json`], and emit the bytes (EOT-terminated) like any byte
15//! producer (`docs/pipes.md`). The shell's `| from json` lifts it back to records:
16//!
17//! ```ignore
18//! let mut t = Table::new(&["name", "n"]);
19//! let n = t.intern(b"alpha");
20//! t.add_row(&[n, Value::Int(1)]);
21//! let mut buf = MyBuf::new(); // any RecordSink (e.g. wrapping an IPC message)
22//! t.to_json(&mut buf); // → `[ {"name": "alpha", "n": 1} ]`
23//! ```
24//!
25//! To cross a **service** boundary *as records* (skipping the JSON round-trip), use the bounded
26//! binary **wire codec** instead: [`Table::encode`] on the producer, [`Table::decode`] on the
27//! consumer. It is the `Table` itself on the wire - compact and typed, not JSON. `examples/roster`
28//! produces records this way; the shell decodes them straight into a `Table`.
29//!
30//! ## Bounds (§26.6)
31//!
32//! Everything is fixed-size and stack-resident - no heap, loud on overflow: at most
33//! [`REC_MAX_COLS`] columns, [`REC_MAX_ROWS`] rows, a [`REC_ARENA`]-byte string arena, and
34//! [`REC_COL_NAME`]-byte column names. Overflowing any bound sets [`Table::overflow`].
35//! One input is ignored WITHOUT setting it: values past the column count in an `add_row` call.
36
37use core::cmp::Ordering;
38
39/// Maximum columns in a table.
40pub const REC_MAX_COLS: usize = 8;
41/// Maximum rows in a table.
42pub const REC_MAX_ROWS: usize = 64;
43/// Backing store (bytes) for interned `Str` cell values.
44pub const REC_ARENA: usize = 4 * 1024;
45/// Maximum column-name length (bytes).
46pub const REC_COL_NAME: usize = 24;
47
48/// Magic prefix of the binary wire encoding (`Table::encode`/`decode`). Lets a decoder reject a
49/// non-record byte stream loudly instead of misparsing it.
50pub const REC_WIRE_MAGIC: &[u8; 4] = b"GSR1";
51
52/// A typed cell. `Str` points into the owning [`Table`]'s arena (no lifetimes, no heap).
53#[derive(Clone, Copy)]
54pub enum Value {
55 /// A string, stored as an `(offset, length)` into the table's arena.
56 Str { off: u32, len: u32 },
57 /// An unsigned integer.
58 Int(u64),
59 /// A SIGNED integer: a reading that can be below zero. Pulled into existence by `wifi list`, whose
60 /// signal is in dBm - always negative - and is the raw fact (`utilities/0_conventions.md` rule 7):
61 /// the magnitude would be a different number, and a string would sort by bytes, which orders
62 /// `-9` after `-80`. Its own variant rather than a change to `Int`, so every count, size and tick
63 /// keeps the type it had. Compares numerically with `Int` (`sort`, `where`) and is reduced by the
64 /// aggregators through `cell_num_wide` (i128); only the u64 `cell_int` accessor refuses a negative.
65 Signed(i64),
66 /// An absent / null cell.
67 Empty,
68}
69
70/// A numeric-column reducer (the `sum`/`min`/`max`/`avg` pipe aggregators, docs/scripting.md §5).
71#[derive(Clone, Copy)]
72pub enum AggOp { Sum, Min, Max, Avg }
73
74/// Why an aggregate failed - each maps to a loud shell notice, never a silent 0.
75#[derive(Clone, Copy)]
76pub enum AggErr { NoColumn, NonNumeric }
77
78/// A sink the renderers write bytes into - the caller's bridge to a console, a capture buffer,
79/// or an IPC message. Implement it for whatever the bytes should go to.
80pub trait RecordSink {
81 /// Append `bytes` to the sink.
82 fn put(&mut self, bytes: &[u8]);
83}
84
85/// A bounded table of typed rows - the canonical structured-pipe value. Owned column names (so a
86/// `from_json` parse can name columns dynamically), rows of [`Value`], and a byte arena holding
87/// the `Str` cells. All inline, no heap.
88pub struct Table {
89 col_names: [[u8; REC_COL_NAME]; REC_MAX_COLS],
90 col_lens: [u8; REC_MAX_COLS],
91 ncols: usize,
92 rows: [[Value; REC_MAX_COLS]; REC_MAX_ROWS],
93 nrows: usize,
94 arena: [u8; REC_ARENA],
95 alen: usize,
96 overflow: bool,
97}
98
99impl Table {
100 /// A new table with the given column names (in order).
101 pub fn new(cols: &[&str]) -> Self {
102 let mut t = Table {
103 col_names: [[0u8; REC_COL_NAME]; REC_MAX_COLS], col_lens: [0; REC_MAX_COLS], ncols: 0,
104 rows: [[Value::Empty; REC_MAX_COLS]; REC_MAX_ROWS], nrows: 0,
105 arena: [0u8; REC_ARENA], alen: 0, overflow: false,
106 };
107 for c in cols { t.add_col(c.as_bytes()); }
108 t
109 }
110
111 /// Add a column by name; returns its index (or `None` if full / name too long → overflow).
112 pub fn add_col(&mut self, name: &[u8]) -> Option<usize> {
113 if self.ncols >= REC_MAX_COLS || name.len() > REC_COL_NAME { self.overflow = true; return None; }
114 let i = self.ncols;
115 self.col_names[i][..name.len()].copy_from_slice(name);
116 self.col_lens[i] = name.len() as u8;
117 self.ncols += 1;
118 Some(i)
119 }
120
121 /// Copy bytes into the arena and return a `Str` value (or `Empty` if the arena is full).
122 pub fn intern(&mut self, s: &[u8]) -> Value {
123 if self.alen + s.len() > REC_ARENA { self.overflow = true; return Value::Empty; }
124 let off = self.alen as u32;
125 self.arena[self.alen..self.alen + s.len()].copy_from_slice(s);
126 self.alen += s.len();
127 Value::Str { off, len: s.len() as u32 }
128 }
129
130 /// Append a row (values in column order). Loud-bounded: extra rows set [`Table::overflow`].
131 pub fn add_row(&mut self, vals: &[Value]) {
132 if self.nrows >= REC_MAX_ROWS { self.overflow = true; return; }
133 // More values than columns is a row that does not fit, and says so like every other
134 // bound here; the extras were dropped silently until 2026-10-10 (`backlog/fixed/80` S7).
135 if vals.len() > self.ncols { self.overflow = true; }
136 for (i, v) in vals.iter().take(self.ncols).enumerate() { self.rows[self.nrows][i] = *v; }
137 self.nrows += 1;
138 }
139
140 /// Number of columns.
141 pub fn ncols(&self) -> usize { self.ncols }
142 /// Number of rows.
143 pub fn nrows(&self) -> usize { self.nrows }
144
145 /// The value of a numeric cell; `None` for a string or absent one. The twin of
146 /// [`Table::cell_bytes`], for the same reason: deriving a view from a table beats rendering it
147 /// and parsing the text back.
148 pub fn cell_int(&self, r: usize, c: usize) -> Option<u64> {
149 if r >= self.nrows || c >= self.ncols { return None; }
150 self.cell_num(self.rows[r][c])
151 }
152
153 /// The bytes of a string cell; empty for a numeric or absent one.
154 ///
155 /// Exists so one table can be DERIVED from another - `events deps` reads the event table to count
156 /// what a service actually called. Without it the only way to consume a `Table` was to render it
157 /// and re-parse the text, which is the round-trip this record model exists to avoid.
158 pub fn cell_bytes(&self, r: usize, c: usize) -> &[u8] {
159 if r >= self.nrows || c >= self.ncols { return b""; }
160 match self.rows[r][c] {
161 Value::Str { .. } => self.cell_str(self.rows[r][c]),
162 _ => b"",
163 }
164 }
165 /// True if any bound was exceeded while building (rows/cols/arena/name).
166 pub fn overflow(&self) -> bool { self.overflow }
167
168 /// Column `c`'s name bytes.
169 pub fn col_name(&self, c: usize) -> &[u8] { &self.col_names[c][..self.col_lens[c] as usize] }
170
171 fn col_index(&self, name: &str) -> Option<usize> {
172 (0..self.ncols).find(|&i| self.col_name(i) == name.as_bytes())
173 }
174
175 /// Resolve a cell's text: a `Str` from the arena, or the empty slice for non-strings.
176 fn cell_str(&self, v: Value) -> &[u8] {
177 match v {
178 Value::Str { off, len } => &self.arena[off as usize..(off + len) as usize],
179 _ => &[],
180 }
181 }
182
183 // ── filters / ops ─────────────────────────────────────────────────────────────────────
184
185 /// Keep only rows whose column `col` satisfies `<op> val` (in place). Ops: `=`/`==` `!=` `>`
186 /// `<` `>=` `<=` `~`(contains). Numeric when both sides parse as numbers, else textual.
187 /// Returns `false` (table unchanged) if `col` is not a column.
188 /// Keep rows matching `col op val`. Returns false if `col` is not a column - and in that case the
189 /// table is EMPTIED, not left alone.
190 ///
191 /// It used to be left alone, so `where peeer contains reply` printed a notice and then handed the
192 /// whole table onward: a mistyped column produced output identical to "everything matched", and a
193 /// `| count` downstream reported the full count. A predicate that could not be evaluated has not
194 /// selected anything, and saying so with zero rows is the honest answer (invariant 12) - the loud
195 /// notice stays, but it is no longer contradicted by the data underneath it.
196 pub fn filter(&mut self, col: &str, op: &str, val: &str) -> bool {
197 let ci = match self.col_index(col) { Some(i) => i, None => { self.nrows = 0; return false; } };
198 let mut keep = 0usize;
199 for r in 0..self.nrows {
200 if row_matches(self, r, ci, op, val) {
201 if keep != r { self.rows[keep] = self.rows[r]; }
202 keep += 1;
203 }
204 }
205 self.nrows = keep;
206 true
207 }
208
209 /// Keep only the named columns, in the given order (in place). Returns `false` (table
210 /// unchanged) if any name is not a column. The arena (string storage) is untouched.
211 pub fn select(&mut self, names: &[&str]) -> bool {
212 let mut new_names = [[0u8; REC_COL_NAME]; REC_MAX_COLS];
213 let mut new_lens = [0u8; REC_MAX_COLS];
214 let mut map = [0usize; REC_MAX_COLS];
215 let mut nc = 0usize;
216 for &name in names {
217 if name.is_empty() { continue; }
218 match self.col_index(name) {
219 Some(oi) if nc < REC_MAX_COLS => {
220 new_names[nc] = self.col_names[oi];
221 new_lens[nc] = self.col_lens[oi];
222 map[nc] = oi;
223 nc += 1;
224 }
225 Some(_) => {}
226 None => return false,
227 }
228 }
229 for r in 0..self.nrows {
230 let old = self.rows[r];
231 for i in 0..nc { self.rows[r][i] = old[map[i]]; }
232 for i in nc..self.ncols { self.rows[r][i] = Value::Empty; }
233 }
234 self.col_names = new_names;
235 self.col_lens = new_lens;
236 self.ncols = nc;
237 true
238 }
239
240 /// Order rows by column `col` (numeric when both ints, else by bytes), descending if
241 /// `reverse`. Returns `false` (table unchanged) if `col` is not a column.
242 pub fn sort(&mut self, col: &str, reverse: bool) -> bool {
243 let ci = match self.col_index(col) { Some(i) => i, None => return false };
244 let n = self.nrows;
245 let arena = &self.arena; // disjoint field borrow: rows sorted mutably, arena read-only
246 self.rows[..n].sort_unstable_by(|a, b| {
247 let o = cmp_values(a[ci], b[ci], arena);
248 if reverse { o.reverse() } else { o }
249 });
250 true
251 }
252
253 /// A cell as a number: an `Int` directly, or a `Str` of ASCII digits; `None` otherwise (a
254 /// non-numeric or empty cell - the caller turns that into a loud [`AggErr::NonNumeric`]).
255 fn cell_num(&self, v: Value) -> Option<u64> {
256 match v {
257 Value::Int(n) => Some(n),
258 Value::Signed(n) => u64::try_from(n).ok(),
259 Value::Str { off, len } => {
260 let b = &self.arena[off as usize..(off + len) as usize];
261 if b.is_empty() { return None; }
262 let mut acc: u64 = 0;
263 for &c in b {
264 if !c.is_ascii_digit() { return None; }
265 acc = acc.saturating_mul(10).saturating_add((c - b'0') as u64);
266 }
267 Some(acc)
268 }
269 Value::Empty => None,
270 }
271 }
272
273 /// A cell as a number for the aggregators, signed or not: `i128` holds every `u64` and every `i64`
274 /// exactly. A `Str` of ASCII digits, with an optional leading `-`, counts; anything else is `None`.
275 fn cell_num_wide(&self, v: Value) -> Option<i128> {
276 match v {
277 Value::Int(n) => Some(n as i128),
278 Value::Signed(n) => Some(n as i128),
279 Value::Str { off, len } => {
280 let b = &self.arena[off as usize..(off + len) as usize];
281 let (neg, digits) = match b.split_first() {
282 Some((b'-', rest)) => (true, rest),
283 _ => (false, b),
284 };
285 if digits.is_empty() || !digits.iter().all(|c| c.is_ascii_digit()) { return None; }
286 let mut acc: i128 = 0;
287 for &c in digits {
288 acc = acc.saturating_mul(10).saturating_add((c - b'0') as i128);
289 }
290 Some(if neg { -acc } else { acc })
291 }
292 Value::Empty => None,
293 }
294 }
295
296 /// Reduce a numeric column to a scalar (§5). Loud: `NoColumn` if `col` is not a column,
297 /// `NonNumeric` if any cell is not a number - never a silent 0. Empty table reduces to 0.
298 /// `avg` is integer, truncated toward zero. Signed columns reduce too (`wifi list | max dbm` is the
299 /// strongest signal), which is why the result is wide.
300 pub fn aggregate(&self, col: &str, op: AggOp) -> Result<i128, AggErr> {
301 let ci = self.col_index(col).ok_or(AggErr::NoColumn)?;
302 if self.nrows == 0 { return Ok(0); }
303 let (mut sum, mut mn, mut mx) = (0i128, i128::MAX, i128::MIN);
304 for r in 0..self.nrows {
305 let n = self.cell_num_wide(self.rows[r][ci]).ok_or(AggErr::NonNumeric)?;
306 sum = sum.saturating_add(n);
307 if n < mn { mn = n; }
308 if n > mx { mx = n; }
309 }
310 Ok(match op {
311 AggOp::Sum => sum,
312 AggOp::Min => mn,
313 AggOp::Max => mx,
314 AggOp::Avg => sum / self.nrows as i128,
315 })
316 }
317
318 // ── renderers (edge formats) ──────────────────────────────────────────────────────────
319
320 /// Render as an aligned text grid (the default view). String cells render in full (via the
321 /// arena), so a long value is never silently clipped (§3.12).
322 pub fn to_grid(&self, out: &mut impl RecordSink) {
323 let w = self.grid_widths();
324 self.grid_header(out, &w);
325 for r in 0..self.nrows { self.grid_row(out, r, &w); }
326 }
327
328 /// Column widths for the grid rendering, computed over EVERY row.
329 ///
330 /// Split out so a caller can render rows one at a time. A pager scrolling a long table needs the
331 /// columns to stay put between frames, which means the widths must come from the whole table and
332 /// not from the slice currently on screen.
333 pub fn grid_widths(&self) -> [usize; REC_MAX_COLS] {
334 let mut w = [0usize; REC_MAX_COLS];
335 for c in 0..self.ncols { w[c] = self.col_name(c).len(); }
336 for r in 0..self.nrows {
337 for c in 0..self.ncols {
338 let n = cell_width(self, self.rows[r][c]);
339 if n > w[c] { w[c] = n; }
340 }
341 }
342 w
343 }
344
345 /// The grid header line, at the widths from [`Table::grid_widths`].
346 pub fn grid_header(&self, out: &mut impl RecordSink, w: &[usize; REC_MAX_COLS]) {
347 for c in 0..self.ncols {
348 out.put(self.col_name(c));
349 pad(out, w[c].saturating_sub(self.col_name(c).len()) + 2);
350 }
351 out.put(b"\n");
352 }
353
354 /// One row of the grid, at the widths from [`Table::grid_widths`]. An out-of-range `r` writes
355 /// nothing, so a pager asking past the end gets a blank line rather than a panic.
356 pub fn grid_row(&self, out: &mut impl RecordSink, r: usize, w: &[usize; REC_MAX_COLS]) {
357 if r >= self.nrows { return; }
358 let mut scratch = [0u8; 24];
359 for c in 0..self.ncols {
360 let n = match self.rows[r][c] {
361 Value::Str { .. } => { let s = self.cell_str(self.rows[r][c]); out.put(s); s.len() }
362 v => { let n = fmt_cell(self, v, &mut scratch); out.put(&scratch[..n]); n }
363 };
364 pad(out, w[c].saturating_sub(n) + 2);
365 }
366 out.put(b"\n");
367 }
368
369 /// Render as a JSON array of objects (`to json`). Values are plain ASCII today - a real
370 /// string-escaper is a documented follow-up.
371 pub fn to_json(&self, out: &mut impl RecordSink) {
372 out.put(b"[\n");
373 for r in 0..self.nrows {
374 out.put(b" {");
375 for c in 0..self.ncols {
376 if c > 0 { out.put(b", "); }
377 out.put(b"\"");
378 out.put(self.col_name(c));
379 out.put(b"\": ");
380 match self.rows[r][c] {
381 Value::Int(_) | Value::Signed(_) => {
382 let mut b = [0u8; 24];
383 let n = fmt_cell(self, self.rows[r][c], &mut b);
384 out.put(&b[..n]);
385 }
386 Value::Empty => out.put(b"null"),
387 Value::Str { .. } => {
388 out.put(b"\"");
389 out.put(self.cell_str(self.rows[r][c]));
390 out.put(b"\"");
391 }
392 }
393 }
394 out.put(if r + 1 < self.nrows { b"},\n" } else { b"}\n" });
395 }
396 out.put(b"]\n");
397 }
398
399 /// Render as YAML - a list of mappings (`to yaml`). String cells render in full.
400 pub fn to_yaml(&self, out: &mut impl RecordSink) {
401 let mut scratch = [0u8; 24];
402 for r in 0..self.nrows {
403 for c in 0..self.ncols {
404 out.put(if c == 0 { b"- " } else { b" " });
405 out.put(self.col_name(c));
406 out.put(b": ");
407 match self.rows[r][c] {
408 Value::Str { .. } => out.put(self.cell_str(self.rows[r][c])),
409 v => { let n = fmt_cell(self, v, &mut scratch); out.put(&scratch[..n]); }
410 }
411 out.put(b"\n");
412 }
413 }
414 }
415
416 // ── from json (text → records bridge) ─────────────────────────────────────────────────
417
418 /// Parse a JSON array of flat objects into a `Table` - the `from json` bridge. Bounded
419 /// subset: `[ {"k": v, …}, … ]` with string / number / `true|false` / `null` values, **no
420 /// nesting**. The first object defines the columns; later objects fill known columns (new
421 /// keys ignored). On malformed input, returns `Err(reason)` with a bare static reason (the
422 /// caller adds any `from json:` prefix).
423 #[inline(never)]
424 pub fn from_json(input: &[u8]) -> Result<Table, &'static str> {
425 let mut t = Table::new(&[]);
426 let b = input;
427 let mut i = json_ws(b, 0);
428 if i >= b.len() || b[i] != b'[' { return Err("expected a JSON array '[ … ]'"); }
429 i = json_ws(b, i + 1);
430 if i < b.len() && b[i] == b']' { return Ok(t); }
431 let mut first_obj = true;
432 loop {
433 i = json_ws(b, i);
434 if i >= b.len() || b[i] != b'{' { return Err("expected an object '{ … }'"); }
435 i = json_ws(b, i + 1);
436 let mut row = [Value::Empty; REC_MAX_COLS];
437 if i < b.len() && b[i] == b'}' { i += 1; } else {
438 loop {
439 i = json_ws(b, i);
440 let (ks, ke, kn) = match json_string(b, i) {
441 Some(x) => x,
442 None => return Err("expected a \"key\""),
443 };
444 i = json_ws(b, kn);
445 if i >= b.len() || b[i] != b':' { return Err("expected ':'"); }
446 i = json_ws(b, i + 1);
447 let v;
448 if i < b.len() && b[i] == b'"' {
449 let (vs, ve, vn) = match json_string(b, i) {
450 Some(x) => x, None => return Err("unterminated string"),
451 };
452 v = t.intern(&b[vs..ve]);
453 i = vn;
454 } else if b[i..].starts_with(b"true") { v = Value::Int(1); i += 4; }
455 else if b[i..].starts_with(b"false") { v = Value::Int(0); i += 5; }
456 else if b[i..].starts_with(b"null") { v = Value::Empty; i += 4; }
457 else if i < b.len() && (b[i] == b'-' || b[i].is_ascii_digit()) {
458 let s = i;
459 if b[i] == b'-' { i += 1; }
460 while i < b.len() && b[i].is_ascii_digit() { i += 1; }
461 if i < b.len() && (b[i] == b'.' || b[i] == b'e' || b[i] == b'E') {
462 while i < b.len() && !matches!(b[i], b',' | b'}' | b' ' | b'\t' | b'\n' | b'\r') { i += 1; }
463 v = t.intern(&b[s..i]);
464 } else {
465 // A negative integer is a Signed cell; it used to fail the u64 parse and
466 // arrive as null, silently.
467 let txt = core::str::from_utf8(&b[s..i]).ok();
468 v = match txt.and_then(|x| x.parse::<u64>().ok()) {
469 Some(n) => Value::Int(n),
470 None => txt.and_then(|x| x.parse::<i64>().ok()).map(Value::Signed).unwrap_or(Value::Empty),
471 };
472 }
473 } else {
474 return Err("unsupported value (nested objects/arrays not supported)");
475 }
476 let key = &b[ks..ke];
477 let ci = (0..t.ncols).find(|&c| t.col_name(c) == key);
478 let ci = match ci {
479 Some(c) => Some(c),
480 None if first_obj => t.add_col(key),
481 None => None,
482 };
483 if let Some(ci) = ci { row[ci] = v; }
484 i = json_ws(b, i);
485 if i < b.len() && b[i] == b',' { i += 1; continue; }
486 if i < b.len() && b[i] == b'}' { i += 1; break; }
487 return Err("expected ',' or '}'");
488 }
489 }
490 // This table's columns only - see the same line in `decode`, which had the same fault.
491 t.add_row(&row[..t.ncols]);
492 first_obj = false;
493 i = json_ws(b, i);
494 if i < b.len() && b[i] == b',' { i += 1; continue; }
495 if i < b.len() && b[i] == b']' { return Ok(t); }
496 return Err("expected ',' or ']'");
497 }
498 }
499
500 // ── wire codec (records across a service boundary) ────────────────────────────────────
501
502 /// Encode the table into a compact, *bounded* binary form for crossing a **service**
503 /// boundary as records (no JSON round-trip). This is emphatically **not** JSON - it is the
504 /// `Table` itself on the wire. Layout:
505 ///
506 /// ```text
507 /// magic "GSR1" | ncols:u8 | nrows:u8
508 /// per column: name_len:u8 | name bytes
509 /// per cell: tag:u8 (0=empty 1=int 2=str 3=signed)
510 /// int → val:u64-le ; str → len:u16-le | bytes ; empty → (nothing)
511 /// ```
512 ///
513 /// Symmetric with [`Table::decode`]. The whole encoding is bounded by the table's own
514 /// bounds; a producer sends it as one IPC message (≤ 4 KiB) for a small table, or chunks it
515 /// - the shell drains chunks until EOT, then decodes. (A chunked producer must never emit a
516 /// lone `0x04` chunk, which is the EOT marker; the magic guarantees the first chunk is not.)
517 pub fn encode(&self, out: &mut impl RecordSink) {
518 out.put(REC_WIRE_MAGIC);
519 out.put(&[self.ncols as u8, self.nrows as u8]);
520 for c in 0..self.ncols {
521 let name = self.col_name(c);
522 out.put(&[name.len() as u8]);
523 out.put(name);
524 }
525 for r in 0..self.nrows {
526 for c in 0..self.ncols {
527 match self.rows[r][c] {
528 Value::Empty => out.put(&[0u8]),
529 Value::Int(i) => { out.put(&[1u8]); out.put(&i.to_le_bytes()); }
530 Value::Signed(i) => { out.put(&[3u8]); out.put(&i.to_le_bytes()); }
531 Value::Str { .. } => {
532 let s = self.cell_str(self.rows[r][c]);
533 out.put(&[2u8]);
534 out.put(&(s.len() as u16).to_le_bytes());
535 out.put(s);
536 }
537 }
538 }
539 }
540 }
541
542 /// Decode the binary form produced by [`Table::encode`]. Validates the magic and every length
543 /// against the table bounds and the buffer end - a truncated, oversized, or non-record buffer
544 /// is a loud `Err`, never a misparse (§3.12). Strings intern into the new table's arena.
545 #[inline(never)]
546 pub fn decode(bytes: &[u8]) -> Result<Table, &'static str> {
547 let b = bytes;
548 if b.len() < 6 || &b[..4] != &REC_WIRE_MAGIC[..] { return Err("not a record stream (bad magic)"); }
549 let mut p = 4usize;
550 let ncols = b[p] as usize; p += 1;
551 let nrows = b[p] as usize; p += 1;
552 if ncols > REC_MAX_COLS { return Err("record has too many columns"); }
553 if nrows > REC_MAX_ROWS { return Err("record has too many rows"); }
554 let mut t = Table::new(&[]);
555 for _ in 0..ncols {
556 if p >= b.len() { return Err("truncated record (column count)"); }
557 let nl = b[p] as usize; p += 1;
558 if p + nl > b.len() { return Err("truncated record (column name)"); }
559 if t.add_col(&b[p..p + nl]).is_none() { return Err("record column rejected (name too long)"); }
560 p += nl;
561 }
562 for _ in 0..nrows {
563 let mut row = [Value::Empty; REC_MAX_COLS];
564 for cell in row.iter_mut().take(ncols) {
565 if p >= b.len() { return Err("truncated record (cell tag)"); }
566 let tag = b[p]; p += 1;
567 *cell = match tag {
568 0 => Value::Empty,
569 1 => {
570 if p + 8 > b.len() { return Err("truncated record (int)"); }
571 let mut a = [0u8; 8];
572 a.copy_from_slice(&b[p..p + 8]); p += 8;
573 Value::Int(u64::from_le_bytes(a))
574 }
575 3 => {
576 if p + 8 > b.len() { return Err("truncated record (signed int)"); }
577 let mut a = [0u8; 8];
578 a.copy_from_slice(&b[p..p + 8]); p += 8;
579 Value::Signed(i64::from_le_bytes(a))
580 }
581 2 => {
582 if p + 2 > b.len() { return Err("truncated record (string length)"); }
583 let len = u16::from_le_bytes([b[p], b[p + 1]]) as usize; p += 2;
584 if p + len > b.len() { return Err("truncated record (string)"); }
585 let v = t.intern(&b[p..p + len]); p += len;
586 v
587 }
588 _ => return Err("bad record cell tag"),
589 };
590 }
591 // THIS TABLE'S COLUMNS, not the whole `REC_MAX_COLS` scratch row. Since `add_row` calls more
592 // values than columns an overflow (`backlog/fixed/80` S7, `507c2d55`), passing the scratch row made
593 // every decoded table of fewer than 8 columns report a truncation that did not happen - a
594 // record service piped into anything printed "result exceeded the record bound" over three
595 // rows. Found by `ask`, the first record service whose pipe a test read closely.
596 t.add_row(&row[..ncols]);
597 }
598 Ok(t)
599 }
600}
601
602/// Parse a compact predicate token `col<op>val` (e.g. `mem>0`, `state=BlockRecv`, `name!=x`).
603/// The operator is the longest match (`!=`/`>=`/`<=`/`==` before `=`/`>`/`<`/`~`); before it is
604/// the column, after it the value. `None` if no operator is present.
605pub fn parse_predicate(tok: &str) -> Option<(&str, &str, &str)> {
606 // `contains` IS A WORD, and words are the vocabulary here (`utilities/0_conventions.md` rule 4:
607 // "Subcommands are words, never single-letter flags. A word means the same thing across every
608 // utility"). The comparison operators `= != > < >= <=` are self-evident to anyone who has seen
609 // arithmetic; `~` was the one nobody could read without being told, which is exactly the cost that
610 // rule exists to avoid. It is REPLACED rather than aliased - rule 3 rejects synonyms outright.
611 //
612 // Written spaced (`peer contains reply`) because that is how a word reads. The symbolic operators
613 // stay unspaced, as they are written everywhere else.
614 let t = tok.trim();
615 if let Some(i) = t.find(" contains ") {
616 return Some((t[..i].trim(), "~", t[i + " contains ".len()..].trim()));
617 }
618 for op in ["!=", ">=", "<=", "=="] {
619 if let Some(i) = t.find(op) { return Some((&t[..i], op, &t[i + op.len()..])); }
620 }
621 for op in ["=", ">", "<"] {
622 if let Some(i) = t.find(op) { return Some((&t[..i], &t[i..i + 1], &t[i + 1..])); }
623 }
624 None
625}
626
627// ── private helpers ───────────────────────────────────────────────────────────────────────
628
629fn pad(out: &mut impl RecordSink, n: usize) {
630 for _ in 0..n { out.put(b" "); }
631}
632
633/// Format one cell into `buf`, returning its length. Strings copy out (clamped to `buf`); ints
634/// are decimal. Used for the numeric/scratch path; full-string rendering bypasses this.
635fn fmt_cell(t: &Table, v: Value, buf: &mut [u8; 24]) -> usize {
636 match v {
637 Value::Str { .. } => {
638 let s = t.cell_str(v);
639 let n = s.len().min(buf.len());
640 buf[..n].copy_from_slice(&s[..n]);
641 n
642 }
643 Value::Int(i) => fmt_u64(i, buf),
644 Value::Signed(i) if i < 0 => {
645 buf[0] = b'-';
646 let mut rest = [0u8; 24];
647 let n = fmt_u64(i.unsigned_abs(), &mut rest);
648 buf[1..1 + n].copy_from_slice(&rest[..n]);
649 1 + n
650 }
651 Value::Signed(i) => fmt_u64(i as u64, buf),
652 Value::Empty => 0,
653 }
654}
655
656/// `x` in decimal at the front of `buf`; returns the length.
657fn fmt_u64(x: u64, buf: &mut [u8; 24]) -> usize {
658 let mut tmp = [0u8; 20];
659 let mut p = tmp.len();
660 let mut x = x;
661 loop { p -= 1; tmp[p] = b'0' + (x % 10) as u8; x /= 10; if x == 0 { break; } }
662 let n = tmp.len() - p;
663 buf[..n].copy_from_slice(&tmp[p..]);
664 n
665}
666
667/// Display width of a cell: a string's full arena length, else its formatted (numeric) length.
668fn cell_width(t: &Table, v: Value) -> usize {
669 match v {
670 Value::Str { len, .. } => len as usize,
671 Value::Int(_) | Value::Signed(_) => { let mut b = [0u8; 24]; fmt_cell(t, v, &mut b) }
672 Value::Empty => 0,
673 }
674}
675
676/// Does row `r`'s column `ci` satisfy `<op> val`? Numeric if both are numbers, else textual.
677fn row_matches(t: &Table, r: usize, ci: usize, op: &str, val: &str) -> bool {
678 let cell = t.rows[r][ci];
679 let cell_num: Option<i128> = match cell {
680 Value::Int(i) => Some(i as i128),
681 Value::Signed(i) => Some(i as i128),
682 Value::Str { .. } => core::str::from_utf8(t.cell_str(cell)).ok().and_then(|s| s.parse::<i128>().ok()),
683 Value::Empty => None,
684 };
685 if let (Some(cn), Ok(vn)) = (cell_num, val.parse::<i128>()) {
686 return match op {
687 "=" | "==" => cn == vn,
688 "!=" => cn != vn,
689 ">" => cn > vn,
690 "<" => cn < vn,
691 ">=" => cn >= vn,
692 "<=" => cn <= vn,
693 _ => false,
694 };
695 }
696 let cs = t.cell_str(cell);
697 let vb = val.as_bytes();
698 match op {
699 "=" | "==" => cs == vb,
700 "!=" => cs != vb,
701 "~" => contains(cs, vb),
702 _ => false,
703 }
704}
705
706/// Resolve a value to its comparable bytes (arena slice for `Str`, empty otherwise).
707fn val_str<'a>(v: Value, arena: &'a [u8]) -> &'a [u8] {
708 match v { Value::Str { off, len } => &arena[off as usize..(off + len) as usize], _ => &[] }
709}
710
711/// Order two cells: numeric when both are integers (signed or not), else by bytes.
712fn cmp_values(a: Value, b: Value, arena: &[u8]) -> Ordering {
713 let num = |v: Value| match v {
714 Value::Int(x) => Some(x as i128),
715 Value::Signed(x) => Some(x as i128),
716 _ => None,
717 };
718 match (num(a), num(b)) {
719 (Some(x), Some(y)) => x.cmp(&y),
720 _ => val_str(a, arena).cmp(val_str(b, arena)),
721 }
722}
723
724/// Byte-substring test (private copy; the shell keeps its own for `find`/`match`).
725fn contains(haystack: &[u8], needle: &[u8]) -> bool {
726 if needle.is_empty() { return true; }
727 if needle.len() > haystack.len() { return false; }
728 (0..=haystack.len() - needle.len()).any(|i| &haystack[i..i + needle.len()] == needle)
729}
730
731fn json_ws(b: &[u8], mut i: usize) -> usize {
732 while i < b.len() && (b[i] == b' ' || b[i] == b'\t' || b[i] == b'\n' || b[i] == b'\r') { i += 1; }
733 i
734}
735
736/// At a `"`, scan to the closing quote (a `\`-escaped char is skipped). Returns (content start,
737/// content end, index past the closing quote). Escapes are passed through literally.
738fn json_string(b: &[u8], i: usize) -> Option<(usize, usize, usize)> {
739 if i >= b.len() || b[i] != b'"' { return None; }
740 let start = i + 1;
741 let mut j = start;
742 while j < b.len() {
743 if b[j] == b'\\' { j += 2; continue; }
744 if b[j] == b'"' { return Some((start, j, j + 1)); }
745 j += 1;
746 }
747 None
748}