Skip to main content

plg_runtime/
core.rs

1//! I/O-free query core: parse + solve + the v1 JSON wire shape, with no
2//! commitment to *where* the bytes go. Both the WASI/CLI shell (`entry.rs`,
3//! sink = stdout) and the Tier-2 reactor (`reactor.rs`, sink = a linear-memory
4//! buffer) call into here, so the JSON shape and the `exhausted` rule have a
5//! single source and can't drift between the two transports
6//! (docs/design/done/WASM_TIER2_PLAN.md A1 / WASM.md finding #6). The shared core
7//! INVOCATION.md's resident mode also wants is the same one.
8
9use crate::machine::Machine;
10use crate::{query, render, solve};
11use std::io::{self, Write};
12
13/// Outcome of running one query, with the prefixed message the v1 contract
14/// puts on the wire for the two failure classes. The caller maps these to its
15/// own surface — exit codes 2/3 for the CLI, an `{"error":...}` object for the
16/// reactor — but the message bytes are produced once, here.
17pub enum QueryResult {
18    /// Solved; solutions live in `m.solutions`.
19    Solutions,
20    /// Query failed to parse — `"Parse error: …"` (CLI exit 2).
21    ParseError(String),
22    /// A runtime error was raised — `"Runtime error: …"` (CLI exit 3).
23    RuntimeError(String),
24}
25
26/// Parse `q` against the program in `m`, then solve it. The caller must have
27/// already reset per-query state and set the per-query limits; this consumes
28/// `m.error` on the error path so the message can be returned.
29pub fn run_query(m: &mut Machine, q: &str) -> QueryResult {
30    let goal = match query::parse_query(m, q) {
31        Ok(g) => g,
32        Err(e) => return QueryResult::ParseError(format!("Parse error: {e}")),
33    };
34    match solve::solve(m, goal) {
35        solve::Outcome::Error => {
36            let msg = m.error.take().map(|e| e.message).unwrap_or_default();
37            QueryResult::RuntimeError(format!("Runtime error: {msg}"))
38        }
39        solve::Outcome::Done => QueryResult::Solutions,
40    }
41}
42
43/// The v1 `exhausted` flag: the search ran to completion unless a `--limit`
44/// stopped it exactly at the cap. Single-sourced so the CLI and the reactor
45/// compute it identically (finding #4 — the spike hard-coded `true`).
46pub fn exhausted(m: &Machine) -> bool {
47    m.solution_limit.is_none_or(|l| m.solutions.len() < l)
48}
49
50/// v1 error object: `{"error":"<escaped message>"}`. No trailing newline —
51/// the CLI appends one for stdout, the reactor returns the bytes as-is.
52pub fn write_error_json<W: Write>(w: &mut W, message: &str) -> io::Result<()> {
53    write!(w, "{{\"error\":\"{}\"}}", render::json_escape(message))
54}
55
56/// v1 success object: `{"count":N,"exhausted":B,"solutions":[…]}`, keys in
57/// serde_json sorted order. `output`, when `Some`, inserts an `"output"` field
58/// (sorts between `exhausted` and `solutions`) carrying captured `write/1`
59/// bytes — the reactor uses it (no stdout in an isolate, D4); the CLI passes
60/// `None` because its output already streamed to stdout, keeping native bytes
61/// byte-identical to v1.
62pub fn write_solutions_json<W: Write>(
63    w: &mut W,
64    m: &Machine,
65    exhausted: bool,
66    output: Option<&str>,
67) -> io::Result<()> {
68    write!(
69        w,
70        "{{\"count\":{},\"exhausted\":{}",
71        m.solutions.len(),
72        exhausted
73    )?;
74    if let Some(out) = output {
75        write!(w, ",\"output\":\"{}\"", render::json_escape(out))?;
76    }
77    w.write_all(b",\"solutions\":[")?;
78    for (i, sol) in m.solutions.iter().enumerate() {
79        if i > 0 {
80            w.write_all(b",")?;
81        }
82        w.write_all(b"{")?;
83        for (j, (name, json, _)) in sol.bindings.iter().enumerate() {
84            if j > 0 {
85                w.write_all(b",")?;
86            }
87            write!(w, "\"{}\":{}", render::json_escape(name), json)?;
88        }
89        w.write_all(b"}")?;
90    }
91    w.write_all(b"]}")
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use plg_shared::StringInterner;
98
99    fn machine() -> Box<Machine> {
100        Machine::new(StringInterner::new(), Vec::new())
101    }
102
103    fn bytes(f: impl FnOnce(&mut Vec<u8>) -> io::Result<()>) -> String {
104        let mut buf = Vec::new();
105        f(&mut buf).unwrap();
106        String::from_utf8(buf).unwrap()
107    }
108
109    #[test]
110    fn empty_success_matches_v1_shape() {
111        let m = machine();
112        assert_eq!(
113            bytes(|w| write_solutions_json(w, &m, true, None)),
114            "{\"count\":0,\"exhausted\":true,\"solutions\":[]}"
115        );
116    }
117
118    #[test]
119    fn output_field_sorts_between_exhausted_and_solutions() {
120        let m = machine();
121        assert_eq!(
122            bytes(|w| write_solutions_json(w, &m, false, Some("hi\n"))),
123            "{\"count\":0,\"exhausted\":false,\"output\":\"hi\\n\",\"solutions\":[]}"
124        );
125    }
126
127    #[test]
128    fn error_object_is_escaped() {
129        assert_eq!(
130            bytes(|w| write_error_json(w, "a\"b")),
131            "{\"error\":\"a\\\"b\"}"
132        );
133    }
134
135    #[test]
136    fn exhausted_follows_the_limit() {
137        let mut m = machine();
138        assert!(exhausted(&m), "no limit => exhausted");
139        m.solution_limit = Some(2);
140        assert!(exhausted(&m), "under the limit => exhausted");
141        m.solutions
142            .push(render::RenderedSolution { bindings: vec![] });
143        m.solutions
144            .push(render::RenderedSolution { bindings: vec![] });
145        assert!(!exhausted(&m), "limit hit exactly => not exhausted");
146    }
147}