1
//! Optional rpc-backed eval path for the `nms` REPL.
2
//!
3
//! Activated by `nms --rpc-user <UUID>`. Routes user forms through an
4
//! `rpc::Session` instead of the in-process `nms::interpreter`, so the
5
//! same code path the sshd subsystem will drive becomes available as a
6
//! local debug REPL. Without `--rpc-user`, `nms` stays a pure language
7
//! sandbox — the rpc dep just sits unused.
8
//!
9
//! Each user input is auto-wrapped in a fresh `(:id N :form <form>)`
10
//! envelope before handoff; the response prints verbatim (the rpc envelope
11
//! shape is terse enough to read directly).
12

            
13
use std::sync::atomic::{AtomicU64, Ordering};
14

            
15
use anyhow::{Context, Result};
16
use rpc::{ScriptCtx, Session};
17
use tokio::runtime::{Builder, Runtime};
18
use uuid::Uuid;
19

            
20
pub struct RpcEval {
21
    session: Session,
22
    runtime: Runtime,
23
    counter: AtomicU64,
24
}
25

            
26
impl RpcEval {
27
    /// Builds an rpc-backed eval surface for `user_id`. Verifies a
28
    /// `DATABASE_URL` is set up-front so a missing-env footgun surfaces as
29
    /// a startup error rather than a per-form connection panic from deep
30
    /// inside the first DB-touching native fn.
31
    pub fn new(user_id: Uuid) -> Result<Self> {
32
        if std::env::var("DATABASE_URL").is_err() {
33
            anyhow::bail!(
34
                "rpc-mode requires DATABASE_URL to be set so server::command::* \
35
                 can reach Postgres; export it (or drop --rpc-user for the \
36
                 sandbox-only REPL)"
37
            );
38
        }
39
        let runtime = Builder::new_current_thread()
40
            .enable_all()
41
            .build()
42
            .context("rpc-mode tokio runtime build failed")?;
43
        let session = Session::new(ScriptCtx::new(user_id))
44
            .map_err(|err| anyhow::anyhow!("rpc::Session::new failed: {err:?}"))?;
45
        Ok(Self {
46
            session,
47
            runtime,
48
            counter: AtomicU64::new(1),
49
        })
50
    }
51

            
52
    /// Wraps `form_text` in a fresh envelope, runs it through the Session,
53
    /// and returns the wire response. Multi-form input (whitespace-
54
    /// separated top-level forms) is dispatched as one envelope per form
55
    /// in source order; responses are joined by newline.
56
    pub fn eval(&mut self, form_text: &str) -> String {
57
        let trimmed = form_text.trim();
58
        if trimmed.is_empty() {
59
            return String::new();
60
        }
61
        let id = self.counter.fetch_add(1, Ordering::Relaxed);
62
        // Closing paren on its own line so a trailing line-comment in the
63
        // form can't comment out the envelope terminator (which would make
64
        // `handle_form` reject an unbalanced request).
65
        let envelope = format!("(:id {id} :form {trimmed}\n)");
66
        self.runtime.block_on(self.session.handle_form(&envelope))
67
    }
68
}