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
+65
View File
@@ -0,0 +1,65 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
use std::io::{self, Write};
/// Write `text` to the terminal's clipboard using the OSC-52 escape sequence.
///
/// OSC-52 (`\x1b]52;c;<base64>\x1b\\`) is supported by many terminal emulators
/// (iTerm2, Kitty, tmux, etc.) and allows writing to the system clipboard
/// without external binaries.
///
/// The `output` parameter should be a writable handle to the terminal (e.g.
/// `io::stdout()` or `io::stderr()`).
pub fn write_osc52(output: &mut impl Write, text: &str) -> io::Result<()> {
use base64::Engine as _;
let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
// OSC-52: ESC ] 52 ; c ; <base64> ST
// Where c = "c" for clipboard, ST = ESC \
write!(output, "\x1b]52;c;{encoded}\x1b\\")?;
output.flush()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_write_osc52_output_format() {
let mut buf = Vec::new();
write_osc52(&mut buf, "hello").unwrap();
let output = String::from_utf8(buf).unwrap();
// Should start with OSC sequence
assert!(output.starts_with("\x1b]52;c;"), "should start with OSC52 prefix");
// Should have base64 payload
assert!(output.contains("aGVsbG8="), "should contain base64 of 'hello'");
// Should end with ST
assert!(output.ends_with("\x1b\\"), "should end with string terminator");
}
#[test]
fn test_write_osc52_empty() {
let mut buf = Vec::new();
write_osc52(&mut buf, "").unwrap();
let output = String::from_utf8(buf).unwrap();
assert_eq!(output, "\x1b]52;c;\x1b\\");
}
#[test]
fn test_write_osc52_unicode() {
let mut buf = Vec::new();
write_osc52(&mut buf, "日本語").unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.starts_with("\x1b]52;c;"));
assert!(output.ends_with("\x1b\\"));
}
}
+97
View File
@@ -0,0 +1,97 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
use std::fmt;
/// Unified error type for the zesdex codebase.
#[derive(Debug)]
pub enum Error {
/// Wraps an I/O error.
Io(std::io::Error),
/// Wraps a JSON serialization/deserialization error.
Serde(serde_json::Error),
/// A generic parse failure with a message.
Parse(String),
/// A resource was not found.
NotFound(String),
/// Invalid input was provided.
InvalidInput(String),
/// The session is locked and cannot be accessed.
SessionLocked,
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(e) => write!(f, "I/O error: {e}"),
Self::Serde(e) => write!(f, "serialization error: {e}"),
Self::Parse(msg) => write!(f, "parse error: {msg}"),
Self::NotFound(resource) => write!(f, "not found: {resource}"),
Self::InvalidInput(msg) => write!(f, "invalid input: {msg}"),
Self::SessionLocked => write!(f, "session is locked"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(e) => Some(e),
Self::Serde(e) => Some(e),
Self::Parse(_) | Self::NotFound(_) | Self::InvalidInput(_) | Self::SessionLocked => {
None
}
}
}
}
// ---------------------------------------------------------------------------
// From conversions
// ---------------------------------------------------------------------------
impl From<std::io::Error> for Error {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
impl From<serde_json::Error> for Error {
fn from(e: serde_json::Error) -> Self {
Self::Serde(e)
}
}
// Note: anyhow already provides `From<E> for anyhow::Error` for all
// `E: std::error::Error + Send + Sync + 'static`, which our `Error` satisfies.
// ---------------------------------------------------------------------------
// Type alias
// ---------------------------------------------------------------------------
/// Convenience alias for `Result<T, zesdex_utils::Error>`.
pub type Result<T> = std::result::Result<T, Error>;
// ---------------------------------------------------------------------------
// Additional impls
// ---------------------------------------------------------------------------
impl Error {
/// Create a `Parse` error.
pub fn parse(msg: impl Into<String>) -> Self {
Self::Parse(msg.into())
}
/// Create a `NotFound` error.
pub fn not_found(resource: impl Into<String>) -> Self {
Self::NotFound(resource.into())
}
/// Create an `InvalidInput` error.
pub fn invalid_input(msg: impl Into<String>) -> Self {
Self::InvalidInput(msg.into())
}
}
+13
View File
@@ -0,0 +1,13 @@
pub mod clipboard;
pub mod error;
pub mod logger;
pub mod pagination;
pub mod sanitize;
pub mod slug;
pub use error::{Error, Result};
pub use pagination::{paginate, Paginated};
pub use sanitize::{
is_valid_session_id, sanitize_filename, sanitize_html, sanitize_path, truncate,
};
pub use slug::{slug_path, slugify};
+100
View File
@@ -0,0 +1,100 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
use std::fs::{self, OpenOptions};
use std::io;
use std::path::PathBuf;
use tracing_subscriber::fmt::writer::MakeWriter;
use tracing_subscriber::EnvFilter;
/// A [`MakeWriter`] that writes to a log file, falling back to `/dev/null`.
#[derive(Clone)]
struct LogFileWriter {
path: PathBuf,
}
impl std::io::Write for LogFileWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
match OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
{
Ok(mut file) => file.write(buf),
Err(_) => {
// fallback: write to /dev/null
let mut null = fs::OpenOptions::new().write(true).open("/dev/null")?;
null.write(buf)
}
}
}
fn flush(&mut self) -> io::Result<()> {
match OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
{
Ok(file) => file.sync_all(),
Err(_) => Ok(()),
}
}
}
impl<'a> MakeWriter<'a> for LogFileWriter {
type Writer = LogFileWriter;
fn make_writer(&'a self) -> Self::Writer {
self.clone()
}
}
/// Initialise tracing/logging for the application.
///
/// Creates a log directory at `$DATA_DIR/zesdex/logs/` and opens a log file
/// with a timestamped name in append mode. If the directory cannot be created
/// or the file cannot be opened, falls back to `/dev/null` so that tracing
/// never panics at startup.
///
/// The subscriber uses `RUST_LOG` / `ZESDEX_LOG` env-filtering.
pub fn init_logging() -> Result<(), anyhow::Error> {
// ── determine log directory ──────────────────────────────────────
let data_dir = dirs::data_dir()
.map(|p| p.join("zesdex"))
.unwrap_or_else(|| PathBuf::from("/tmp/zesdex"));
let log_dir = data_dir.join("logs");
// ── create dir (best-effort) ─────────────────────────────────────
if let Err(e) = fs::create_dir_all(&log_dir) {
// If we can't create the directory, log via eprintln and continue
// with a /dev/null fallback.
eprintln!("[zesdex-utils::logger] failed to create log dir {log_dir:?}: {e}");
}
// ── build log file path ──────────────────────────────────────────
let timestamp = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S");
let log_path = log_dir.join(format!("zesdex-{timestamp}.log"));
// ── initialise tracing subscriber ────────────────────────────────
let env_filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_from_env("ZESDEX_LOG"))
.unwrap_or_else(|_| EnvFilter::new("info"));
let writer = LogFileWriter { path: log_path };
tracing_subscriber::fmt()
.with_env_filter(env_filter)
.with_writer(writer)
.with_ansi(false) // log files don't need ANSI colours
.with_target(true)
.with_file(true)
.with_line_number(true)
.init();
Ok(())
}
+138
View File
@@ -0,0 +1,138 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
use serde::{Deserialize, Serialize};
/// A generic paginated response.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Paginated<T> {
/// Items on the current page.
pub items: Vec<T>,
/// Total number of items across all pages.
pub total: usize,
/// Current page number (1-based).
pub page: usize,
/// Number of items per page.
pub page_size: usize,
}
impl<T> Paginated<T> {
/// The total number of pages.
pub fn total_pages(&self) -> usize {
if self.total == 0 {
return 0;
}
self.total.div_ceil(self.page_size)
}
/// Whether there is a next page.
pub fn has_next(&self) -> bool {
self.page < self.total_pages()
}
/// Whether there is a previous page.
pub fn has_prev(&self) -> bool {
self.page > 1
}
}
/// Create a [`Paginated`] response by slicing `items` according to the
/// given `page` (1-based) and `page_size`.
///
/// # Panics
///
/// Panics if `page == 0` or `page_size == 0`.
pub fn paginate<T>(items: Vec<T>, page: usize, page_size: usize) -> Paginated<T> {
assert!(page > 0, "page must be 1-based");
assert!(page_size > 0, "page_size must be > 0");
let total = items.len();
let offset = (page - 1) * page_size;
let items = if offset >= total {
Vec::new()
} else {
let end = (offset + page_size).min(total);
items.into_iter().skip(offset).take(end - offset).collect()
};
Paginated {
items,
total,
page,
page_size,
}
}
/// Compute the SQL offset/limit from 1-based page params.
///
/// Returns `(offset, limit)`.
pub fn page_params(page: usize, page_size: usize) -> (usize, usize) {
let offset = page.saturating_sub(1) * page_size;
(offset, page_size)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_paginate_first_page() {
let items: Vec<i32> = (1..=25).collect();
let result = paginate(items, 1, 10);
assert_eq!(result.items, vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
assert_eq!(result.total, 25);
assert_eq!(result.page, 1);
assert_eq!(result.page_size, 10);
}
#[test]
fn test_paginate_last_page() {
let items: Vec<i32> = (1..=25).collect();
let result = paginate(items, 3, 10);
assert_eq!(result.items, vec![21, 22, 23, 24, 25]);
assert_eq!(result.total, 25);
}
#[test]
fn test_paginate_empty() {
let items: Vec<i32> = vec![];
let result = paginate(items, 1, 10);
assert!(result.items.is_empty());
assert_eq!(result.total, 0);
}
#[test]
fn test_total_pages() {
let items: Vec<i32> = (1..=25).collect();
let result = paginate(items, 1, 10);
assert_eq!(result.total_pages(), 3);
assert!(result.has_next());
assert!(!result.has_prev());
}
#[test]
fn test_page_params() {
assert_eq!(page_params(1, 20), (0, 20));
assert_eq!(page_params(2, 20), (20, 20));
assert_eq!(page_params(3, 20), (40, 20));
assert_eq!(page_params(0, 20), (0, 20)); // saturating sub
}
#[test]
fn test_serde_roundtrip() {
let p: Paginated<String> = Paginated {
items: vec!["a".into(), "b".into()],
total: 2,
page: 1,
page_size: 10,
};
let json = serde_json::to_string(&p).unwrap();
let back: Paginated<String> = serde_json::from_str(&json).unwrap();
assert_eq!(back.items, p.items);
assert_eq!(back.total, p.total);
}
}
+196
View File
@@ -0,0 +1,196 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
/// Characters that are invalid in filenames on most operating systems.
const INVALID_FILENAME_CHARS: &[char] = &[
'/', '\0', '<', '>', ':', '"', '\\', '|', '?', '*', '\x01', '\x02', '\x03', '\x04', '\x05',
'\x06', '\x07', '\x08', '\x09', '\x0a', '\x0b', '\x0c', '\x0d', '\x0e', '\x0f', '\x10',
'\x11', '\x12', '\x13', '\x14', '\x15', '\x16', '\x17', '\x18', '\x19', '\x1a', '\x1b',
'\x1c', '\x1d', '\x1e', '\x1f', '\x7f',
];
/// Replace characters that are invalid in filenames with `_`.
///
/// Also strips leading/trailing whitespace and dots, because those can be
/// problematic on some filesystems.
pub fn sanitize_filename(s: &str) -> String {
let sanitized: String = s
.chars()
.map(|c| {
if INVALID_FILENAME_CHARS.contains(&c) {
'_'
} else {
c
}
})
.collect();
// Trim leading/trailing whitespace and dots
let trimmed = sanitized.trim_matches(|c: char| c == '.' || c.is_whitespace());
if trimmed.is_empty() {
return "unnamed".to_string();
}
trimmed.to_string()
}
/// Sanitize a user-supplied path to prevent directory traversal.
///
/// Replaces `..` path components with `_`, collapses repeated separators,
/// and strips any leading `/` to keep the result relative.
pub fn sanitize_path(path: &str) -> String {
let mut cleaned = String::new();
for component in path.split(&['/', '\\'][..]) {
if component.is_empty() {
continue;
}
if component == "." {
continue;
}
if component == ".." {
if !cleaned.is_empty() {
cleaned.push('/');
}
cleaned.push('_');
} else {
if !cleaned.is_empty() {
cleaned.push('/');
}
cleaned.push_str(component);
}
}
cleaned
}
/// Escape HTML special characters so the string can be safely embedded in
/// HTML or XML content.
pub fn sanitize_html(s: &str) -> String {
let mut escaped = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => escaped.push_str("&amp;"),
'<' => escaped.push_str("&lt;"),
'>' => escaped.push_str("&gt;"),
'"' => escaped.push_str("&quot;"),
'\'' => escaped.push_str("&#x27;"),
_ => escaped.push(c),
}
}
escaped
}
/// Truncate a string to at most `max_chars` characters, appending `…` if it
/// was truncated.
///
/// If `max_chars` is 0, returns an empty string. If the string is already
/// short enough, returns it unchanged.
pub fn truncate(s: &str, max_chars: usize) -> String {
if max_chars == 0 {
return String::new();
}
if s.chars().count() <= max_chars {
return s.to_string();
}
// Leave room for the ellipsis character
let cutoff = max_chars.saturating_sub(1);
let truncated: String = s.chars().take(cutoff).collect();
format!("{truncated}")
}
/// Validate that a session ID contains only alphanumeric characters, dashes,
/// and underscores, and is non-empty.
pub fn is_valid_session_id(id: &str) -> bool {
if id.is_empty() {
return false;
}
id.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sanitize_filename_replaces_invalid() {
assert_eq!(sanitize_filename("hello/world:test"), "hello_world_test");
}
#[test]
fn test_sanitize_filename_trims_dots() {
assert_eq!(sanitize_filename(".hidden"), "hidden");
}
#[test]
fn test_sanitize_filename_empty_fallback() {
assert_eq!(sanitize_filename(".."), "unnamed");
}
#[test]
fn test_sanitize_path_removes_dotdot() {
assert_eq!(
sanitize_path("foo/../../bar"),
"foo/_/_/bar"
);
}
#[test]
fn test_sanitize_path_removes_dot() {
assert_eq!(sanitize_path("./foo/./bar"), "foo/bar");
}
#[test]
fn test_sanitize_path_backslash() {
assert_eq!(sanitize_path("foo\\..\\bar"), "foo/_/bar");
}
#[test]
fn test_sanitize_html_escapes() {
assert_eq!(
sanitize_html("<script>alert('xss')</script>"),
"&lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;"
);
}
#[test]
fn test_sanitize_html_ampersand() {
assert_eq!(sanitize_html("a & b"), "a &amp; b");
}
#[test]
fn test_truncate_short() {
assert_eq!(truncate("hello", 10), "hello");
}
#[test]
fn test_truncate_long() {
let result = truncate("hello world this is long", 10);
assert_eq!(result.chars().count(), 10);
assert!(result.ends_with('…'));
}
#[test]
fn test_truncate_zero() {
assert_eq!(truncate("hello", 0), "");
}
#[test]
fn test_valid_session_id() {
assert!(is_valid_session_id("abc-123_def"));
assert!(!is_valid_session_id("abc 123"));
assert!(!is_valid_session_id(""));
assert!(!is_valid_session_id("../evil"));
}
}
+136
View File
@@ -0,0 +1,136 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
use std::path::{Path, PathBuf};
const MAX_SLUG_LENGTH: usize = 80;
/// Convert an arbitrary string into a URL / filesystem-safe slug.
///
/// The algorithm:
/// 1. Lowercase the input.
/// 2. Replace any sequence of non-alphanumeric characters (except `-` and `_`)
/// with a single `-`.
/// 3. Strip leading/trailing `-`.
/// 4. If the result is empty, return `None`.
/// 5. Truncate to 80 characters, breaking at the last full word if possible.
///
/// Returns `None` if the slug would be completely empty.
pub fn slugify(s: &str) -> Option<String> {
if s.is_empty() {
return None;
}
let lower = s.to_lowercase();
// Replace non-alphanumeric (except dash/underscore) sequences with '-'
let mut slug = String::with_capacity(lower.len());
let mut prev_was_sep = false;
for c in lower.chars() {
if c.is_alphanumeric() {
slug.push(c);
prev_was_sep = false;
} else if !prev_was_sep {
slug.push('-');
prev_was_sep = true;
}
// else skip consecutive separators
}
// Strip leading/trailing dashes
let slug = slug.trim_matches('-').to_string();
if slug.is_empty() {
return None;
}
// Truncate to MAX_SLUG_LENGTH
let slug = if slug.len() > MAX_SLUG_LENGTH {
let mut truncated: String = slug.chars().take(MAX_SLUG_LENGTH).collect();
// Trim trailing dash from broken word boundary
while truncated.ends_with('-') {
truncated.pop();
}
if truncated.is_empty() {
// If trimming removed everything, take the raw max-length prefix
slug.chars().take(MAX_SLUG_LENGTH).collect()
} else {
truncated
}
} else {
slug
};
Some(slug)
}
/// Join `base` with a slugified version of `name`.
///
/// If `slugify(name)` returns `None`, the name is used as-is (lowercased).
pub fn slug_path(base: &Path, name: &str) -> PathBuf {
match slugify(name) {
Some(slug) => base.join(slug),
None => base.join(name.to_lowercase()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_slugify_basic() {
assert_eq!(slugify("Hello World"), Some("hello-world".into()));
}
#[test]
fn test_slugify_special_chars() {
assert_eq!(slugify("Hello, World! #2"), Some("hello-world-2".into()));
}
#[test]
fn test_slugify_empty() {
assert_eq!(slugify(""), None);
}
#[test]
fn test_slugify_only_separators() {
assert_eq!(slugify("!!! @@"), None);
}
#[test]
fn test_slugify_collapse() {
assert_eq!(slugify("a b---c___d"), Some("a-b-c-d".into()));
}
#[test]
fn test_slugify_leading_trailing() {
assert_eq!(slugify("---hello---"), Some("hello".into()));
}
#[test]
fn test_slugify_dash_underscore_as_separator() {
assert_eq!(slugify("my-slug_here"), Some("my-slug-here".into()));
}
#[test]
fn test_slugify_truncate() {
let long = "a".repeat(100);
let slug = slugify(&long);
assert!(slug.is_some());
assert!(slug.as_ref().unwrap().len() <= MAX_SLUG_LENGTH);
}
#[test]
fn test_slug_path() {
let base = Path::new("/tmp");
assert_eq!(slug_path(base, "Hello World"), Path::new("/tmp/hello-world"));
}
}