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
@@ -0,0 +1,160 @@
//! JSON filebacked `AppConfigRepository`.
//!
//! Path: `<base_dir>/app_config.json`
//!
//! On load, auto-detects Claude credentials from the environment or
//! `~/.claude/settings.json` and merges them into the provider map.
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
use std::io::Write;
use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::domain::app_config::{AppConfig, ModelRole, ProviderConfig};
use crate::domain::repository::AppConfigRepository;
/// Persists `AppConfig` as pretty-printed JSON at `<base_dir>/app_config.json`.
#[derive(Debug, Clone, Default)]
pub struct JsonAppConfigRepository;
impl JsonAppConfigRepository {
/// Create a new repository instance.
pub fn new() -> Self {
Self
}
}
/// Configuration structure inside `~/.claude/settings.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ClaudeEnv {
#[serde(alias = "ANTHROPIC_BASE_URL")]
anthropic_base_url: Option<String>,
#[serde(alias = "ANTHROPIC_API_KEY")]
anthropic_api_key: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ClaudeSettings {
env: Option<ClaudeEnv>,
}
/// Try to read Claude credentials from `~/.claude/settings.json`'s `env` block.
fn claude_credentials_from_file() -> Option<(String, String)> {
let path = dirs::home_dir()?.join(".claude").join("settings.json");
let content = std::fs::read_to_string(&path).ok()?;
let settings: ClaudeSettings = serde_json::from_str(&content).ok()?;
let env = settings.env?;
let base_url = env.anthropic_base_url?;
let key = env.anthropic_api_key?;
Some((base_url, key))
}
/// Try to read Claude credentials from environment variables.
fn claude_credentials_from_env() -> Option<(String, String)> {
let base_url = std::env::var("ANTHROPIC_BASE_URL").ok()?;
let key = std::env::var("ANTHROPIC_API_KEY").ok()?;
Some((base_url, key))
}
/// Return a `ProviderConfig` for the Claude provider, checking both
/// `~/.claude/settings.json` and the process environment.
fn detect_claude_settings_provider() -> Option<ProviderConfig> {
let (base_url, key) = claude_credentials_from_file().or_else(claude_credentials_from_env)?;
Some(ProviderConfig {
api_base: base_url,
api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
default_model: None,
default_api_key: Some(key),
})
}
impl AppConfigRepository for JsonAppConfigRepository {
fn load(&self, base_dir: &Path) -> Result<AppConfig> {
let path = base_dir.join("app_config.json");
let mut cfg: AppConfig = match std::fs::read_to_string(&path) {
Ok(s) => serde_json::from_str(&s)
.map_err(|e| anyhow::anyhow!("failed to parse app_config.json: {e}"))?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::info!("app_config.json not found, using defaults");
AppConfig::default()
}
Err(e) => {
return Err(anyhow::anyhow!("failed to read app_config.json: {e}"));
}
};
// Merge any default providers not present in the loaded config
let defaults = AppConfig::default();
for (name, provider) in defaults.providers {
cfg.providers.entry(name).or_insert(provider);
}
// Auto-detect Claude provider
if let Some(claude_provider) = detect_claude_settings_provider() {
cfg.providers
.entry("claude".to_string())
.or_insert(claude_provider);
let claude_models: [(&str, &str); 3] = [
("claude-opus-4-8", "claude-opus-4-8"),
("claude-sonnet-5", "claude-sonnet-5"),
("claude-haiku-4-5", "claude-haiku-4-5-20251001"),
];
for (role_name, model_name) in &claude_models {
cfg.model_roles
.entry(role_name.to_string())
.or_insert(ModelRole {
provider: "claude".to_string(),
model: model_name.to_string(),
max_tokens: Some(8192),
context_window: Some(200_000),
temperature: Some(0.7),
});
}
// Set as default provider only if user hasn't picked a custom default
if cfg.default_provider == defaults.default_provider {
cfg.default_provider = "claude".to_string();
cfg.default_model = "claude-opus-4-8".to_string();
}
}
Ok(cfg)
}
fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<()> {
std::fs::create_dir_all(base_dir)
.with_context(|| format!("failed to create base dir '{}'", base_dir.display()))?;
let path = base_dir.join("app_config.json");
let tmp = base_dir.join("app_config.json.tmp");
let json = serde_json::to_string_pretty(config)
.context("failed to serialize app config")?;
{
let mut f = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&tmp)
.with_context(|| format!("failed to write temp file '{}'", tmp.display()))?;
f.write_all(json.as_bytes())?;
f.sync_all()?;
}
std::fs::rename(&tmp, &path)
.with_context(|| format!("failed to rename '{}' -> '{}'", tmp.display(), path.display()))?;
if let Some(parent) = path.parent() {
if let Ok(d) = std::fs::File::open(parent) {
let _ = d.sync_all();
}
}
tracing::debug!("app_config saved to '{}'", path.display());
Ok(())
}
}
@@ -0,0 +1,70 @@
//! JSON filebacked `ConversationRepository`.
//!
//! Path: `<session_dir>/conversation.json`
//!
//! Uses write-then-rename with fsync for crash safety.
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
use std::io::Write;
use std::path::Path;
use anyhow::{Context, Result};
use crate::domain::conversation::Conversation;
use crate::domain::repository::ConversationRepository;
/// Persists `Conversation` as pretty-printed JSON at `<session_dir>/conversation.json`.
#[derive(Debug, Clone, Default)]
pub struct JsonConversationRepository;
impl JsonConversationRepository {
/// Create a new repository instance.
pub fn new() -> Self {
Self
}
}
impl ConversationRepository for JsonConversationRepository {
fn load(&self, session_dir: &Path) -> Result<Conversation> {
let path = session_dir.join("conversation.json");
let data = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read conversation at '{}'", path.display()))?;
let conv: Conversation = serde_json::from_str(&data)
.with_context(|| format!("failed to parse conversation at '{}'", path.display()))?;
Ok(conv)
}
fn save(&self, session_dir: &Path, conversation: &Conversation) -> Result<()> {
std::fs::create_dir_all(session_dir)
.with_context(|| format!("failed to create session dir '{}'", session_dir.display()))?;
let path = session_dir.join("conversation.json");
let tmp = session_dir.join("conversation.json.tmp");
let json = serde_json::to_string_pretty(conversation)
.context("failed to serialize conversation")?;
{
let mut f = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&tmp)
.with_context(|| format!("failed to write temp file '{}'", tmp.display()))?;
f.write_all(json.as_bytes())?;
f.sync_all()?;
}
std::fs::rename(&tmp, &path)
.with_context(|| format!("failed to rename '{}' -> '{}'", tmp.display(), path.display()))?;
if let Some(parent) = path.parent() {
if let Ok(d) = std::fs::File::open(parent) {
let _ = d.sync_all();
}
}
tracing::debug!("conversation saved to '{}'", path.display());
Ok(())
}
}
@@ -0,0 +1,106 @@
//! JSONL filebacked `EditLogRepository`.
//!
//! Path: `<session_dir>/edits.jsonl`
//!
//! Append-only log: new entries are appended to the file, never rewritten.
//! In-memory cache is capped at 10K entries to prevent unbounded growth.
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
use std::io::{BufRead, BufReader, Write};
use std::path::Path;
use anyhow::{Context, Result};
use crate::domain::edit_log::{EditLog, EditLogEntry, MAX_MEMORY_ENTRIES};
use crate::domain::repository::EditLogRepository;
/// Persists `EditLog` as an append-only JSONL file at `<session_dir>/edits.jsonl`.
#[derive(Debug, Clone, Default)]
pub struct JsonlEditLogRepository;
impl JsonlEditLogRepository {
/// Create a new repository instance.
pub fn new() -> Self {
Self
}
/// Read existing entries from disk into memory, capped at `MAX_MEMORY_ENTRIES`.
fn load_from_disk(path: &Path) -> Vec<EditLogEntry> {
let Ok(file) = std::fs::File::open(path) else {
return Vec::new();
};
let reader = BufReader::new(file);
let mut entries: Vec<EditLogEntry> = Vec::new();
for line in reader.lines() {
let Ok(line) = line else {
continue;
};
if let Ok(entry) = serde_json::from_str::<EditLogEntry>(&line) {
if entries.len() >= MAX_MEMORY_ENTRIES {
entries.remove(0);
}
entries.push(entry);
}
}
entries
}
}
impl EditLogRepository for JsonlEditLogRepository {
fn open(&self, session_dir: &Path) -> Result<EditLog> {
let path = session_dir.join("edits.jsonl");
// Ensure parent dir exists
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create session dir '{}'", parent.display()))?;
}
let entries = Self::load_from_disk(&path);
// Touch the file if it doesn't exist yet
if !path.exists() {
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("failed to create edits.jsonl at '{}'", path.display()))?;
}
Ok(EditLog { entries })
}
fn append(&self, session_dir: &Path, log: &mut EditLog, entry: EditLogEntry) -> Result<()> {
let path = session_dir.join("edits.jsonl");
let line = serde_json::to_string(&entry)
.context("failed to serialize edit log entry")?
+ "\n";
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create session dir '{}'", parent.display()))?;
}
{
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("failed to open edits.jsonl at '{}'", path.display()))?;
file.write_all(line.as_bytes())
.context("failed to write edit log entry")?;
file.sync_all()
.context("failed to fsync edit log")?;
}
log.entries.push(entry);
// Enforce in-memory cap
if log.entries.len() > MAX_MEMORY_ENTRIES {
log.entries.remove(0);
}
Ok(())
}
fn entries(&self, log: &EditLog) -> Vec<EditLogEntry> {
log.entries.clone()
}
}
@@ -0,0 +1,215 @@
//! Markdown filebacked `MemoryRepository`.
//!
//! Each memory is stored as a `.md` file with YAML-ish frontmatter.
//! Filenames are derived from the memory's `name` via slugification.
//!
//! Frontmatter fields parsed from `---\n...\n---\n` header:
//! name, description, kind, created_at, updated_at, lifecycle,
//! outcome, scope, before, after, provenances
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
use std::collections::HashMap;
use std::io::Write;
use std::path::Path;
use anyhow::{Context, Result};
use crate::domain::memory::Memory;
use crate::domain::repository::MemoryRepository;
/// Persists `Memory` as markdown files with YAML-ish frontmatter.
#[derive(Debug, Clone, Default)]
pub struct MarkdownMemoryRepository;
impl MarkdownMemoryRepository {
/// Create a new repository instance.
pub fn new() -> Self {
Self
}
/// Build the frontmatter lines for a memory.
fn build_frontmatter(memory: &Memory) -> String {
let outcome_line = memory
.outcome
.as_ref()
.map(|o| format!("outcome: {o}\n"))
.unwrap_or_default();
let scope_line = memory
.scope
.as_ref()
.map(|s| format!("scope: {s}\n"))
.unwrap_or_default();
let before_line = memory
.before_snippet
.as_ref()
.map(|s| format!("before: {s}\n"))
.unwrap_or_default();
let after_line = memory
.after_snippet
.as_ref()
.map(|s| format!("after: {s}\n"))
.unwrap_or_default();
let prov_line = if memory.provenances.is_empty() {
String::new()
} else {
format!("provenances: {}\n", memory.provenances.join(", "))
};
format!(
"name: {name}\ndescription: {desc}\nkind: {kind}\n\
created_at: {created}\nupdated_at: {updated}\nlifecycle: {lifecycle}\n\
{outcome}{scope}{before}{after}{prov}",
name = memory.name,
desc = memory.description,
kind = memory.kind,
created = memory.created_at,
updated = memory.updated_at,
lifecycle = memory.lifecycle,
outcome = outcome_line,
scope = scope_line,
before = before_line,
after = after_line,
prov = prov_line,
)
}
/// Parse frontmatter lines into a `HashMap`.
fn parse_frontmatter(front: &str) -> HashMap<String, String> {
front
.lines()
.filter_map(|l| {
let mut it = l.splitn(2, ':');
Some((
it.next()?.trim().to_string(),
it.next()?.trim().to_string(),
))
})
.collect()
}
/// Parse a memory file's contents (frontmatter + body) into a `Memory`.
fn parse(content: &str) -> std::io::Result<Memory> {
let content = content.strip_prefix("---\n").unwrap_or(content);
let parts: Vec<&str> = content.splitn(2, "\n---\n").collect();
if parts.len() < 2 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"missing frontmatter",
));
}
let front = Self::parse_frontmatter(parts[0]);
let body = parts.get(1).unwrap_or(&"").trim().to_string();
Ok(Memory {
name: front.get("name").cloned().unwrap_or_default(),
description: front.get("description").cloned().unwrap_or_default(),
content: body,
kind: front
.get("kind")
.cloned()
.unwrap_or_else(|| "reference".to_string()),
created_at: front
.get("created_at")
.and_then(|v| v.parse().ok())
.unwrap_or(0),
updated_at: front
.get("updated_at")
.and_then(|v| v.parse().ok())
.unwrap_or(0),
outcome: front.get("outcome").cloned().filter(|s| !s.is_empty()),
lifecycle: front
.get("lifecycle")
.cloned()
.unwrap_or_else(|| "new".to_string()),
scope: front.get("scope").cloned().filter(|s| !s.is_empty()),
before_snippet: front.get("before").cloned().filter(|s| !s.is_empty()),
after_snippet: front.get("after").cloned().filter(|s| !s.is_empty()),
provenances: front
.get("provenances")
.cloned()
.map(|s| {
s.split(", ")
.map(std::string::ToString::to_string)
.collect()
})
.unwrap_or_default(),
})
}
}
impl MemoryRepository for MarkdownMemoryRepository {
fn list(&self, memory_dir: &Path) -> Result<Vec<String>> {
let Ok(entries) = std::fs::read_dir(memory_dir) else {
return Ok(Vec::new());
};
let slugs: Vec<String> = entries
.filter_map(std::result::Result::ok)
.filter(|e| e.path().extension().is_some_and(|x| x == "md"))
.filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string();
// Skip special summary file
if name == "MEMORY.md" {
return None;
}
name.strip_suffix(".md").map(std::string::ToString::to_string)
})
.collect();
Ok(slugs)
}
fn load(&self, memory_dir: &Path, name: &str) -> Result<Memory> {
let path = Memory::path(memory_dir, name);
let content = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read memory '{name}' at '{}'", path.display()))?;
let memory = Self::parse(&content)
.map_err(|e| anyhow::anyhow!("failed to parse memory '{name}': {e}"))?;
Ok(memory)
}
fn save(&self, memory_dir: &Path, memory: &Memory) -> Result<()> {
let path = Memory::path(memory_dir, &memory.name);
let parent = path.parent().unwrap();
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create memory dir '{}'", parent.display()))?;
let frontmatter = Self::build_frontmatter(memory);
let content = format!("---\n{frontmatter}---\n\n{}", memory.content);
let tmp = parent.join(format!(".{}.tmp", uuid::Uuid::new_v4()));
{
let mut f = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&tmp)
.with_context(|| format!("failed to write temp file '{}'", tmp.display()))?;
f.write_all(content.as_bytes())?;
f.sync_all()?;
}
std::fs::rename(&tmp, &path)
.with_context(|| format!("failed to rename '{}' -> '{}'", tmp.display(), path.display()))?;
if let Some(p) = path.parent() {
if let Ok(d) = std::fs::File::open(p) {
let _ = d.sync_all();
}
}
tracing::debug!("memory saved to '{}'", path.display());
Ok(())
}
fn delete(&self, memory_dir: &Path, name: &str) -> Result<()> {
let path = Memory::path(memory_dir, name);
if path.exists() {
std::fs::remove_file(&path)
.with_context(|| format!("failed to delete memory '{name}' at '{}'", path.display()))?;
tracing::debug!("memory deleted: '{}'", path.display());
} else {
tracing::warn!("memory '{name}' not found at '{}', skipping delete", path.display());
}
Ok(())
}
}
@@ -0,0 +1,20 @@
//! Persistence adapters — concrete file-based repository implementations.
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
pub mod app_config_repo;
pub mod conversation_repo;
pub mod edit_log_repo;
pub mod memory_repo;
pub mod settings_repo;
pub use app_config_repo::JsonAppConfigRepository;
pub use conversation_repo::JsonConversationRepository;
pub use edit_log_repo::JsonlEditLogRepository;
pub use memory_repo::MarkdownMemoryRepository;
pub use settings_repo::JsonSettingsRepository;
@@ -0,0 +1,74 @@
//! JSON filebacked `SettingsRepository`.
//!
//! Path: `<base_dir>/settings.json`
//!
//! Uses write-then-rename with fsync for crash safety.
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
use std::io::Write;
use std::path::Path;
use anyhow::{Context, Result};
use crate::domain::repository::SettingsRepository;
use crate::domain::settings::Settings;
/// Persists `Settings` as pretty-printed JSON at `<base_dir>/settings.json`.
#[derive(Debug, Clone, Default)]
pub struct JsonSettingsRepository;
impl JsonSettingsRepository {
/// Create a new repository instance.
pub fn new() -> Self {
Self
}
}
impl SettingsRepository for JsonSettingsRepository {
fn load(&self, base_dir: &Path) -> Result<Settings> {
let path = base_dir.join("settings.json");
match std::fs::read_to_string(&path) {
Ok(s) => serde_json::from_str(&s)
.map_err(|e| anyhow::anyhow!("failed to parse settings.json: {e}")),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::info!("settings.json not found, using defaults");
Ok(Settings::default())
}
Err(e) => Err(anyhow::anyhow!("failed to read settings.json: {e}")),
}
}
fn save(&self, base_dir: &Path, settings: &Settings) -> Result<()> {
std::fs::create_dir_all(base_dir)
.with_context(|| format!("failed to create base dir '{}'", base_dir.display()))?;
let path = base_dir.join("settings.json");
let tmp = base_dir.join("settings.json.tmp");
let json = serde_json::to_string_pretty(settings)
.context("failed to serialize settings")?;
{
let mut f = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&tmp)
.with_context(|| format!("failed to write temp file '{}'", tmp.display()))?;
f.write_all(json.as_bytes())?;
f.sync_all()?;
}
std::fs::rename(&tmp, &path)
.with_context(|| format!("failed to rename '{}' -> '{}'", tmp.display(), path.display()))?;
if let Some(parent) = path.parent() {
if let Ok(d) = std::fs::File::open(parent) {
let _ = d.sync_all();
}
}
tracing::debug!("settings saved to '{}'", path.display());
Ok(())
}
}