crates/signaltty-cli/src/client.rs

//! JSONL Unix-socket client. One request → one response; server events
//! on a non-subscribed connection are skipped by `call`.

use serde_json::{json, Value};
use thiserror::Error;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf};
use tokio::net::UnixStream;

use signaltty_proto::{Request, Response};

#[derive(Debug, Error)]
pub enum CliError {
    #[error("cannot connect to server at {0}: {1}")]
    Connect(String, String),
    #[error("connection lost: {0}")]
    Io(String),
    #[error("server error {code}: {message}")]
    Server { code: String, message: String },
    #[error("{0}")]
    Usage(String),
}

pub struct Client {
    reader: BufReader<OwnedReadHalf>,
    writer: OwnedWriteHalf,
    next_id: u64,
}

impl Client {
    pub async fn connect(socket: &std::path::Path) -> Result<Client, CliError> {
        let stream = UnixStream::connect(socket)
            .await
            .map_err(|e| CliError::Connect(socket.display().to_string(), e.to_string()))?;
        let (read_half, writer) = stream.into_split();
        Ok(Client {
            reader: BufReader::new(read_half),
            writer,
            next_id: 1,
        })
    }

    pub async fn call(&mut self, method: &str, params: Value) -> Result<Value, CliError> {
        let id = self.next_id.to_string();
        self.next_id += 1;
        let req = Request {
            protocol: signaltty_proto::PROTOCOL.to_string(),
            id,
            method: method.to_string(),
            params: if params.is_null() { json!({}) } else { params },
        };
        let mut line = serde_json::to_string(&req).unwrap();
        line.push('\n');
        self.writer
            .write_all(line.as_bytes())
            .await
            .map_err(|e| CliError::Io(e.to_string()))?;
        loop {
            let mut buf = String::new();
            let n = self
                .reader
                .read_line(&mut buf)
                .await
                .map_err(|e| CliError::Io(e.to_string()))?;
            if n == 0 {
                return Err(CliError::Io("server closed connection".to_string()));
            }
            let v: Value =
                serde_json::from_str(buf.trim()).map_err(|e| CliError::Io(e.to_string()))?;
            if v.get("type").and_then(|t| t.as_str()) == Some("event") {
                continue; // skip stray events on call connections
            }
            let resp: Response =
                serde_json::from_value(v).map_err(|e| CliError::Io(e.to_string()))?;
            if resp.ok {
                return Ok(resp.result);
            }
            let e = resp.error.unwrap_or(signaltty_proto::ErrorBody {
                code: "INTERNAL".to_string(),
                message: "unknown error".to_string(),
                details: Value::Null,
            });
            return Err(CliError::Server {
                code: e.code,
                message: e.message,
            });
        }
    }

    pub fn into_parts(self) -> (BufReader<OwnedReadHalf>, OwnedWriteHalf) {
        (self.reader, self.writer)
    }
}