Skip to main content

scrybe_rpc/
client.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Shawn Hartsock and contributors
3
4//! Thin JSON-RPC client for talking to a running Scrybe GUI.
5//!
6//! Connects to the Unix-domain socket at `~/.scrybe/sock` (or `$SCRYBE_SOCK`),
7//! sends a single request, returns the reply's `result` value. One request per
8//! connection keeps the client trivially correct.
9//!
10//! This lives in `scrybe-rpc` (not `scrybe-cli`) so **every** client — the CLI
11//! and the MCP server — dials the live app through one shared implementation.
12//! Two divergent dialers is exactly the split this crate exists to prevent.
13//!
14//! ## Errors are typed, end-to-end (A3)
15//!
16//! Every public function returns [`ClientError`] — never a string. The one
17//! blessed way to detect "no app is running" is
18//! [`ClientError::is_not_running`]; matching on message text is forbidden.
19//! In-band application errors (the app answered and said "no") arrive as
20//! [`ClientError::Remote`]; everything else is a transport-class failure.
21//!
22//! ## Wire validation
23//!
24//! Replies are validated against the JSON-RPC 2.0 envelope before the caller
25//! sees them: `jsonrpc` must be `"2.0"`, the echoed `id` must match the
26//! request id, exactly one of `result`/`error` must be present, and the
27//! `error` member must be a well-formed error object. Frames are capped at
28//! [`MAX_FRAME_BYTES`] before any unbounded allocation. The full contract is
29//! frozen in `docs/rpc-contract-0.6.md`.
30
31use crate::{default_socket_path, JsonRpcVersion, Request, RpcError};
32use std::path::{Path, PathBuf};
33use std::time::Duration;
34
35#[cfg(unix)]
36use std::io::{BufRead, BufReader, Read as _, Write};
37#[cfg(unix)]
38use std::os::unix::net::UnixStream;
39
40/// Per-request read timeout. Reply-based commands (open/read/find/section/edit)
41/// block until the GUI frontend replies; the app's own reply timeout is 5 s, so
42/// the client waits at least that long before giving up.
43pub const READ_TIMEOUT: Duration = Duration::from_secs(5);
44
45/// Per-request write timeout. Requests are one small line; a healthy server
46/// drains them immediately, so a stalled write means a wedged peer.
47pub const WRITE_TIMEOUT: Duration = Duration::from_secs(5);
48
49/// Maximum reply frame size (one newline-terminated line, newline included):
50/// 16 MiB. Read buffers are capped at this size *before* allocation, so a
51/// runaway or malicious server cannot balloon client memory; a larger frame is
52/// a typed [`ClientError::FrameTooLarge`]. Part of the frozen 0.6 contract
53/// (`docs/rpc-contract-0.6.md`) — raising it is a contract change.
54pub const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;
55
56/// The request id used on every connection. The client opens one connection
57/// per request (see module docs), so a fixed per-connection id is correct; the
58/// echoed reply id is still checked against it (`MismatchedResponseId`).
59const REQUEST_ID: u64 = 1;
60
61// ── Typed errors ─────────────────────────────────────────────────────────────
62
63/// Why the socket could not be reached — the two "no app is running" shapes.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum UnavailableKind {
66    /// The socket file does not exist (app never started, or cleaned up).
67    NotFound,
68    /// The socket file exists but nothing is accepting (stale socket left by
69    /// a dead app).
70    ConnectionRefused,
71}
72
73/// A JSON-RPC 2.0 envelope violation in a reply. The bytes parsed as JSON but
74/// the message does not conform to the wire contract.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
76pub enum EnvelopeError {
77    /// The `jsonrpc` member is missing or is not the literal `"2.0"`.
78    #[error("missing or unsupported `jsonrpc` version (expected \"2.0\")")]
79    WrongVersion,
80    /// The `id` member is missing or not an unsigned integer.
81    #[error("missing or non-integer `id`")]
82    MissingId,
83    /// Both `result` and `error` are present — the spec allows exactly one.
84    #[error("both `result` and `error` present")]
85    BothResultAndError,
86    /// Neither `result` nor `error` is present — the spec requires one.
87    #[error("neither `result` nor `error` present")]
88    NeitherResultNorError,
89    /// The `error` member is not a well-formed JSON-RPC error object
90    /// (`{code: int, message: string, data?}`).
91    #[error("`error` member is not a valid JSON-RPC error object")]
92    InvalidErrorObject,
93}
94
95/// Everything that can go wrong talking to the live app, typed.
96///
97/// Three semantic classes, deliberately kept distinct:
98///
99/// 1. **No app** — [`SocketUnavailable`](Self::SocketUnavailable). Detect it
100///    with [`is_not_running`](Self::is_not_running), never by message text.
101/// 2. **Transport failure** — everything from
102///    [`PermissionDenied`](Self::PermissionDenied) through
103///    [`MismatchedResponseId`](Self::MismatchedResponseId): the request did
104///    not complete a valid round-trip. The app did *not* answer.
105/// 3. **Remote error** — [`Remote`](Self::Remote): the app answered with an
106///    in-band JSON-RPC error object. The transport worked; this is an
107///    application-level "no".
108#[derive(Debug, thiserror::Error)]
109pub enum ClientError {
110    /// No Scrybe app is reachable on the socket (missing file or stale
111    /// socket). This is the *only* variant [`Self::is_not_running`] matches.
112    #[error("no Scrybe running (socket {}: {kind:?})", path.display())]
113    SocketUnavailable {
114        /// The socket path that was dialed.
115        path: PathBuf,
116        /// Which "not running" shape was observed.
117        kind: UnavailableKind,
118    },
119    /// The socket exists but this process may not connect to it.
120    #[error("permission denied connecting to Scrybe socket {}", path.display())]
121    PermissionDenied {
122        /// The socket path that was dialed.
123        path: PathBuf,
124    },
125    /// Connecting did not complete in time (e.g. the app's accept queue is
126    /// wedged).
127    #[error("timed out connecting to the Scrybe socket")]
128    ConnectTimeout,
129    /// The request could not be written within [`WRITE_TIMEOUT`].
130    #[error("timed out writing request to the Scrybe socket")]
131    WriteTimeout,
132    /// No reply arrived within [`READ_TIMEOUT`].
133    #[error("timed out waiting for a reply from the Scrybe app")]
134    ReadTimeout,
135    /// Any other socket-level I/O failure (including the peer closing the
136    /// connection before or during the reply frame).
137    #[error("socket I/O error: {0}")]
138    Io(#[source] std::io::Error),
139    /// The reply frame exceeded [`MAX_FRAME_BYTES`]; reading stopped before
140    /// unbounded allocation. `bytes` is the count read before giving up.
141    #[error("reply frame too large: read {bytes} bytes, limit {limit}")]
142    FrameTooLarge {
143        /// Bytes read before the cap tripped (at least `limit + 1`).
144        bytes: usize,
145        /// The cap ([`MAX_FRAME_BYTES`]).
146        limit: usize,
147    },
148    /// The reply frame is not valid UTF-8.
149    #[error("reply is not valid UTF-8")]
150    InvalidUtf8,
151    /// The reply frame is not valid JSON.
152    #[error("reply is not valid JSON: {0}")]
153    InvalidJson(#[source] serde_json::Error),
154    /// The reply parsed as JSON but violates the JSON-RPC 2.0 envelope.
155    #[error("reply violates the JSON-RPC envelope: {0}")]
156    InvalidEnvelope(#[from] EnvelopeError),
157    /// The reply's `id` does not match the request id — the reply belongs to
158    /// some other request.
159    #[error("reply id {actual} does not match request id {expected}")]
160    MismatchedResponseId {
161        /// The id this client sent.
162        expected: u64,
163        /// The id the server echoed.
164        actual: u64,
165    },
166    /// The app answered with an in-band JSON-RPC error object. The transport
167    /// worked — this is an application-level outcome, carrying the stable
168    /// error code registry documented in `docs/rpc-contract-0.6.md`.
169    #[error("Scrybe app error {}: {}", .0.code, .0.message)]
170    Remote(RpcError),
171}
172
173impl ClientError {
174    /// `true` when no Scrybe app is running — THE one blessed way to detect
175    /// the no-app condition (socket file missing, or a stale socket refusing
176    /// connections). Callers must use this instead of matching message text.
177    pub fn is_not_running(&self) -> bool {
178        matches!(self, Self::SocketUnavailable { .. })
179    }
180}
181
182// ── Connection ───────────────────────────────────────────────────────────────
183
184/// Resolved socket path the client uses by default (used for diagnostics).
185pub fn socket_path() -> PathBuf {
186    default_socket_path()
187}
188
189/// `true` if a Scrybe GUI is reachable on the default socket right now.
190/// Cheap liveness probe used to choose the live-app path over headless.
191pub fn is_live() -> bool {
192    try_connect().is_ok()
193}
194
195/// Connect to the Scrybe socket at the default location. "Not running" is the
196/// typed [`ClientError::SocketUnavailable`] — check [`ClientError::is_not_running`].
197#[cfg(unix)]
198pub fn try_connect() -> Result<UnixStream, ClientError> {
199    try_connect_at(&default_socket_path())
200}
201
202/// Connect at an explicit socket path. Tests use this to avoid the
203/// `SCRYBE_SOCK` env-var race when running in parallel.
204#[cfg(unix)]
205pub fn try_connect_at(path: &Path) -> Result<UnixStream, ClientError> {
206    if !path.exists() {
207        return Err(ClientError::SocketUnavailable {
208            path: path.to_path_buf(),
209            kind: UnavailableKind::NotFound,
210        });
211    }
212    match UnixStream::connect(path) {
213        Ok(s) => {
214            s.set_read_timeout(Some(READ_TIMEOUT))
215                .map_err(ClientError::Io)?;
216            s.set_write_timeout(Some(WRITE_TIMEOUT))
217                .map_err(ClientError::Io)?;
218            Ok(s)
219        }
220        Err(e) => Err(match e.kind() {
221            std::io::ErrorKind::NotFound => ClientError::SocketUnavailable {
222                path: path.to_path_buf(),
223                kind: UnavailableKind::NotFound,
224            },
225            std::io::ErrorKind::ConnectionRefused => ClientError::SocketUnavailable {
226                path: path.to_path_buf(),
227                kind: UnavailableKind::ConnectionRefused,
228            },
229            std::io::ErrorKind::PermissionDenied => ClientError::PermissionDenied {
230                path: path.to_path_buf(),
231            },
232            std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => {
233                ClientError::ConnectTimeout
234            }
235            _ => ClientError::Io(e),
236        }),
237    }
238}
239
240#[cfg(not(unix))]
241pub fn try_connect() -> Result<(), ClientError> {
242    try_connect_at(Path::new("(unsupported)"))
243}
244
245#[cfg(not(unix))]
246pub fn try_connect_at(path: &Path) -> Result<(), ClientError> {
247    Err(unsupported(path))
248}
249
250/// Non-unix has no socket transport (Phase 1), so no live app is ever
251/// reachable — the honest outcome is the typed not-running condition
252/// (`is_not_running() == true`), NOT a transport engine fault: callers get
253/// the same business-level `no_live_app` / headless behavior they would on a
254/// unix host with no app running. (Windows named-pipe transport is a
255/// documented follow-up in `docs/rpc-contract-0.6.md`.)
256#[cfg(not(unix))]
257fn unsupported(path: &Path) -> ClientError {
258    ClientError::SocketUnavailable {
259        path: path.to_path_buf(),
260        kind: UnavailableKind::NotFound,
261    }
262}
263
264// ── Request/response ─────────────────────────────────────────────────────────
265
266/// Send a single request to the default socket path. On success returns the
267/// reply's `result` value; an in-band application error is
268/// [`ClientError::Remote`].
269pub fn send(method: &str, params: serde_json::Value) -> Result<serde_json::Value, ClientError> {
270    send_to(&default_socket_path(), method, params)
271}
272
273/// Send a single request to an explicit socket path. Tests use this.
274#[cfg(unix)]
275pub fn send_to(
276    socket: &Path,
277    method: &str,
278    params: serde_json::Value,
279) -> Result<serde_json::Value, ClientError> {
280    let mut stream = try_connect_at(socket)?;
281    let req = Request {
282        jsonrpc: JsonRpcVersion,
283        id: REQUEST_ID,
284        method: method.to_string(),
285        params,
286    };
287    let line = serde_json::to_string(&req)
288        .map_err(|e| ClientError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
289    writeln!(stream, "{line}").map_err(write_error)?;
290    let raw = read_frame(stream)?;
291    validate_envelope(&raw, REQUEST_ID)
292}
293
294#[cfg(not(unix))]
295pub fn send_to(
296    socket: &Path,
297    _method: &str,
298    _params: serde_json::Value,
299) -> Result<serde_json::Value, ClientError> {
300    Err(unsupported(socket))
301}
302
303/// Read one newline-terminated reply frame, enforcing [`MAX_FRAME_BYTES`]
304/// before any unbounded allocation and typing timeout/EOF failures.
305#[cfg(unix)]
306fn read_frame(stream: UnixStream) -> Result<Vec<u8>, ClientError> {
307    // `take` caps how much the reader will ever pull, so a runaway server
308    // cannot balloon `buf` past the limit (+1 to detect "too large").
309    let mut reader = BufReader::new(stream).take(MAX_FRAME_BYTES as u64 + 1);
310    let mut buf = Vec::new();
311    reader.read_until(b'\n', &mut buf).map_err(read_error)?;
312    if buf.len() > MAX_FRAME_BYTES {
313        return Err(ClientError::FrameTooLarge {
314            bytes: buf.len(),
315            limit: MAX_FRAME_BYTES,
316        });
317    }
318    if buf.is_empty() {
319        return Err(ClientError::Io(std::io::Error::new(
320            std::io::ErrorKind::UnexpectedEof,
321            "server closed connection without responding",
322        )));
323    }
324    if buf.last() != Some(&b'\n') {
325        return Err(ClientError::Io(std::io::Error::new(
326            std::io::ErrorKind::UnexpectedEof,
327            "connection closed mid-frame",
328        )));
329    }
330    Ok(buf)
331}
332
333/// Type a write-side I/O failure: timeout kinds become [`ClientError::WriteTimeout`].
334fn write_error(e: std::io::Error) -> ClientError {
335    match e.kind() {
336        std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => ClientError::WriteTimeout,
337        _ => ClientError::Io(e),
338    }
339}
340
341/// Type a read-side I/O failure: timeout kinds become [`ClientError::ReadTimeout`].
342fn read_error(e: std::io::Error) -> ClientError {
343    match e.kind() {
344        std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => ClientError::ReadTimeout,
345        _ => ClientError::Io(e),
346    }
347}
348
349/// Validate one reply frame against the JSON-RPC 2.0 envelope and unwrap it.
350///
351/// Checks, in order: UTF-8 → JSON → `jsonrpc == "2.0"` → integer `id` echoing
352/// the request id → exactly one of `result`/`error` → a well-formed `error`
353/// object. A valid `error` becomes [`ClientError::Remote`]; a valid `result`
354/// is returned.
355fn validate_envelope(frame: &[u8], expected_id: u64) -> Result<serde_json::Value, ClientError> {
356    let text = std::str::from_utf8(frame).map_err(|_| ClientError::InvalidUtf8)?;
357    let raw: serde_json::Value =
358        serde_json::from_str(text.trim_end()).map_err(ClientError::InvalidJson)?;
359    match raw.get("jsonrpc").and_then(serde_json::Value::as_str) {
360        Some("2.0") => {}
361        _ => return Err(EnvelopeError::WrongVersion.into()),
362    }
363    let actual = raw
364        .get("id")
365        .and_then(serde_json::Value::as_u64)
366        .ok_or(EnvelopeError::MissingId)?;
367    if actual != expected_id {
368        return Err(ClientError::MismatchedResponseId {
369            expected: expected_id,
370            actual,
371        });
372    }
373    match (raw.get("result"), raw.get("error")) {
374        (Some(_), Some(_)) => Err(EnvelopeError::BothResultAndError.into()),
375        (None, None) => Err(EnvelopeError::NeitherResultNorError.into()),
376        (Some(result), None) => Ok(result.clone()),
377        (None, Some(error)) => {
378            let rpc: RpcError = serde_json::from_value(error.clone())
379                .map_err(|_| EnvelopeError::InvalidErrorObject)?;
380            Err(ClientError::Remote(rpc))
381        }
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use crate::ERR_METHOD_NOT_FOUND;
389
390    #[test]
391    fn socket_path_exposed() {
392        let p = socket_path();
393        assert!(!p.as_os_str().is_empty());
394    }
395
396    #[test]
397    fn request_serializes_to_single_line() {
398        let req = Request {
399            jsonrpc: JsonRpcVersion,
400            id: 1,
401            method: "open".into(),
402            params: serde_json::json!({"path": "/tmp/foo.md"}),
403        };
404        let s = serde_json::to_string(&req).unwrap();
405        assert!(!s.contains('\n'));
406    }
407
408    #[test]
409    fn valid_result_envelope_unwraps() {
410        let frame = br#"{"jsonrpc":"2.0","id":1,"result":{"applied":true}}
411"#;
412        let v = validate_envelope(frame, 1).unwrap();
413        assert_eq!(v["applied"], true);
414    }
415
416    #[test]
417    fn remote_error_envelope_is_typed() {
418        let frame = format!(
419            "{{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{{\"code\":{ERR_METHOD_NOT_FOUND},\"message\":\"x\"}}}}\n",
420        );
421        let err = validate_envelope(frame.as_bytes(), 1).unwrap_err();
422        match err {
423            ClientError::Remote(e) => assert_eq!(e.code, ERR_METHOD_NOT_FOUND),
424            other => panic!("expected Remote, got {other:?}"),
425        }
426    }
427
428    #[test]
429    fn wrong_version_is_envelope_error() {
430        let frame = br#"{"jsonrpc":"1.0","id":1,"result":{}}"#;
431        let err = validate_envelope(frame, 1).unwrap_err();
432        assert!(matches!(
433            err,
434            ClientError::InvalidEnvelope(EnvelopeError::WrongVersion)
435        ));
436    }
437
438    #[test]
439    fn missing_version_is_envelope_error() {
440        let frame = br#"{"id":1,"result":{}}"#;
441        let err = validate_envelope(frame, 1).unwrap_err();
442        assert!(matches!(
443            err,
444            ClientError::InvalidEnvelope(EnvelopeError::WrongVersion)
445        ));
446    }
447
448    #[test]
449    fn missing_id_is_envelope_error() {
450        let frame = br#"{"jsonrpc":"2.0","result":{}}"#;
451        let err = validate_envelope(frame, 1).unwrap_err();
452        assert!(matches!(
453            err,
454            ClientError::InvalidEnvelope(EnvelopeError::MissingId)
455        ));
456    }
457
458    #[test]
459    fn mismatched_id_carries_both_ids() {
460        let frame = br#"{"jsonrpc":"2.0","id":7,"result":{}}"#;
461        let err = validate_envelope(frame, 1).unwrap_err();
462        match err {
463            ClientError::MismatchedResponseId { expected, actual } => {
464                assert_eq!(expected, 1);
465                assert_eq!(actual, 7);
466            }
467            other => panic!("expected MismatchedResponseId, got {other:?}"),
468        }
469    }
470
471    #[test]
472    fn both_result_and_error_is_envelope_error() {
473        let frame =
474            br#"{"jsonrpc":"2.0","id":1,"result":{},"error":{"code":-32000,"message":"x"}}"#;
475        let err = validate_envelope(frame, 1).unwrap_err();
476        assert!(matches!(
477            err,
478            ClientError::InvalidEnvelope(EnvelopeError::BothResultAndError)
479        ));
480    }
481
482    #[test]
483    fn neither_result_nor_error_is_envelope_error() {
484        let frame = br#"{"jsonrpc":"2.0","id":1}"#;
485        let err = validate_envelope(frame, 1).unwrap_err();
486        assert!(matches!(
487            err,
488            ClientError::InvalidEnvelope(EnvelopeError::NeitherResultNorError)
489        ));
490    }
491
492    #[test]
493    fn malformed_error_object_is_envelope_error() {
494        let frame = br#"{"jsonrpc":"2.0","id":1,"error":"boom"}"#;
495        let err = validate_envelope(frame, 1).unwrap_err();
496        assert!(matches!(
497            err,
498            ClientError::InvalidEnvelope(EnvelopeError::InvalidErrorObject)
499        ));
500    }
501
502    #[test]
503    fn invalid_utf8_is_typed() {
504        let err = validate_envelope(&[0xff, 0xfe, b'\n'], 1).unwrap_err();
505        assert!(matches!(err, ClientError::InvalidUtf8));
506    }
507
508    #[test]
509    fn invalid_json_is_typed() {
510        let err = validate_envelope(b"{not json\n", 1).unwrap_err();
511        assert!(matches!(err, ClientError::InvalidJson(_)));
512    }
513
514    #[cfg(unix)]
515    #[test]
516    fn try_connect_at_types_missing_socket_as_not_running() {
517        let path = std::path::PathBuf::from("/tmp/scrybe-nonexistent-sock-rpc-unit-test");
518        let err = try_connect_at(&path).unwrap_err();
519        assert!(err.is_not_running());
520        assert!(matches!(
521            err,
522            ClientError::SocketUnavailable {
523                kind: UnavailableKind::NotFound,
524                ..
525            }
526        ));
527    }
528
529    #[cfg(unix)]
530    #[test]
531    fn send_to_types_missing_socket_as_not_running() {
532        let path = std::path::PathBuf::from("/tmp/scrybe-nonexistent-sock-rpc-send-test");
533        let err = send_to(&path, "open", serde_json::json!({"path": "/tmp/foo.md"})).unwrap_err();
534        assert!(err.is_not_running(), "actual: {err:?}");
535    }
536
537    #[cfg(unix)]
538    #[test]
539    fn is_live_false_without_server() {
540        // No app running in the test environment → not live.
541        // (This asserts the default-socket probe doesn't panic and returns a bool.)
542        let _ = is_live();
543    }
544}