mod client;
mod queue;
mod server;
pub use client::Client;
pub use queue::{QueueKey, SyncJob, SyncQueue};
pub use server::Server;
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const PROTOCOL_VERSION: u32 = 1;
pub const DEFAULT_SOCKET_PATH: &str = "~/.ixchel/run/ixcheld.sock";
pub const DEFAULT_IDLE_TIMEOUT_MS: u64 = 300_000;
#[derive(Debug, Error)]
pub enum DaemonError {
#[error("Invalid request: {0}")]
InvalidRequest(String),
#[error("Incompatible protocol version: expected {expected}, got {got}")]
IncompatibleVersion { expected: u32, got: u32 },
#[error("Repository not found: {0}")]
RepoNotFound(String),
#[error("Timeout waiting for sync: {0}")]
Timeout(String),
#[error("Internal error: {0}")]
Internal(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCode {
InvalidRequest,
IncompatibleVersion,
RepoNotFound,
Timeout,
InternalError,
}
impl From<&DaemonError> for ErrorCode {
fn from(err: &DaemonError) -> Self {
match err {
DaemonError::InvalidRequest(_) => Self::InvalidRequest,
DaemonError::IncompatibleVersion { .. } => Self::IncompatibleVersion,
DaemonError::RepoNotFound(_) => Self::RepoNotFound,
DaemonError::Timeout(_) => Self::Timeout,
DaemonError::Internal(_) | DaemonError::Io(_) | DaemonError::Json(_) => {
Self::InternalError
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Request {
pub version: u32,
pub id: String,
pub repo_root: String,
pub tool: String,
pub command: Command,
}
impl Request {
pub fn new(repo_root: impl Into<String>, tool: impl Into<String>, command: Command) -> Self {
Self {
version: PROTOCOL_VERSION,
id: uuid::Uuid::new_v4().to_string(),
repo_root: repo_root.into(),
tool: tool.into(),
command,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "command", content = "payload", rename_all = "snake_case")]
pub enum Command {
Ping,
EnqueueSync(EnqueueSyncPayload),
WaitSync(WaitSyncPayload),
Status(StatusPayload),
Shutdown(ShutdownPayload),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct EnqueueSyncPayload {
pub directory: String,
#[serde(default)]
pub force: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WaitSyncPayload {
pub sync_id: String,
#[serde(default = "default_timeout_ms")]
pub timeout_ms: u64,
}
const fn default_timeout_ms() -> u64 {
30_000
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StatusPayload {
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_root: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ShutdownPayload {
#[serde(default)]
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Response {
pub version: u32,
pub id: String,
#[serde(flatten)]
pub result: ResponseResult,
}
impl Response {
pub fn ok(id: impl Into<String>, payload: ResponsePayload) -> Self {
Self {
version: PROTOCOL_VERSION,
id: id.into(),
result: ResponseResult::Ok { payload },
}
}
pub fn error(id: impl Into<String>, code: ErrorCode, message: impl Into<String>) -> Self {
Self {
version: PROTOCOL_VERSION,
id: id.into(),
result: ResponseResult::Error {
error: ErrorInfo {
code,
message: message.into(),
},
},
}
}
pub fn from_error(id: impl Into<String>, err: &DaemonError) -> Self {
Self::error(id, ErrorCode::from(err), err.to_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum ResponseResult {
Ok { payload: ResponsePayload },
Error { error: ErrorInfo },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorInfo {
pub code: ErrorCode,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ResponsePayload {
Ping(PingResponse),
EnqueueSync(EnqueueSyncResponse),
WaitSync(WaitSyncResponse),
Status(StatusResponse),
Shutdown(ShutdownResponse),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PingResponse {
pub daemon_version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnqueueSyncResponse {
pub sync_id: String,
pub queued_at_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WaitSyncResponse {
pub sync_id: String,
pub state: SyncState,
#[serde(skip_serializing_if = "Option::is_none")]
pub stats: Option<SyncStats>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SyncState {
Queued,
Running,
Done,
Error,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SyncStats {
pub files_scanned: u64,
pub files_updated: u64,
pub duration_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatusResponse {
pub queues: Vec<QueueInfo>,
pub uptime_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueueInfo {
pub repo_root: String,
pub tool: String,
pub pending: u32,
pub active: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ShutdownResponse {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_request_serialization() {
let req = Request::new("/path/to/repo", "decisions", Command::Ping);
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"version\":1"));
assert!(json.contains("\"command\":\"ping\""));
}
#[test]
fn test_enqueue_sync_request() {
let req = Request::new(
"/path/to/repo",
"decisions",
Command::EnqueueSync(EnqueueSyncPayload {
directory: ".ixchel/decisions".to_string(),
force: false,
}),
);
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"command\":\"enqueue_sync\""));
assert!(json.contains("\".ixchel/decisions\""));
}
#[test]
fn test_response_ok_serialization() {
let resp = Response::ok(
"test-id",
ResponsePayload::Ping(PingResponse {
daemon_version: "0.1.0".to_string(),
}),
);
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("\"status\":\"ok\""));
assert!(json.contains("\"daemon_version\":\"0.1.0\""));
}
#[test]
fn test_response_error_serialization() {
let resp = Response::error("test-id", ErrorCode::RepoNotFound, "repo not found");
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("\"status\":\"error\""));
assert!(json.contains("\"code\":\"repo_not_found\""));
}
#[test]
fn test_request_roundtrip() {
let req = Request::new(
"/path/to/repo",
"issues",
Command::WaitSync(WaitSyncPayload {
sync_id: "sync-123".to_string(),
timeout_ms: 5000,
}),
);
let json = serde_json::to_string(&req).unwrap();
let parsed: Request = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.repo_root, "/path/to/repo");
assert_eq!(parsed.tool, "issues");
}
#[test]
fn test_sync_state_values() {
assert_eq!(
serde_json::to_string(&SyncState::Queued).unwrap(),
"\"queued\""
);
assert_eq!(
serde_json::to_string(&SyncState::Running).unwrap(),
"\"running\""
);
assert_eq!(serde_json::to_string(&SyncState::Done).unwrap(), "\"done\"");
assert_eq!(
serde_json::to_string(&SyncState::Error).unwrap(),
"\"error\""
);
}
}