Skip to main content

plg_runtime/
wire.rs

1//! The wire layer: the fixed envelope *shape* as a typed value, plus plural
2//! encodings exposed as **descriptors** (vtables of function pointers). Splits
3//! what was collapsed in `core.rs` — where byte-emitters implicitly defined the
4//! shape — so the shape is owned once (here) and the encodings vary
5//! independently.
6//!
7//! Two encodings, **no JSON**: **text** (the readable `X = foo` form — the
8//! default, human/shell-facing) and **bson** (binary, dense, typed — the
9//! machine-facing format). A host that wants JSON derives it from bson at the
10//! host boundary; the engine itself never serializes JSON. Term values inside
11//! bson are `BinData(0x00)` wrapping a `copyterm::TermBuf` — the same cell ABI
12//! the fact tables and `copy_term/2` use, single-sourced in `plg-shared::cell`,
13//! lossless including cyclic terms. Because a binary format can't coexist with
14//! streamed text bytes, bson forces capture mode; the encoding dictates the
15//! sink.
16//!
17//! **Why descriptors, not a trait.** Codegen bakes a per-binary *capability
18//! table* — pointers to the descriptors the program declared via
19//! `io_format/1` — and `entry.rs` dispatches through those pointers, never
20//! naming an encoder statically. Link-time `--gc-sections` then strips any
21//! encoder whose descriptor isn't in the table.
22
23use crate::copyterm::TermBuf;
24use crate::machine::Machine;
25use crate::render::RenderedSolution;
26use std::io::{self, Write};
27
28/// The fixed engine-output shape — the contract, made a type. Every encoding
29/// reads the same fields; only their byte encoding varies.
30pub struct Envelope<'a> {
31    pub count: usize,
32    pub exhausted: bool,
33    pub solutions: &'a [RenderedSolution],
34    /// Captured `write/1` bytes, when the sink is in capture mode. `None` when
35    /// output streamed to stdout (the CLI text path). Encodings that can't
36    /// stream require the caller to have run in capture mode so this is `Some`.
37    pub program_output: Option<&'a str>,
38    /// The atom map (id → name, id = array index), when the bson output is
39    /// self-describing (`--atoms`). Lets a host decode bson term values (atom-
40    /// id-keyed TermBuf) from the single document — no external atom source.
41    /// Emitted post-query, so it covers query-introduced atoms too.
42    pub atoms: Option<&'a [String]>,
43}
44
45impl<'a> Envelope<'a> {
46    /// Build from the machine's solved state. `program_output` follows the
47    /// machine's output sink: `None` when streaming to stdout, `Some(..)` when
48    /// capturing — so one constructor serves both the CLI and the reactor.
49    pub fn from_machine(m: &'a Machine, exhausted: bool) -> Self {
50        Self {
51            count: m.solutions.len(),
52            exhausted,
53            solutions: &m.solutions,
54            program_output: m.captured_output(),
55            atoms: None,
56        }
57    }
58}
59
60/// The two failure classes the engine distinguishes, carrying the message the
61/// wire contract puts on the wire. Callers construct the *correct* variant
62/// (`Parse` vs `Runtime`) so the distinction is honest on the wire even though
63/// today's encoders collapse both to the same bytes — a future bson `kind`
64/// field will surface it. Callers map the class to their surface (exit codes
65/// 2/3 for the CLI); the message bytes come from `core::run_query`.
66pub enum WireError {
67    Parse(String),
68    Runtime(String),
69}
70
71/// An encoding as a vtable: a name plus the three operations the wire contract
72/// needs. `#[repr(C)]` so codegen can reference a descriptor by address and
73/// `entry.rs` can read its fields after dereferencing the pointer from the
74/// capability table. A `#[no_mangle] static` of this type per encoding
75/// (`PLG_ENC_TEXT`, `PLG_ENC_BSON`) is what codegen's `@plg_caps` table points
76/// at; encoders not listed there are unreferenced and get dead-stripped.
77#[repr(C)]
78pub struct EncoderDesc {
79    /// The `--format` name this descriptor answers to ("text", "bson").
80    pub name: &'static str,
81    pub write_envelope: fn(&mut dyn Write, &Machine, &Envelope) -> io::Result<()>,
82    pub write_error: fn(&mut dyn Write, &WireError) -> io::Result<()>,
83    /// False ⇒ the encoding can't coexist with streamed `write/1` bytes on the
84    /// same stdout (binary formats), so the caller must run in capture mode.
85    pub can_stream: fn() -> bool,
86}
87
88impl EncoderDesc {
89    /// Find a descriptor by name in a capability table (codegen-baked pointers
90    /// to `#[no_mangle] static` descriptors). Returns `None` when the name
91    /// isn't advertised — `entry.rs` maps that to a usage error (exit 2).
92    /// # Safety
93    /// `caps` must point at `len` valid pointers to static descriptors.
94    pub unsafe fn find(
95        caps: *const *const EncoderDesc,
96        len: usize,
97        name: &str,
98    ) -> Option<&'static EncoderDesc> {
99        let slice = unsafe { std::slice::from_raw_parts(caps, len) };
100        for &p in slice {
101            let d = unsafe { &*p };
102            if d.name == name {
103                return Some(d);
104            }
105        }
106        None
107    }
108}
109
110// ── text: the readable wire encoding ────────────────────────────────────────
111//
112// The human/shell-facing form: `X = foo` per binding, `true.`/`false.` for
113// binding-less / empty solutions. Projects the envelope to solutions only —
114// `count`/`exhausted` live in the bson envelope; text is the simple, readable
115// face. Streaming-safe (line-oriented, coexists with `write/1` on stdout).
116// Like all text rendering, cyclic terms are cut (`X = f(X)` → `f(_N)`); bson
117// round-trips them.
118
119fn text_write_envelope(w: &mut dyn Write, _m: &Machine, e: &Envelope) -> io::Result<()> {
120    if e.solutions.is_empty() {
121        return w.write_all(b"false.\n");
122    }
123    for sol in e.solutions {
124        if sol.bindings.is_empty() {
125            w.write_all(b"true.\n")?;
126            continue;
127        }
128        for b in &sol.bindings {
129            writeln!(w, "{} = {}", b.name, b.text)?;
130        }
131    }
132    Ok(())
133}
134
135fn text_write_error(w: &mut dyn Write, err: &WireError) -> io::Result<()> {
136    let msg = match err {
137        WireError::Parse(m) | WireError::Runtime(m) => m,
138    };
139    writeln!(w, "error: {msg}")
140}
141
142const fn text_can_stream() -> bool {
143    true
144}
145
146/// The readable text wire encoding (default).
147#[unsafe(no_mangle)]
148pub static PLG_ENC_TEXT: EncoderDesc = EncoderDesc {
149    name: "text",
150    write_envelope: text_write_envelope,
151    write_error: text_write_error,
152    can_stream: text_can_stream,
153};
154
155// ── bson: the binary wire encoding ──────────────────────────────────────────
156//
157// Hand-rolled (no serde in the runtime — footprint). BSON is little-endian,
158// length-prefixed, self-delimiting; because every document needs its total
159// size up front and `dyn Write` isn't seekable, bson is built into a `Vec<u8>`
160// and flushed in one `write_all`. This is the non-streaming path by design
161// (`can_stream() == false`).
162//
163// Envelope as a bson document, field order = insertion order (bson preserves
164// it):
165//     { count: int32, exhausted: bool, output?: string,
166//       solutions: [ { <var>: BinData(0x00, <TermBuf bytes>), ... }, ... ] }
167//
168// Scalars map to native bson types; term values are opaque `BinData(0x00)` (a
169// caller speaking bson to a patch-prolog binary has opted into this engine's
170// cell ABI). See `serialize_termbuf` for the payload.
171
172/// BinData(0x00) payload layout for a term (the TermBuf cell format, framed):
173///     byte 0      format version (0x01)
174///     bytes 1..5  cell count  (u32 LE)
175///     bytes 5..13 root word   (u64 LE — a tagged cell word; an immediate for
176///                              scalar terms, a buffer index for structured)
177///     bytes 13..  cells, each u64 LE (the `plg-shared::cell` ABI, verbatim)
178///
179/// `root` is a full tagged word, not a bare index: when the term is a scalar
180/// (atom/integer), `copy_to_buf` returns `cells == []` and `root` carries the
181/// value itself. A decoder rebuilds `TermBuf { cells, root }` and either
182/// `restore_from_buf`s it or walks it with the cell ABI.
183fn serialize_termbuf(tb: &TermBuf) -> Vec<u8> {
184    let mut out = Vec::with_capacity(13 + tb.cells.len() * 8);
185    out.push(0x01); // version
186    out.extend_from_slice(&(tb.cells.len() as u32).to_le_bytes());
187    out.extend_from_slice(&tb.root.to_le_bytes());
188    for c in &tb.cells {
189        out.extend_from_slice(&c.to_le_bytes());
190    }
191    out
192}
193
194// BSON element type bytes.
195const T_STRING: u8 = 0x02;
196const T_DOCUMENT: u8 = 0x03;
197const T_ARRAY: u8 = 0x04;
198const T_BINARY: u8 = 0x05;
199const T_BOOL: u8 = 0x08;
200const T_INT32: u8 = 0x10;
201const T_INT64: u8 = 0x12;
202
203fn bson_cstring(buf: &mut Vec<u8>, s: &str) {
204    buf.extend_from_slice(s.as_bytes());
205    buf.push(0x00);
206}
207
208fn bson_doc_begin(buf: &mut Vec<u8>) -> usize {
209    let start = buf.len();
210    buf.extend_from_slice(&[0; 4]); // length placeholder
211    start
212}
213
214fn bson_doc_end(buf: &mut Vec<u8>, start: usize) {
215    buf.push(0x00); // null terminator
216    let len = i32::try_from(buf.len() - start).expect("bson doc < 2GB");
217    buf[start..start + 4].copy_from_slice(&len.to_le_bytes());
218}
219
220/// Emit the `atoms` array element (id = index → name string) into `buf`.
221/// Shared by the envelope's inline map and the standalone `--atoms` map.
222fn bson_atoms_array(buf: &mut Vec<u8>, names: &[String]) {
223    buf.push(T_ARRAY);
224    bson_cstring(buf, "atoms");
225    let arr = bson_doc_begin(buf);
226    for (i, name) in names.iter().enumerate() {
227        buf.push(T_STRING);
228        bson_cstring(buf, &i.to_string());
229        let len = i32::try_from(name.len() + 1).expect("atom name < 2GB");
230        buf.extend_from_slice(&len.to_le_bytes());
231        buf.extend_from_slice(name.as_bytes());
232        buf.push(0x00);
233    }
234    bson_doc_end(buf, arr);
235}
236
237/// Standalone `--atoms` bson output: `{count: N, atoms: [...]}` — just the atom
238/// map (program atoms only; no query has run, so no query-introduced atoms).
239/// For the case where a host wants the map once and its queries won't introduce
240/// new atoms.
241pub fn write_atom_map_bson<W: Write>(w: &mut W, m: &Machine) -> io::Result<()> {
242    let names: Vec<String> = (0..m.atoms.len())
243        .map(|i| {
244            m.atoms
245                .try_resolve(i as u32)
246                .unwrap_or_default()
247                .to_string()
248        })
249        .collect();
250    let mut buf = Vec::new();
251    let doc = bson_doc_begin(&mut buf);
252    buf.push(T_INT32);
253    bson_cstring(&mut buf, "count");
254    buf.extend_from_slice(&(names.len().min(i32::MAX as usize) as i32).to_le_bytes());
255    bson_atoms_array(&mut buf, &names);
256    bson_doc_end(&mut buf, doc);
257    w.write_all(&buf)
258}
259
260/// Standalone `--atoms` text output: `id\tname` per line (program atoms only).
261pub fn write_atom_map_text<W: Write>(w: &mut W, m: &Machine) -> io::Result<()> {
262    for i in 0..m.atoms.len() {
263        let name = m.atoms.try_resolve(i as u32).unwrap_or_default();
264        writeln!(w, "{i}\t{name}")?;
265    }
266    Ok(())
267}
268
269fn bson_write_envelope(w: &mut dyn Write, _m: &Machine, e: &Envelope) -> io::Result<()> {
270    let mut buf = Vec::new();
271    let doc = bson_doc_begin(&mut buf);
272
273    buf.push(T_INT32);
274    bson_cstring(&mut buf, "count");
275    buf.extend_from_slice(&(e.count.min(i32::MAX as usize) as i32).to_le_bytes());
276
277    buf.push(T_BOOL);
278    bson_cstring(&mut buf, "exhausted");
279    buf.push(if e.exhausted { 0x01 } else { 0x00 });
280
281    if let Some(out) = e.program_output {
282        buf.push(T_STRING);
283        bson_cstring(&mut buf, "output");
284        let len = i32::try_from(out.len() + 1).expect("output string < 2GB");
285        buf.extend_from_slice(&len.to_le_bytes());
286        buf.extend_from_slice(out.as_bytes());
287        buf.push(0x00);
288    }
289
290    // Self-describing mode (`--atoms`): the atom map (id = index) so a host
291    // can resolve bson term atom ids from this same document. Post-query, so
292    // it covers query-introduced atoms too.
293    if let Some(names) = e.atoms {
294        bson_atoms_array(&mut buf, names);
295    }
296
297    buf.push(T_ARRAY);
298    bson_cstring(&mut buf, "solutions");
299    let arr = bson_doc_begin(&mut buf);
300    for (i, sol) in e.solutions.iter().enumerate() {
301        buf.push(T_DOCUMENT);
302        bson_cstring(&mut buf, &i.to_string());
303        let sdoc = bson_doc_begin(&mut buf);
304        for b in &sol.bindings {
305            // The TermBuf was snapshotted at capture time — serializing it
306            // must not touch the (long-since-rewound) machine heap (#58).
307            let payload = serialize_termbuf(&b.buf);
308            buf.push(T_BINARY);
309            bson_cstring(&mut buf, &b.name);
310            let len = i32::try_from(payload.len()).expect("termbuf < 2GB");
311            buf.extend_from_slice(&len.to_le_bytes());
312            buf.push(0x00); // subtype: generic binary
313            buf.extend_from_slice(&payload);
314        }
315        bson_doc_end(&mut buf, sdoc);
316    }
317    bson_doc_end(&mut buf, arr);
318
319    bson_doc_end(&mut buf, doc);
320    w.write_all(&buf)
321}
322
323fn bson_write_error(w: &mut dyn Write, err: &WireError) -> io::Result<()> {
324    let msg = match err {
325        WireError::Parse(m) | WireError::Runtime(m) => m,
326    };
327    let mut buf = Vec::new();
328    let doc = bson_doc_begin(&mut buf);
329    buf.push(T_STRING);
330    bson_cstring(&mut buf, "error");
331    let len = i32::try_from(msg.len() + 1).expect("error message < 2GB");
332    buf.extend_from_slice(&len.to_le_bytes());
333    buf.extend_from_slice(msg.as_bytes());
334    buf.push(0x00);
335    bson_doc_end(&mut buf, doc);
336    w.write_all(&buf)
337}
338
339const fn bson_can_stream() -> bool {
340    false
341}
342
343#[unsafe(no_mangle)]
344pub static PLG_ENC_BSON: EncoderDesc = EncoderDesc {
345    name: "bson",
346    write_envelope: bson_write_envelope,
347    write_error: bson_write_error,
348    can_stream: bson_can_stream,
349};
350
351// ── bson input: the one-field request document ──────────────────────────────
352//
353// Input is transport framing around a query string (IO.md's "honest cheat"
354// — the engine parses the inner string exactly as for argv `--query`, so there
355// is no bson-term loader). The request document is:
356//     { query: string, limit?: int32|int64 }
357// Output format is NOT carried here — it stays on argv `--format` (input and
358// output encodings are orthogonal). Unknown fields are skipped for forward-
359// compat; `query` is required and must be a string.
360
361/// A parsed bson request document. `limit` is `None` when absent.
362#[derive(Debug)]
363pub struct ParsedRequest {
364    pub query: String,
365    pub limit: Option<usize>,
366}
367
368/// Parse a bson request document from `buf`. `buf` may be longer than the
369/// document (stdin may carry trailing bytes); only the leading document
370/// (delimited by its int32 length) is consumed. Returns the `query` and an
371/// optional `limit`.
372pub fn parse_bson_request(buf: &[u8]) -> Result<ParsedRequest, String> {
373    if buf.len() < 5 {
374        return Err("bson request too short".to_string());
375    }
376    let total = i32::from_le_bytes(buf[0..4].try_into().unwrap()) as usize;
377    if total < 5 || total > buf.len() {
378        return Err(format!(
379            "bson request length mismatch: declared {total}, have {}",
380            buf.len()
381        ));
382    }
383    let body = &buf[..total];
384    let mut off = 4; // skip the length prefix
385    let end = total - 1; // last byte is the 0x00 terminator
386    let mut query = None;
387    let mut limit = None;
388    while off < end {
389        let ty = body[off];
390        off += 1;
391        let (key, after_key) = read_cstring(body, off)?;
392        off = after_key;
393        match (ty, key.as_str()) {
394            (T_STRING, "query") => {
395                let (s, next) = read_string(body, off)?;
396                query = Some(s);
397                off = next;
398            }
399            (T_INT32, "limit") => {
400                let n = read_i32(body, off)?;
401                limit = Some(n.max(0) as usize);
402                off += 4;
403            }
404            (T_INT64, "limit") => {
405                let n = read_i64(body, off)?;
406                limit = Some(n.max(0) as usize);
407                off += 8;
408            }
409            _ => {
410                off = skip_value(body, off, ty)?;
411            }
412        }
413    }
414    let query = query.ok_or_else(|| "bson request missing required 'query' string".to_string())?;
415    Ok(ParsedRequest { query, limit })
416}
417
418fn read_cstring(buf: &[u8], mut off: usize) -> Result<(String, usize), String> {
419    let end = buf[off..]
420        .iter()
421        .position(|&b| b == 0)
422        .ok_or_else(|| "bson key not null-terminated".to_string())?;
423    let s = std::str::from_utf8(&buf[off..off + end])
424        .map_err(|_| "bson key not utf-8".to_string())?
425        .to_string();
426    off += end + 1;
427    Ok((s, off))
428}
429
430fn read_string(buf: &[u8], off: usize) -> Result<(String, usize), String> {
431    let n = read_i32(buf, off)? as usize;
432    if n == 0 || off + 4 + n > buf.len() {
433        return Err("bson string length out of range".to_string());
434    }
435    let s = std::str::from_utf8(&buf[off + 4..off + 4 + n - 1])
436        .map_err(|_| "bson string not utf-8".to_string())?
437        .to_string();
438    Ok((s, off + 4 + n))
439}
440
441fn read_i32(buf: &[u8], off: usize) -> Result<i32, String> {
442    buf[off..]
443        .get(..4)
444        .map(|b| i32::from_le_bytes(b.try_into().unwrap()))
445        .ok_or_else(|| "bson int32 truncated".to_string())
446}
447
448fn read_i64(buf: &[u8], off: usize) -> Result<i64, String> {
449    buf[off..]
450        .get(..8)
451        .map(|b| i64::from_le_bytes(b.try_into().unwrap()))
452        .ok_or_else(|| "bson int64 truncated".to_string())
453}
454
455/// Advance past a value of known bson type, returning the new offset.
456fn skip_value(buf: &[u8], off: usize, ty: u8) -> Result<usize, String> {
457    match ty {
458        0x01 => Ok(off + 8),                      // double
459        T_STRING => Ok(read_string(buf, off)?.1), // string
460        T_DOCUMENT | T_ARRAY => {
461            let n = read_i32(buf, off)? as usize;
462            Ok(off + n)
463        }
464        T_BINARY => {
465            let n = read_i32(buf, off)? as usize;
466            Ok(off + 4 + 1 + n)
467        }
468        T_BOOL => Ok(off + 1),
469        0x0A => Ok(off), // null
470        T_INT32 => Ok(off + 4),
471        T_INT64 => Ok(off + 8),
472        _ => Err(format!("bson: cannot skip unknown element type {ty:#x}")),
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use crate::cell::{TAG_LST, TAG_STR, make, make_atom, make_int, pack_functor, payload, tag_of};
480    use plg_shared::StringInterner;
481    use plg_shared::atom::ATOM_NIL;
482
483    fn machine() -> Box<Machine> {
484        Machine::new(StringInterner::new(), Vec::new())
485    }
486
487    fn bytes(f: impl FnOnce(&mut Vec<u8>) -> io::Result<()>) -> Vec<u8> {
488        let mut buf = Vec::new();
489        f(&mut buf).unwrap();
490        buf
491    }
492
493    // ── text ────────────────────────────────────────────────────────────────
494
495    fn env_with(bindings: Vec<(&str, &str)>) -> Vec<RenderedSolution> {
496        bindings
497            .into_iter()
498            .map(|(n, t)| RenderedSolution {
499                bindings: vec![crate::render::Binding {
500                    name: n.to_string(),
501                    text: t.to_string(),
502                    buf: TermBuf {
503                        cells: Vec::new(),
504                        root: make_atom(0),
505                    },
506                }],
507            })
508            .collect()
509    }
510
511    #[test]
512    fn text_empty_is_false() {
513        let e = Envelope {
514            count: 0,
515            exhausted: true,
516            solutions: &[],
517            program_output: None,
518            atoms: None,
519        };
520        assert_eq!(
521            String::from_utf8(bytes(|w| (PLG_ENC_TEXT.write_envelope)(w, &machine(), &e))).unwrap(),
522            "false.\n"
523        );
524    }
525
526    #[test]
527    fn text_renders_bindings_and_true() {
528        let sols = env_with(vec![("X", "auth")]);
529        let empty_sols = vec![RenderedSolution { bindings: vec![] }];
530        // A bound solution renders `X = auth`; a binding-less one renders `true.`.
531        let e1 = Envelope {
532            count: 1,
533            exhausted: false,
534            solutions: &sols,
535            program_output: None,
536            atoms: None,
537        };
538        assert_eq!(
539            String::from_utf8(bytes(|w| (PLG_ENC_TEXT.write_envelope)(w, &machine(), &e1)))
540                .unwrap(),
541            "X = auth\n"
542        );
543        let e2 = Envelope {
544            count: 1,
545            exhausted: true,
546            solutions: &empty_sols,
547            program_output: None,
548            atoms: None,
549        };
550        assert_eq!(
551            String::from_utf8(bytes(|w| (PLG_ENC_TEXT.write_envelope)(w, &machine(), &e2)))
552                .unwrap(),
553            "true.\n"
554        );
555    }
556
557    #[test]
558    fn descriptors_named_and_streaming() {
559        assert_eq!(PLG_ENC_TEXT.name, "text");
560        assert_eq!(PLG_ENC_BSON.name, "bson");
561        assert!((PLG_ENC_TEXT.can_stream)());
562        assert!(!(PLG_ENC_BSON.can_stream)());
563    }
564
565    #[test]
566    fn find_locates_advertised_encoders() {
567        let caps: [*const EncoderDesc; 2] = [&PLG_ENC_TEXT, &PLG_ENC_BSON];
568        assert_eq!(
569            unsafe { EncoderDesc::find(caps.as_ptr(), 2, "text") }
570                .unwrap()
571                .name,
572            "text"
573        );
574        assert_eq!(
575            unsafe { EncoderDesc::find(caps.as_ptr(), 2, "bson") }
576                .unwrap()
577                .name,
578            "bson"
579        );
580        assert!(unsafe { EncoderDesc::find(caps.as_ptr(), 2, "json") }.is_none());
581    }
582
583    #[test]
584    fn find_omitted_encoder_is_none() {
585        let caps: [*const EncoderDesc; 1] = [&PLG_ENC_TEXT];
586        assert!(unsafe { EncoderDesc::find(caps.as_ptr(), 1, "bson") }.is_none());
587    }
588
589    // ── bson structure ──────────────────────────────────────────────────────
590
591    fn bson_doc_len(buf: &[u8]) -> i32 {
592        i32::from_le_bytes(buf[0..4].try_into().unwrap())
593    }
594
595    fn assert_valid_bson_doc(buf: &[u8]) {
596        assert_eq!(
597            bson_doc_len(buf) as usize,
598            buf.len(),
599            "bson doc self-delimits"
600        );
601        assert_eq!(
602            *buf.last().unwrap(),
603            0x00,
604            "bson doc ends in null terminator"
605        );
606    }
607
608    #[test]
609    fn bson_empty_envelope_self_delimits() {
610        let m = machine();
611        let e = Envelope {
612            count: 0,
613            exhausted: true,
614            solutions: &[],
615            program_output: None,
616            atoms: None,
617        };
618        let buf = bytes(|w| (PLG_ENC_BSON.write_envelope)(w, &m, &e));
619        assert_valid_bson_doc(&buf);
620        assert!(contains_key(&buf, b"count"));
621        assert!(contains_key(&buf, b"exhausted"));
622        assert!(contains_key(&buf, b"solutions"));
623    }
624
625    #[test]
626    fn bson_error_document_valid() {
627        let buf = bytes(|w| (PLG_ENC_BSON.write_error)(w, &WireError::Runtime("boom".into())));
628        assert_valid_bson_doc(&buf);
629        assert!(contains_key(&buf, b"error"));
630    }
631
632    fn contains_key(buf: &[u8], key: &[u8]) -> bool {
633        let mut needle = key.to_vec();
634        needle.push(0x00);
635        buf.windows(needle.len()).any(|w| w == needle.as_slice())
636    }
637
638    // ── TermBuf framing round-trip (the losslessness claim) ─────────────────
639
640    fn deserialize_termbuf(data: &[u8]) -> TermBuf {
641        assert_eq!(data[0], 0x01, "format version");
642        let n = u32::from_le_bytes(data[1..5].try_into().unwrap()) as usize;
643        let root = u64::from_le_bytes(data[5..13].try_into().unwrap());
644        let mut cells = Vec::with_capacity(n);
645        for i in 0..n {
646            let off = 13 + i * 8;
647            cells.push(u64::from_le_bytes(data[off..off + 8].try_into().unwrap()));
648        }
649        TermBuf { cells, root }
650    }
651
652    #[test]
653    fn termbuf_framing_roundtrips_scalar_and_cycle() {
654        let m = machine();
655        let a = make_atom(7);
656        let tb = crate::copyterm::copy_to_buf(&m, a);
657        assert!(tb.cells.is_empty());
658        let rt = deserialize_termbuf(&serialize_termbuf(&tb));
659        assert_eq!(rt.root, a);
660
661        // X = f(X): bson must round-trip the cycle (text cuts it to f(_N)).
662        let mut m = machine();
663        let x = m.new_var();
664        let s = {
665            let i = m.heap.len();
666            m.heap.push(pack_functor(3, 1));
667            m.heap.push(x);
668            make(TAG_STR, i as u64)
669        };
670        m.bind(payload(x) as usize, s);
671        let tb = crate::copyterm::copy_to_buf(&m, s);
672        let rt = deserialize_termbuf(&serialize_termbuf(&tb));
673        let restored = crate::copyterm::restore_from_buf(&mut m, &rt);
674        assert_eq!(tag_of(restored), TAG_STR);
675        let ri = payload(restored) as usize;
676        assert_eq!(
677            m.deref(m.heap[ri + 1]),
678            restored,
679            "f(X) arg is the term itself"
680        );
681    }
682
683    /// #58: multi-solution bson output must not alias cell-backed bindings
684    /// to the last solution's value. Solutions used to capture raw heap
685    /// words which the encoder walked AFTER solving — when backtracking
686    /// had already rewound and reused the arena — so every solution
687    /// rendered the last one's list. Snapshots are taken at capture time.
688    #[test]
689    fn bson_solutions_do_not_alias_across_heap_reuse() {
690        // Solution 1: X = [1, 2].
691        let mut m = machine();
692        let x = m.new_var();
693        m.query_vars.push(("X".to_string(), payload(x) as usize));
694        let build = |m: &mut Machine, a: i64, b: i64| {
695            let inner = {
696                let i = m.heap.len();
697                m.heap.push(make_int(b));
698                m.heap.push(make_atom(ATOM_NIL));
699                make(TAG_LST, i as u64)
700            };
701            let i = m.heap.len();
702            m.heap.push(make_int(a));
703            m.heap.push(inner);
704            make(TAG_LST, i as u64)
705        };
706        let mark = m.heap.len();
707        let l12 = build(&mut m, 1, 2);
708        m.bind(payload(x) as usize, l12);
709        let sol1 = crate::render::capture_solution(&m);
710
711        // Backtracking rewinds the heap (and the trail unbinds X); the next
712        // solution's list lands on the SAME cells sol1's binding used.
713        m.heap.truncate(mark);
714        m.heap[payload(x) as usize] = x; // trail rewind: X is unbound again
715        let l34 = build(&mut m, 3, 4);
716        m.bind(payload(x) as usize, l34);
717        let sol2 = crate::render::capture_solution(&m);
718
719        let sols = [sol1, sol2];
720        let e = Envelope {
721            count: 2,
722            exhausted: true,
723            solutions: &sols,
724            program_output: None,
725            atoms: None,
726        };
727        let buf = bytes(|w| (PLG_ENC_BSON.write_envelope)(w, &machine(), &e));
728
729        // Extract each solution's `X` BinData payload (T_BINARY, "X\0",
730        // i32 len, subtype byte, payload) and decode it back to a term.
731        let mut payloads: Vec<Vec<u8>> = Vec::new();
732        for i in 0..buf.len().saturating_sub(8) {
733            if buf[i] == T_BINARY && buf[i + 1..].starts_with(b"X\0") {
734                let len = i32::from_le_bytes(buf[i + 3..i + 7].try_into().unwrap()) as usize;
735                payloads.push(buf[i + 8..i + 8 + len].to_vec());
736            }
737        }
738        assert_eq!(payloads.len(), 2, "one X binding per solution");
739
740        let mut m2 = machine();
741        let render = |m2: &mut Machine, p: &[u8]| {
742            let tb = deserialize_termbuf(p);
743            let w = crate::copyterm::restore_from_buf(m2, &tb);
744            crate::render::term_to_string(m2, w)
745        };
746        assert_eq!(render(&mut m2, &payloads[0]), "[1, 2]");
747        assert_eq!(render(&mut m2, &payloads[1]), "[3, 4]");
748    }
749
750    // ── bson input parsing ─────────────────────────────────────────────────
751
752    fn req_doc(fields: &[(u8, &str, &[u8])]) -> Vec<u8> {
753        let mut buf = Vec::new();
754        let start = bson_doc_begin(&mut buf);
755        for (ty, key, val) in fields {
756            buf.push(*ty);
757            bson_cstring(&mut buf, key);
758            buf.extend_from_slice(val);
759        }
760        bson_doc_end(&mut buf, start);
761        buf
762    }
763    fn bson_int32(n: i32) -> Vec<u8> {
764        n.to_le_bytes().to_vec()
765    }
766    fn bson_str(s: &str) -> Vec<u8> {
767        let mut v = (s.len() as i32 + 1).to_le_bytes().to_vec();
768        v.extend_from_slice(s.as_bytes());
769        v.push(0x00);
770        v
771    }
772
773    #[test]
774    fn parses_query_and_int32_limit() {
775        let doc = req_doc(&[
776            (T_STRING, "query", &bson_str("p(X)")),
777            (T_INT32, "limit", &bson_int32(5)),
778        ]);
779        let r = parse_bson_request(&doc).unwrap();
780        assert_eq!(r.query, "p(X)");
781        assert_eq!(r.limit, Some(5));
782    }
783
784    #[test]
785    fn ignores_unknown_fields() {
786        let doc = req_doc(&[
787            (T_STRING, "caller", &bson_str("x")),
788            (T_STRING, "query", &bson_str("ok")),
789        ]);
790        assert_eq!(parse_bson_request(&doc).unwrap().query, "ok");
791    }
792
793    #[test]
794    fn missing_query_is_an_error() {
795        let doc = req_doc(&[(T_INT32, "limit", &bson_int32(3))]);
796        assert!(
797            parse_bson_request(&doc)
798                .unwrap_err()
799                .contains("missing required 'query'")
800        );
801    }
802
803    // keep `make_int`/`ATOM_NIL` imports used for future list tests
804    #[test]
805    fn _keep_imports_used() {
806        let _ = make_int(1);
807        let _ = ATOM_NIL;
808    }
809}