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 response. One request per connection
8//! 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//! `try_connect` returns `Ok(None)` for the two "GUI not running" outcomes
15//! (socket missing or `connect` refused). Callers branch on that to fall
16//! through to launch-app or headless semantics.
17
18use crate::{default_socket_path, JsonRpcVersion, Request, Response};
19use std::path::{Path, PathBuf};
20use std::time::Duration;
21
22#[cfg(unix)]
23use std::io::{BufRead, BufReader, Write};
24#[cfg(unix)]
25use std::os::unix::net::UnixStream;
26
27/// Per-request read timeout. Reply-based commands (open/read/find/section/edit)
28/// block until the GUI frontend replies; the app's own reply timeout is 5 s, so
29/// the client waits at least that long before giving up.
30const READ_TIMEOUT: Duration = Duration::from_secs(5);
31
32/// Resolved socket path the client uses by default (used for diagnostics).
33pub fn socket_path() -> PathBuf {
34    default_socket_path()
35}
36
37/// `true` if a Scrybe GUI is reachable on the default socket right now.
38/// Cheap liveness probe used to choose the live-app path over headless.
39pub fn is_live() -> bool {
40    matches!(try_connect(), Ok(Some(_)))
41}
42
43/// Try to connect to the Scrybe socket at the default location.
44/// `Ok(Some(_))` = live server, `Ok(None)` = no server running,
45/// `Err(_)` = something else went wrong (e.g. permission denied).
46#[cfg(unix)]
47pub fn try_connect() -> Result<Option<UnixStream>, String> {
48    try_connect_at(&default_socket_path())
49}
50
51/// Try to connect at an explicit socket path. Tests use this to avoid the
52/// `SCRYBE_SOCK` env-var race when running in parallel.
53#[cfg(unix)]
54pub fn try_connect_at(path: &Path) -> Result<Option<UnixStream>, String> {
55    if !path.exists() {
56        return Ok(None);
57    }
58    match UnixStream::connect(path) {
59        Ok(s) => {
60            s.set_read_timeout(Some(READ_TIMEOUT))
61                .map_err(|e| format!("set_read_timeout: {e}"))?;
62            Ok(Some(s))
63        }
64        Err(e)
65            if matches!(
66                e.kind(),
67                std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound
68            ) =>
69        {
70            Ok(None)
71        }
72        Err(e) => Err(format!("connect to {}: {e}", path.display())),
73    }
74}
75
76#[cfg(not(unix))]
77pub fn try_connect() -> Result<Option<()>, String> {
78    Ok(None)
79}
80
81#[cfg(not(unix))]
82pub fn try_connect_at(_path: &Path) -> Result<Option<()>, String> {
83    Ok(None)
84}
85
86/// Send a single request to the default socket path.
87pub fn send(method: &str, params: serde_json::Value) -> Result<Response, String> {
88    send_to(&default_socket_path(), method, params)
89}
90
91/// Send a single request to an explicit socket path. Tests use this.
92#[cfg(unix)]
93pub fn send_to(socket: &Path, method: &str, params: serde_json::Value) -> Result<Response, String> {
94    let mut stream = match try_connect_at(socket)? {
95        Some(s) => s,
96        None => return Err("no Scrybe running".to_string()),
97    };
98    let req = Request {
99        jsonrpc: JsonRpcVersion,
100        id: 1,
101        method: method.to_string(),
102        params,
103    };
104    let line = serde_json::to_string(&req).map_err(|e| format!("serialize: {e}"))?;
105    writeln!(stream, "{line}").map_err(|e| format!("write: {e}"))?;
106    let mut reader = BufReader::new(stream);
107    let mut response_line = String::new();
108    reader
109        .read_line(&mut response_line)
110        .map_err(|e| format!("read: {e}"))?;
111    if response_line.is_empty() {
112        return Err("server closed connection without responding".to_string());
113    }
114    let resp: Response = serde_json::from_str(response_line.trim_end())
115        .map_err(|e| format!("parse response: {e}"))?;
116    Ok(resp)
117}
118
119#[cfg(not(unix))]
120pub fn send_to(
121    _socket: &Path,
122    _method: &str,
123    _params: serde_json::Value,
124) -> Result<Response, String> {
125    Err("scrybe-rpc client is unix-only in Phase 1".to_string())
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::ERR_METHOD_NOT_FOUND;
132
133    #[test]
134    fn socket_path_exposed() {
135        let p = socket_path();
136        assert!(!p.as_os_str().is_empty());
137    }
138
139    #[test]
140    fn request_serializes_to_single_line() {
141        let req = Request {
142            jsonrpc: JsonRpcVersion,
143            id: 1,
144            method: "open".into(),
145            params: serde_json::json!({"path": "/tmp/foo.md"}),
146        };
147        let s = serde_json::to_string(&req).unwrap();
148        assert!(!s.contains('\n'));
149    }
150
151    #[test]
152    fn response_with_error_parses() {
153        let line = format!(
154            r#"{{"jsonrpc":"2.0","id":1,"error":{{"code":{ERR_METHOD_NOT_FOUND},"message":"x"}}}}"#,
155        );
156        let r: Response = serde_json::from_str(&line).unwrap();
157        assert!(r.result.is_none());
158        assert_eq!(r.error.unwrap().code, ERR_METHOD_NOT_FOUND);
159    }
160
161    #[cfg(unix)]
162    #[test]
163    fn try_connect_at_returns_none_when_no_socket() {
164        let path = std::path::PathBuf::from("/tmp/scrybe-nonexistent-sock-rpc-unit-test");
165        let result = try_connect_at(&path).unwrap();
166        assert!(result.is_none());
167    }
168
169    #[cfg(unix)]
170    #[test]
171    fn send_to_errors_when_no_server() {
172        let path = std::path::PathBuf::from("/tmp/scrybe-nonexistent-sock-rpc-send-test");
173        let err = send_to(&path, "open", serde_json::json!({"path": "/tmp/foo.md"})).unwrap_err();
174        assert!(err.contains("no Scrybe running"));
175    }
176
177    #[cfg(unix)]
178    #[test]
179    fn is_live_false_without_server() {
180        // No app running in the test environment → not live.
181        // (This asserts the default-socket probe doesn't panic and returns a bool.)
182        let _ = is_live();
183    }
184}