Skip to main content

scrybe_cli/
rpc_client.rs

1//! Thin JSON-RPC client to talk to the running Scrybe GUI.
2//!
3//! Connects to the Unix-domain socket at `~/.scrybe/sock` (or `$SCRYBE_SOCK`),
4//! sends a single request, returns the response. One request per connection
5//! keeps the client trivially correct.
6//!
7//! `try_connect` returns `None` for the two "GUI not running" outcomes
8//! (file missing or `connect` refused). Callers branch on that to either
9//! fall through to launch-app or silent-no-op semantics, per the design.
10
11use scrybe_rpc::{default_socket_path, JsonRpcVersion, Request, Response};
12use std::io::{BufRead, BufReader, Write};
13use std::path::{Path, PathBuf};
14use std::time::Duration;
15
16#[cfg(unix)]
17use std::os::unix::net::UnixStream;
18
19/// Per-request read timeout. Phase 1 commands all return immediately
20/// (fire-and-forget on the GUI side); 2 s is generous.
21const READ_TIMEOUT: Duration = Duration::from_secs(2);
22
23/// Resolved socket path the client uses by default (used for diagnostics).
24pub fn socket_path() -> PathBuf {
25    default_socket_path()
26}
27
28/// Try to connect to the Scrybe socket at the default location.
29/// `Ok(Some(_))` = live server, `Ok(None)` = no server running,
30/// `Err(_)` = something else went wrong (e.g. permission denied).
31#[cfg(unix)]
32pub fn try_connect() -> Result<Option<UnixStream>, String> {
33    try_connect_at(&default_socket_path())
34}
35
36/// Try to connect at an explicit socket path. Tests use this to avoid the
37/// `SCRYBE_SOCK` env-var race when running in parallel.
38#[cfg(unix)]
39pub fn try_connect_at(path: &Path) -> Result<Option<UnixStream>, String> {
40    if !path.exists() {
41        return Ok(None);
42    }
43    match UnixStream::connect(path) {
44        Ok(s) => {
45            s.set_read_timeout(Some(READ_TIMEOUT))
46                .map_err(|e| format!("set_read_timeout: {e}"))?;
47            Ok(Some(s))
48        }
49        Err(e)
50            if matches!(
51                e.kind(),
52                std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound
53            ) =>
54        {
55            Ok(None)
56        }
57        Err(e) => Err(format!("connect to {}: {e}", path.display())),
58    }
59}
60
61#[cfg(not(unix))]
62pub fn try_connect() -> Result<Option<()>, String> {
63    Ok(None)
64}
65
66#[cfg(not(unix))]
67pub fn try_connect_at(_path: &Path) -> Result<Option<()>, String> {
68    Ok(None)
69}
70
71/// Send a single request to the default socket path.
72pub fn send(method: &str, params: serde_json::Value) -> Result<Response, String> {
73    send_to(&default_socket_path(), method, params)
74}
75
76/// Send a single request to an explicit socket path. Tests use this.
77#[cfg(unix)]
78pub fn send_to(socket: &Path, method: &str, params: serde_json::Value) -> Result<Response, String> {
79    let mut stream = match try_connect_at(socket)? {
80        Some(s) => s,
81        None => return Err("no Scrybe running".to_string()),
82    };
83    let req = Request {
84        jsonrpc: JsonRpcVersion,
85        id: 1,
86        method: method.to_string(),
87        params,
88    };
89    let line = serde_json::to_string(&req).map_err(|e| format!("serialize: {e}"))?;
90    writeln!(stream, "{line}").map_err(|e| format!("write: {e}"))?;
91    let mut reader = BufReader::new(stream);
92    let mut response_line = String::new();
93    reader
94        .read_line(&mut response_line)
95        .map_err(|e| format!("read: {e}"))?;
96    if response_line.is_empty() {
97        return Err("server closed connection without responding".to_string());
98    }
99    let resp: Response = serde_json::from_str(response_line.trim_end())
100        .map_err(|e| format!("parse response: {e}"))?;
101    Ok(resp)
102}
103
104#[cfg(not(unix))]
105pub fn send_to(
106    _socket: &Path,
107    _method: &str,
108    _params: serde_json::Value,
109) -> Result<Response, String> {
110    Err("scrybe-cli RPC is unix-only in Phase 1".to_string())
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use scrybe_rpc::ERR_METHOD_NOT_FOUND;
117
118    #[test]
119    fn socket_path_exposed() {
120        let p = socket_path();
121        assert!(!p.as_os_str().is_empty());
122    }
123
124    #[test]
125    fn request_serializes_to_single_line() {
126        let req = Request {
127            jsonrpc: JsonRpcVersion,
128            id: 1,
129            method: "open".into(),
130            params: serde_json::json!({"path": "/tmp/foo.md"}),
131        };
132        let s = serde_json::to_string(&req).unwrap();
133        assert!(!s.contains('\n'));
134    }
135
136    #[test]
137    fn response_with_error_parses() {
138        let line = format!(
139            r#"{{"jsonrpc":"2.0","id":1,"error":{{"code":{ERR_METHOD_NOT_FOUND},"message":"x"}}}}"#,
140        );
141        let r: Response = serde_json::from_str(&line).unwrap();
142        assert!(r.result.is_none());
143        assert_eq!(r.error.unwrap().code, ERR_METHOD_NOT_FOUND);
144    }
145
146    #[test]
147    fn try_connect_at_returns_none_when_no_socket() {
148        let path = std::path::PathBuf::from("/tmp/scrybe-nonexistent-sock-unit-test");
149        let result = try_connect_at(&path).unwrap();
150        assert!(result.is_none());
151    }
152
153    #[test]
154    fn send_to_errors_when_no_server() {
155        let path = std::path::PathBuf::from("/tmp/scrybe-nonexistent-sock-send-unit-test");
156        let err = send_to(&path, "open", serde_json::json!({"path": "/tmp/foo.md"})).unwrap_err();
157        assert!(err.contains("no Scrybe running"));
158    }
159}