omni_dev/daemon/
client.rs1use std::path::{Path, PathBuf};
6
7use anyhow::{bail, Context, Result};
8use futures::{SinkExt, StreamExt};
9use tokio::net::UnixStream;
10use tokio_util::codec::{Framed, LinesCodec};
11
12use super::protocol::{DaemonEnvelope, DaemonReply, StatusReport, MAX_LINE_BYTES};
13
14#[derive(Debug, Clone)]
18pub struct DaemonClient {
19 socket_path: PathBuf,
20}
21
22impl DaemonClient {
23 pub fn new(socket_path: impl Into<PathBuf>) -> Self {
25 Self {
26 socket_path: socket_path.into(),
27 }
28 }
29
30 pub fn socket_path(&self) -> &Path {
32 &self.socket_path
33 }
34
35 pub async fn request(&self, envelope: DaemonEnvelope) -> Result<DaemonReply> {
37 let stream = UnixStream::connect(&self.socket_path)
38 .await
39 .with_context(|| {
40 format!(
41 "failed to connect to daemon socket {} (is the daemon running?)",
42 self.socket_path.display()
43 )
44 })?;
45 let mut framed = Framed::new(stream, LinesCodec::new_with_max_length(MAX_LINE_BYTES));
46 let line = serde_json::to_string(&envelope).context("failed to encode daemon request")?;
47 framed
48 .send(line)
49 .await
50 .context("failed to send daemon request")?;
51 let response = framed
52 .next()
53 .await
54 .context("daemon closed the connection without replying")?
55 .context("failed to read daemon reply")?;
56 serde_json::from_str(&response).context("failed to decode daemon reply")
57 }
58
59 pub async fn subscribe(&self, envelope: DaemonEnvelope) -> Result<DaemonSubscription> {
66 let stream = UnixStream::connect(&self.socket_path)
67 .await
68 .with_context(|| {
69 format!(
70 "failed to connect to daemon socket {} (is the daemon running?)",
71 self.socket_path.display()
72 )
73 })?;
74 let mut framed = Framed::new(stream, LinesCodec::new_with_max_length(MAX_LINE_BYTES));
75 let line = serde_json::to_string(&envelope).context("failed to encode daemon request")?;
76 framed
77 .send(line)
78 .await
79 .context("failed to send daemon subscription request")?;
80 Ok(DaemonSubscription { framed })
81 }
82
83 async fn request_ok(&self, envelope: DaemonEnvelope) -> Result<serde_json::Value> {
86 let reply = self.request(envelope).await?;
87 if reply.ok {
88 Ok(reply.payload)
89 } else {
90 bail!(
91 "daemon returned an error: {}",
92 reply.error.as_deref().unwrap_or("unknown error")
93 )
94 }
95 }
96
97 pub async fn ping(&self) -> Result<()> {
99 self.request_ok(DaemonEnvelope::builtin("ping"))
100 .await
101 .map(|_| ())
102 }
103
104 pub async fn version(&self) -> Result<Option<String>> {
110 let payload = self.request_ok(DaemonEnvelope::builtin("ping")).await?;
111 Ok(payload
112 .get("version")
113 .and_then(serde_json::Value::as_str)
114 .map(str::to_string))
115 }
116
117 pub async fn status(&self) -> Result<StatusReport> {
119 let payload = self.request_ok(DaemonEnvelope::builtin("status")).await?;
120 serde_json::from_value(payload).context("failed to decode daemon status report")
121 }
122
123 pub async fn shutdown(&self) -> Result<()> {
125 self.request_ok(DaemonEnvelope::builtin("shutdown"))
126 .await
127 .map(|_| ())
128 }
129}
130
131#[derive(Debug)]
138pub struct DaemonSubscription {
139 framed: Framed<UnixStream, LinesCodec>,
140}
141
142impl DaemonSubscription {
143 pub async fn next(&mut self) -> Option<Result<DaemonReply>> {
146 match self.framed.next().await {
147 Some(Ok(line)) => {
148 Some(serde_json::from_str(&line).context("failed to decode daemon stream frame"))
149 }
150 Some(Err(e)) => Some(Err(
151 anyhow::Error::new(e).context("failed to read daemon stream")
152 )),
153 None => None,
154 }
155 }
156}
157
158#[cfg(test)]
159#[allow(clippy::unwrap_used, clippy::expect_used)]
160mod tests {
161 use super::*;
162 use crate::daemon::testutil::{fake_daemon_reply, fake_daemon_stream};
163
164 #[tokio::test]
165 async fn version_reads_the_version_from_a_ping_reply() {
166 let (_dir, sock, server) = fake_daemon_reply(
167 serde_json::json!({ "ok": true, "payload": { "pong": true, "version": "1.2.3" } }),
168 );
169 let version = DaemonClient::new(&sock).version().await.unwrap();
170 assert_eq!(version.as_deref(), Some("1.2.3"));
171 server.await.unwrap();
172 }
173
174 #[tokio::test]
175 async fn version_is_none_for_a_pre_1113_daemon_without_a_version_field() {
176 let (_dir, sock, server) =
179 fake_daemon_reply(serde_json::json!({ "ok": true, "payload": { "pong": true } }));
180 let version = DaemonClient::new(&sock).version().await.unwrap();
181 assert!(version.is_none());
182 server.await.unwrap();
183 }
184
185 #[tokio::test]
186 async fn request_ok_maps_an_error_reply_to_an_err() {
187 let (_dir, sock, server) =
188 fake_daemon_reply(serde_json::json!({ "ok": false, "error": "boom" }));
189 let err = DaemonClient::new(&sock).version().await.unwrap_err();
190 assert!(err.to_string().contains("boom"), "{err}");
191 server.await.unwrap();
192 }
193
194 #[tokio::test]
195 async fn subscribe_yields_each_pushed_frame_then_none_on_close() {
196 let (_dir, sock, server) = fake_daemon_stream(vec![
199 serde_json::json!({ "ok": true, "payload": { "repos": [], "show_closed": true } }),
200 serde_json::json!({ "ok": true, "payload": { "repos": [], "show_closed": false } }),
201 ]);
202 let mut sub = DaemonClient::new(&sock)
203 .subscribe(DaemonEnvelope::service(
204 "worktrees",
205 "subscribe",
206 serde_json::Value::Null,
207 ))
208 .await
209 .unwrap();
210
211 let first = sub.next().await.unwrap().unwrap();
212 assert!(first.ok);
213 assert_eq!(first.payload["show_closed"], serde_json::json!(true));
214 let second = sub.next().await.unwrap().unwrap();
215 assert_eq!(second.payload["show_closed"], serde_json::json!(false));
216 assert!(sub.next().await.is_none());
218 server.await.unwrap();
219 }
220
221 #[tokio::test]
222 async fn subscribe_next_surfaces_a_read_error_on_an_oversized_frame() {
223 let big = "x".repeat(crate::daemon::protocol::MAX_LINE_BYTES + 1);
226 let (_dir, sock, server) = fake_daemon_stream(vec![serde_json::Value::String(big)]);
227 let mut sub = DaemonClient::new(&sock)
228 .subscribe(DaemonEnvelope::service(
229 "worktrees",
230 "subscribe",
231 serde_json::Value::Null,
232 ))
233 .await
234 .unwrap();
235 let frame = sub.next().await;
236 assert!(
237 matches!(frame, Some(Err(_))),
238 "an oversized frame should surface as a read error"
239 );
240 server.await.unwrap();
241 }
242
243 #[tokio::test]
244 async fn subscribe_errors_when_the_daemon_is_unreachable() {
245 let err = DaemonClient::new("/nonexistent/omni-dev-sub.sock")
247 .subscribe(DaemonEnvelope::service(
248 "worktrees",
249 "subscribe",
250 serde_json::Value::Null,
251 ))
252 .await
253 .unwrap_err();
254 assert!(err.to_string().contains("failed to connect"), "{err}");
255 }
256}