1mod client;
11mod queue;
12mod server;
13
14pub use client::Client;
15pub use queue::{QueueKey, SyncJob, SyncQueue};
16pub use server::Server;
17
18use serde::{Deserialize, Serialize};
19use thiserror::Error;
20
21pub const PROTOCOL_VERSION: u32 = 1;
23
24pub const DEFAULT_SOCKET_PATH: &str = "~/.ixchel/run/ixcheld.sock";
26
27pub const DEFAULT_IDLE_TIMEOUT_MS: u64 = 300_000; #[derive(Debug, Error)]
35pub enum DaemonError {
36 #[error("Invalid request: {0}")]
37 InvalidRequest(String),
38
39 #[error("Incompatible protocol version: expected {expected}, got {got}")]
40 IncompatibleVersion { expected: u32, got: u32 },
41
42 #[error("Repository not found: {0}")]
43 RepoNotFound(String),
44
45 #[error("Timeout waiting for sync: {0}")]
46 Timeout(String),
47
48 #[error("Internal error: {0}")]
49 Internal(String),
50
51 #[error("IO error: {0}")]
52 Io(#[from] std::io::Error),
53
54 #[error("JSON error: {0}")]
55 Json(#[from] serde_json::Error),
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum ErrorCode {
62 InvalidRequest,
63 IncompatibleVersion,
64 RepoNotFound,
65 Timeout,
66 InternalError,
67}
68
69impl From<&DaemonError> for ErrorCode {
70 fn from(err: &DaemonError) -> Self {
71 match err {
72 DaemonError::InvalidRequest(_) => Self::InvalidRequest,
73 DaemonError::IncompatibleVersion { .. } => Self::IncompatibleVersion,
74 DaemonError::RepoNotFound(_) => Self::RepoNotFound,
75 DaemonError::Timeout(_) => Self::Timeout,
76 DaemonError::Internal(_) | DaemonError::Io(_) | DaemonError::Json(_) => {
77 Self::InternalError
78 }
79 }
80 }
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct Request {
90 pub version: u32,
92
93 pub id: String,
95
96 pub repo_root: String,
98
99 pub tool: String,
101
102 pub command: Command,
104}
105
106impl Request {
107 pub fn new(repo_root: impl Into<String>, tool: impl Into<String>, command: Command) -> Self {
109 Self {
110 version: PROTOCOL_VERSION,
111 id: uuid::Uuid::new_v4().to_string(),
112 repo_root: repo_root.into(),
113 tool: tool.into(),
114 command,
115 }
116 }
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
121#[serde(tag = "command", content = "payload", rename_all = "snake_case")]
122pub enum Command {
123 Ping,
125
126 EnqueueSync(EnqueueSyncPayload),
128
129 WaitSync(WaitSyncPayload),
131
132 Status(StatusPayload),
134
135 Shutdown(ShutdownPayload),
137}
138
139#[derive(Debug, Clone, Default, Serialize, Deserialize)]
140pub struct EnqueueSyncPayload {
141 pub directory: String,
143
144 #[serde(default)]
146 pub force: bool,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct WaitSyncPayload {
151 pub sync_id: String,
153
154 #[serde(default = "default_timeout_ms")]
156 pub timeout_ms: u64,
157}
158
159const fn default_timeout_ms() -> u64 {
160 30_000
161}
162
163#[derive(Debug, Clone, Default, Serialize, Deserialize)]
164pub struct StatusPayload {
165 #[serde(skip_serializing_if = "Option::is_none")]
167 pub repo_root: Option<String>,
168
169 #[serde(skip_serializing_if = "Option::is_none")]
171 pub tool: Option<String>,
172}
173
174#[derive(Debug, Clone, Default, Serialize, Deserialize)]
175pub struct ShutdownPayload {
176 #[serde(default)]
178 pub reason: String,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct Response {
188 pub version: u32,
190
191 pub id: String,
193
194 #[serde(flatten)]
196 pub result: ResponseResult,
197}
198
199impl Response {
200 pub fn ok(id: impl Into<String>, payload: ResponsePayload) -> Self {
202 Self {
203 version: PROTOCOL_VERSION,
204 id: id.into(),
205 result: ResponseResult::Ok { payload },
206 }
207 }
208
209 pub fn error(id: impl Into<String>, code: ErrorCode, message: impl Into<String>) -> Self {
211 Self {
212 version: PROTOCOL_VERSION,
213 id: id.into(),
214 result: ResponseResult::Error {
215 error: ErrorInfo {
216 code,
217 message: message.into(),
218 },
219 },
220 }
221 }
222
223 pub fn from_error(id: impl Into<String>, err: &DaemonError) -> Self {
225 Self::error(id, ErrorCode::from(err), err.to_string())
226 }
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
230#[serde(tag = "status", rename_all = "snake_case")]
231pub enum ResponseResult {
232 Ok { payload: ResponsePayload },
233 Error { error: ErrorInfo },
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct ErrorInfo {
238 pub code: ErrorCode,
239 pub message: String,
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
244#[serde(untagged)]
245pub enum ResponsePayload {
246 Ping(PingResponse),
247 EnqueueSync(EnqueueSyncResponse),
248 WaitSync(WaitSyncResponse),
249 Status(StatusResponse),
250 Shutdown(ShutdownResponse),
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct PingResponse {
255 pub daemon_version: String,
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct EnqueueSyncResponse {
260 pub sync_id: String,
261 pub queued_at_ms: u64,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct WaitSyncResponse {
266 pub sync_id: String,
267 pub state: SyncState,
268 #[serde(skip_serializing_if = "Option::is_none")]
269 pub stats: Option<SyncStats>,
270}
271
272#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
273#[serde(rename_all = "snake_case")]
274pub enum SyncState {
275 Queued,
276 Running,
277 Done,
278 Error,
279}
280
281#[derive(Debug, Clone, Default, Serialize, Deserialize)]
282pub struct SyncStats {
283 pub files_scanned: u64,
284 pub files_updated: u64,
285 pub duration_ms: u64,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
289pub struct StatusResponse {
290 pub queues: Vec<QueueInfo>,
291 pub uptime_ms: u64,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct QueueInfo {
296 pub repo_root: String,
297 pub tool: String,
298 pub pending: u32,
299 pub active: Option<String>,
300}
301
302#[derive(Debug, Clone, Default, Serialize, Deserialize)]
303pub struct ShutdownResponse {}
304
305#[cfg(test)]
310mod tests {
311 use super::*;
312
313 #[test]
314 fn test_request_serialization() {
315 let req = Request::new("/path/to/repo", "decisions", Command::Ping);
316 let json = serde_json::to_string(&req).unwrap();
317 assert!(json.contains("\"version\":1"));
318 assert!(json.contains("\"command\":\"ping\""));
319 }
320
321 #[test]
322 fn test_enqueue_sync_request() {
323 let req = Request::new(
324 "/path/to/repo",
325 "decisions",
326 Command::EnqueueSync(EnqueueSyncPayload {
327 directory: ".ixchel/decisions".to_string(),
328 force: false,
329 }),
330 );
331 let json = serde_json::to_string(&req).unwrap();
332 assert!(json.contains("\"command\":\"enqueue_sync\""));
333 assert!(json.contains("\".ixchel/decisions\""));
334 }
335
336 #[test]
337 fn test_response_ok_serialization() {
338 let resp = Response::ok(
339 "test-id",
340 ResponsePayload::Ping(PingResponse {
341 daemon_version: "0.1.0".to_string(),
342 }),
343 );
344 let json = serde_json::to_string(&resp).unwrap();
345 assert!(json.contains("\"status\":\"ok\""));
346 assert!(json.contains("\"daemon_version\":\"0.1.0\""));
347 }
348
349 #[test]
350 fn test_response_error_serialization() {
351 let resp = Response::error("test-id", ErrorCode::RepoNotFound, "repo not found");
352 let json = serde_json::to_string(&resp).unwrap();
353 assert!(json.contains("\"status\":\"error\""));
354 assert!(json.contains("\"code\":\"repo_not_found\""));
355 }
356
357 #[test]
358 fn test_request_roundtrip() {
359 let req = Request::new(
360 "/path/to/repo",
361 "issues",
362 Command::WaitSync(WaitSyncPayload {
363 sync_id: "sync-123".to_string(),
364 timeout_ms: 5000,
365 }),
366 );
367 let json = serde_json::to_string(&req).unwrap();
368 let parsed: Request = serde_json::from_str(&json).unwrap();
369 assert_eq!(parsed.repo_root, "/path/to/repo");
370 assert_eq!(parsed.tool, "issues");
371 }
372
373 #[test]
374 fn test_sync_state_values() {
375 assert_eq!(
376 serde_json::to_string(&SyncState::Queued).unwrap(),
377 "\"queued\""
378 );
379 assert_eq!(
380 serde_json::to_string(&SyncState::Running).unwrap(),
381 "\"running\""
382 );
383 assert_eq!(serde_json::to_string(&SyncState::Done).unwrap(), "\"done\"");
384 assert_eq!(
385 serde_json::to_string(&SyncState::Error).unwrap(),
386 "\"error\""
387 );
388 }
389}