refactor: migrate monolithic crate to Cargo Workspace with Clean Architecture

Transform the single binary crate into a 9-crate workspace monorepo:

- Root Cargo.toml as [workspace] manager with resolver = "2"
- zesdex-entities: Domain entity types (session, settings, store, message, etc.)
- zesdex-utils: Pure utility functions (error, logger, pagination, slug, clipboard)
- zesdex-dto: Data Transfer Objects for LLM provider API communication
- zesdex-ipc: Unix-socket IPC layer (client/server/framing/protocol)
- zesdex-iam: Identity & Access Management (Clean Architecture: domain/application/infrastructure)
- zesdex-cms: Content Management (Clean Architecture: domain/application/infrastructure)
- zesdex-middleware: HTTP middleware (Auth, CORS, Rate Limiting)
- zesdex-libs: Composition root (AppContext, DB init, JWT, Argon2)
- zesdex-backend: Main binary entry point + seed/migrate binaries
- DevOps: Dockerfile, docker-compose, Nix (flake/shell/default), CI/CD updates
- Remove dead root src/ and src-misc/ directories

All crate re-exports maintain backward compatibility with original
crate::model::*, crate::dto::*, crate::ipc::* module paths.
Feature crates enforce strict layer separation: domain -> application
-> infrastructure with generic trait-based dependency injection.
This commit is contained in:
asepharyana
2026-07-17 09:08:41 +07:00
parent 86cc412395
commit be0a9582bb
248 changed files with 7901 additions and 1505 deletions
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "zesdex-ipc"
version.workspace = true
edition.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
anyhow.workspace = true
tracing.workspace = true
zesdex-entities = { path = "../zesdex-entities" }
zesdex-dto = { path = "../zesdex-dto" }
+111
View File
@@ -0,0 +1,111 @@
//! IPC client — connects to the daemon's Unix socket and sends/receives
//! framed JSON messages.
//!
//! [`IpcClient`] wraps a [`Connection`] behind a [`Mutex`] so it can be
//! shared across threads (e.g. the TUI event loop and the render task).
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
use crate::conn::Connection;
use anyhow::{Context, Result};
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::os::unix::net::UnixStream;
use std::sync::Mutex;
/// A thread-safe IPC client connected to a Zesdex daemon over a Unix
/// socket.
pub struct IpcClient {
/// Inner connection protected by a mutex for shared access.
conn: Mutex<Connection>,
}
impl IpcClient {
/// Connect to the daemon listening at `path` (a Unix socket path).
///
/// # Errors
///
/// Returns an error if the socket path does not exist, the connection
/// is refused, or the caller lacks permission.
pub fn connect_unix(path: &str) -> Result<Self> {
let stream = UnixStream::connect(path)
.with_context(|| format!("failed to connect to Unix socket at {path:?}"))?;
let conn = Connection::new(stream);
Ok(Self {
conn: Mutex::new(conn),
})
}
/// Serialise `msg` to JSON and send it as a length-prefixed frame.
///
/// # Errors
///
/// Delegates to the underlying [`Connection::send`].
pub fn send<T: Serialize>(&self, msg: &T) -> Result<()> {
let mut guard = self
.conn
.lock()
.expect("IpcClient mutex poisoned — the previous operation panicked");
guard.send(msg)
}
/// Read one framed JSON message and deserialise it.
///
/// Returns `Ok(None)` on clean EOF (daemon closed the connection).
///
/// # Errors
///
/// Delegates to the underlying [`Connection::receive`].
pub fn receive<T: DeserializeOwned>(&self) -> Result<Option<T>> {
let mut guard = self
.conn
.lock()
.expect("IpcClient mutex poisoned — the previous operation panicked");
guard.receive()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
use std::os::unix::net::UnixListener;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct Ping {
seq: u32,
}
#[test]
fn connect_and_round_trip() {
let dir = std::env::temp_dir().join(format!("zesdex-ipc-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let sock_path = dir.join("test.sock");
let sock_path_str = sock_path.to_string_lossy().to_string();
// Start a minimal echo server in a background thread.
let listener = UnixListener::bind(&sock_path).unwrap();
let server_handle = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut conn = Connection::new(stream);
// Echo one message back.
let req: Ping = conn.receive().unwrap().unwrap();
conn.send(&req).unwrap();
});
// Client connects and sends a ping, then receives the echo.
let client = IpcClient::connect_unix(&sock_path_str).unwrap();
client.send(&Ping { seq: 7 }).unwrap();
let resp: Ping = client.receive().unwrap().expect("expected a response");
assert_eq!(resp, Ping { seq: 7 });
server_handle.join().unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
}
+140
View File
@@ -0,0 +1,140 @@
//! Connection wrapper around a Unix socket stream.
//!
//! [`Connection`] pairs a buffered reader with a raw writer and exposes
//! `send` / `receive` for framed JSON messages.
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
use crate::frame::{read_frame, write_frame};
use anyhow::{Context, Result};
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::io::BufReader;
use std::os::unix::net::UnixStream;
/// A framed JSON connection over a Unix socket.
///
/// Wraps the raw [`UnixStream`] with a [`BufReader`] on the read side and
/// direct writes (with explicit flushing) on the write side.
pub struct Connection {
/// Buffered reader for receiving frames.
reader: BufReader<UnixStream>,
/// Unbuffered writer (flushed after every frame).
writer: UnixStream,
}
impl Connection {
/// Create a new `Connection` from an already-connected [`UnixStream`].
pub fn new(stream: UnixStream) -> Self {
// Clone the stream so that reader and writer can reference separate
// file-descriptor handles. `UnixStream::try_clone` is infallible on
// Unix (it calls `dup(2)`).
let reader = BufReader::new(
stream
.try_clone()
.expect("UnixStream::try_clone should never fail on Linux"),
);
let writer = stream;
Self { reader, writer }
}
/// Serialise `msg` to JSON and send it as a length-prefixed frame.
///
/// # Errors
///
/// Delegates to [`serde_json::to_vec`] for serialisation and
/// [`write_frame`] for writing.
pub fn send<T: Serialize>(&mut self, msg: &T) -> Result<()> {
let json =
serde_json::to_vec(msg).context("failed to serialise message to JSON")?;
write_frame(&mut self.writer, &json)
.context("failed to write frame to connection")
}
/// Read one framed JSON message and deserialise it.
///
/// Returns `Ok(None)` when the remote end has closed the connection
/// cleanly (EOF). Returns `Ok(Some(msg))` on a successful read.
///
/// # Errors
///
/// Delegates to [`read_frame`] for reading and
/// [`serde_json::from_slice`] for deserialisation.
pub fn receive<T: DeserializeOwned>(&mut self) -> Result<Option<T>> {
let raw = read_frame(&mut self.reader)
.context("failed to read frame from connection")?;
match raw {
None => Ok(None),
Some(bytes) => {
let msg: T = serde_json::from_slice(&bytes)
.with_context(|| {
format!(
"failed to deserialise frame ({} byte(s))",
bytes.len()
)
})?;
Ok(Some(msg))
}
}
}
}
// Safety: `UnixStream` is `Send` but not `Sync`. Wrapping `Connection` in
// a `Mutex` (as done in `IpcClient`) provides the `Sync` guarantee.
// The type itself is `Send` because both fields are `Send`.
//
// We explicitly assert Send here for clarity:
fn _assert_send()
where
Connection: Send,
{
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct Ping {
seq: u32,
}
/// Helper: create a pair of connected `Connection` values via a
/// Unix socket pair.
fn pair() -> (Connection, Connection) {
let (a, b) = UnixStream::pair().expect("UnixStream::pair failed");
(Connection::new(a), Connection::new(b))
}
#[test]
fn round_trip() {
let (mut left, mut right) = pair();
left.send(&Ping { seq: 42 }).unwrap();
let received: Ping = right.receive().unwrap().expect("expected a frame");
assert_eq!(received, Ping { seq: 42 });
}
#[test]
fn eof_detection() {
let (left, right) = pair();
drop(right); // close remote end
// Send something first so we can read past it... actually let's
// just drop the peer and check that receive returns None.
// Since we dropped right, left's reads should eventually get EOF.
// But with a socket pair, dropping one end signals EOF on the other.
drop(left); // drop left too — we'll test EOF on a fresh pair
let (mut a, _b) = pair();
drop(_b);
let result: Option<Ping> = a.receive().unwrap();
assert!(result.is_none());
}
}
+140
View File
@@ -0,0 +1,140 @@
//! Length-prefixed framing for Unix-socket IPC.
//!
//! Every message on the wire is encoded as:
//!
//! ```text
//! [ 4-byte big-endian payload length ][ payload bytes (JSON) ]
//! ```
//!
//! The length prefix **excludes** itself — it encodes only the number of
//! payload bytes that follow.
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
use anyhow::{Context, Result};
use std::io::{Read, Write};
/// Maximum frame payload size (64 MiB).
const MAX_PAYLOAD: u32 = 64 * 1024 * 1024;
/// Read one length-prefixed frame from `reader`.
///
/// Returns `Ok(None)` when the stream has reached end-of-file (the reader
/// returned `Ok(0)` on the first read). Returns `Ok(Some(...))` with the
/// raw payload bytes for any successfully decoded frame.
///
/// # Errors
///
/// - `UnexpectedEof` if the stream terminates partway through a length
/// prefix or payload.
/// - `anyhow` error if the payload length exceeds [`MAX_PAYLOAD`].
/// - Any I/O error from the underlying reader.
pub fn read_frame(reader: &mut impl Read) -> Result<Option<Vec<u8>>> {
// --- Read the 4-byte big-endian length prefix ---------------------------
let mut len_buf = [0u8; 4];
match reader.read_exact(&mut len_buf) {
Ok(()) => {}
Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
// Zero bytes available → clean EOF.
return Ok(None);
}
Err(e) => return Err(e).context("failed to read frame length prefix"),
}
let payload_len = u32::from_be_bytes(len_buf) as usize;
if payload_len > MAX_PAYLOAD as usize {
anyhow::bail!(
"frame payload too large: {payload_len} bytes (max {MAX_PAYLOAD})"
);
}
// --- Read the payload ---------------------------------------------------
let mut payload = vec![0u8; payload_len];
reader
.read_exact(&mut payload)
.with_context(|| format!("failed to read {payload_len} byte(s) of frame payload"))?;
Ok(Some(payload))
}
/// Write one length-prefixed frame to `writer`.
///
/// Writes the 4-byte big-endian length of `data`, followed by `data` itself.
///
/// # Errors
///
/// - Returns an error if `data` is longer than [`MAX_PAYLOAD`].
/// - Any I/O error from the underlying writer.
pub fn write_frame(writer: &mut impl Write, data: &[u8]) -> Result<()> {
let payload_len: u32 = data
.len()
.try_into()
.context("payload length exceeds u32 range")?;
if payload_len > MAX_PAYLOAD {
anyhow::bail!(
"frame payload too large: {payload_len} bytes (max {MAX_PAYLOAD})"
);
}
let len_bytes = payload_len.to_be_bytes();
writer
.write_all(&len_bytes)
.context("failed to write frame length prefix")?;
writer
.write_all(data)
.context("failed to write frame payload")?;
writer
.flush()
.context("failed to flush frame writer")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip_small() {
let payload = b"hello world";
let mut buf = Vec::new();
write_frame(&mut buf, payload).unwrap();
let mut cursor = std::io::Cursor::new(&buf);
let result = read_frame(&mut cursor).unwrap();
assert_eq!(result, Some(payload.to_vec()));
}
#[test]
fn round_trip_empty() {
let payload = b"";
let mut buf = Vec::new();
write_frame(&mut buf, payload).unwrap();
let mut cursor = std::io::Cursor::new(&buf);
let result = read_frame(&mut cursor).unwrap();
assert_eq!(result, Some(payload.to_vec()));
}
#[test]
fn eof_returns_none() {
let mut empty: &[u8] = b"";
let result = read_frame(&mut empty).unwrap();
assert!(result.is_none());
}
#[test]
fn oversized_rejected() {
let huge = vec![0u8; (MAX_PAYLOAD as usize) + 1];
let mut buf = Vec::new();
assert!(write_frame(&mut buf, &huge).is_err());
}
}
+17
View File
@@ -0,0 +1,17 @@
//! Unix-socket IPC layer for daemon/client communication.
//!
//! This crate provides the wire protocol, framing, and connection
//! wrappers used by both the Zesdex daemon and its TUI client.
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
pub mod protocol;
pub mod frame;
pub mod conn;
pub mod client;
pub mod server;
+159
View File
@@ -0,0 +1,159 @@
//! Wire types for the Zesdex IPC protocol.
//!
//! All types exchanged between the daemon and the TUI client over the
//! Unix socket are defined here. Both [`ClientRequest`] and
//! [`DaemonFrame`] are serialised as JSON messages framed with a
//! length prefix (see [`crate::frame`]).
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
// KeyAction
// ---------------------------------------------------------------------------
/// A resolved key press sent from the daemon to the client (or used inside
/// the client event loop for deferred dispatch).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum KeyAction {
Char(char),
Enter,
Escape,
Backspace,
Delete,
Tab,
Up,
Down,
Left,
Right,
Home,
End,
PageUp,
PageDown,
Function(u8),
}
// ---------------------------------------------------------------------------
// ClientRequest
// ---------------------------------------------------------------------------
/// A message sent from the TUI client to the daemon.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ClientRequest {
/// Periodic heartbeat / tick event.
Tick,
/// A keyboard event with modifier flags.
KeyPress {
key: KeyAction,
ctrl: bool,
alt: bool,
shift: bool,
},
/// A completed text submission (e.g. pressing Enter in the input bar).
Submit(String),
/// Pasted text content.
Paste(String),
/// Terminal resize notification.
Resize(u16, u16),
/// Graceful close / shutdown request.
Close,
/// Scroll the session view up one page or line.
ScrollUp,
/// Scroll the session view down one page or line.
ScrollDown,
}
// ---------------------------------------------------------------------------
// MessageEntry
// ---------------------------------------------------------------------------
/// A single chat message within a session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageEntry {
/// The role of the message author (e.g. "user", "assistant", "system").
pub role: String,
/// The text content of the message.
pub content: String,
/// Unix timestamp (seconds since epoch) when the message was created.
pub timestamp: i64,
}
// ---------------------------------------------------------------------------
// ToastEntry
// ---------------------------------------------------------------------------
/// A transient toast notification sent to the client.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToastEntry {
/// The kind / category of the toast (e.g. "info", "error", "success").
pub kind: String,
/// The display message.
pub message: String,
/// Unix timestamp when the toast was created.
pub created_at: i64,
/// How long (in milliseconds) the toast should remain visible.
pub lifetime_ms: u64,
}
// ---------------------------------------------------------------------------
// StatePayload
// ---------------------------------------------------------------------------
/// Full UI state snapshot pushed from the daemon to the client after every
/// mutation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatePayload {
/// Opaque session identifier.
pub session_id: String,
/// Ordered chat messages in the current session.
pub messages: Vec<MessageEntry>,
/// Monotonically increasing edit counter — used for change detection.
pub edit_count: u32,
/// Cached length of `messages` (redundant but avoids a deserialisation
/// lookup on the client side).
pub message_count: usize,
/// Name of the currently active overlay, if any.
pub overlay: Option<String>,
/// Active toast notifications.
pub toasts: Vec<ToastEntry>,
/// Whether the session has uncommitted changes.
pub dirty: bool,
/// Current text in the client input buffer (set by the daemon when a
/// session is activated so the client restores cursor state).
pub input_buffer: String,
/// Cursor position within `input_buffer`.
pub input_cursor: usize,
}
// ---------------------------------------------------------------------------
// DaemonFrame
// ---------------------------------------------------------------------------
/// A frame sent from the daemon to the client.
///
/// Every response from the daemon is one of these variants. The client
/// dispatches on the variant to update its UI model.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DaemonFrame {
/// Full state update — the client should replace its entire local state.
StateUpdate(Box<StatePayload>),
/// A streaming token for incremental assistant response rendering.
StreamToken(String),
/// A system-level notification that doesn't alter the session state.
SystemNote {
/// The kind of system note (e.g. "info", "warning", "error").
kind: String,
/// The note content.
message: String,
},
/// Instructs the client to place `text` into the system clipboard.
ClipboardCopy(String),
/// Signals that the daemon has shut down / the session is complete.
Closed,
}
+116
View File
@@ -0,0 +1,116 @@
//! IPC server — binds a Unix socket and accepts incoming client
//! connections.
//!
//! [`IpcServer`] wraps a [`UnixListener`] and provides a blocking
//! `accept` method that returns a [`Connection`] for each new client.
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
use crate::conn::Connection;
use anyhow::{Context, Result};
use std::os::unix::net::UnixListener;
use std::path::Path;
/// A Unix-socket IPC server.
///
/// Each call to [`accept`](Self::accept) blocks until a new client connects
/// and returns a [`Connection`] for that client.
pub struct IpcServer {
listener: UnixListener,
}
impl IpcServer {
/// Bind a [`UnixListener`] to `path`.
///
/// If `path` already exists, it is **removed** first so that a stale
/// socket file from a previous run does not prevent binding.
///
/// # Errors
///
/// Returns an error if the socket cannot be bound (e.g. insufficient
/// permissions or an unreachable parent directory).
pub fn bind_unix(path: &str) -> Result<Self> {
// Remove stale socket file if present.
let p = Path::new(path);
if p.exists() {
std::fs::remove_file(p)
.with_context(|| format!("failed to remove stale socket at {path:?}"))?;
}
let listener = UnixListener::bind(path)
.with_context(|| format!("failed to bind Unix socket at {path:?}"))?;
Ok(Self { listener })
}
/// Block until a client connects and return a [`Connection`] for the new
/// client.
///
/// # Errors
///
/// Returns an error if the underlying `accept` call fails.
pub fn accept(&self) -> Result<Connection> {
let (stream, addr) = self
.listener
.accept()
.context("failed to accept client connection")?;
tracing::debug!("accepted client from {addr:?}");
Ok(Connection::new(stream))
}
}
/// `UnixListener` is `Send` but not `Sync`. However, `&self`-based
/// `accept` is fine because the OS-level listen backlog is inherently
/// thread-safe (multiple threads can call `accept` on the same listener).
///
/// We explicitly assert Send + Sync for clarity:
fn _assert_send_sync()
where
IpcServer: Send + Sync,
{
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct Ping {
seq: u32,
}
#[test]
fn bind_and_accept_one() {
let dir = std::env::temp_dir().join(format!("zesdex-ipc-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let sock_path = dir.join("server_test.sock");
let sock_path_str = sock_path.to_string_lossy().to_string();
let server = IpcServer::bind_unix(&sock_path_str).unwrap();
let server_handle = std::thread::spawn(move || {
let mut conn = server.accept().unwrap();
let msg: Ping = conn.receive().unwrap().unwrap();
assert_eq!(msg, Ping { seq: 1 });
conn.send(&Ping { seq: 2 }).unwrap();
});
// Connect a raw client.
let stream = std::os::unix::net::UnixStream::connect(&sock_path_str).unwrap();
let mut conn = Connection::new(stream);
conn.send(&Ping { seq: 1 }).unwrap();
let resp: Ping = conn.receive().unwrap().unwrap();
assert_eq!(resp, Ping { seq: 2 });
server_handle.join().unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
}