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,18 @@
//! Bash mode: handles submitting a shell command from the bash input panel.
use crate::app::state::rest::AppStateRest;
/// Launch a background bash job for the submitted command.
///
/// Flow: ignore empty input → spawn the job (fire-and-forget, the job's
/// output is polled elsewhere via `bgbash::control`) → mark state dirty
/// so the TUI re-renders.
///
/// Why: the returned `BashJob` handle is intentionally dropped — this
/// function only needs to kick the job off; the job registers itself in
/// the shared jobs map for later polling.
pub fn handle_bash_submit(state: &mut AppStateRest, command: String) {
if !command.is_empty() {
let _ = crate::app::bgbash::job::spawn_bash_job(command);
state.dirty = true;
}
}
@@ -0,0 +1,145 @@
//! Editor mode: a minimal in-TUI line editor for viewing/modifying a file,
//! with bounded undo history.
use crate::app::state::rest::AppStateRest;
use crate::app::state::types::Overlay;
/// State for the built-in line editor overlay: buffer contents, cursor
/// position, and a bounded undo stack.
#[derive(Debug, Clone)]
pub struct EditorState {
pub path: String,
pub content: Vec<String>,
pub undo_stack: Vec<Vec<String>>,
pub cursor_line: usize,
pub cursor_col: usize,
}
impl Default for EditorState {
fn default() -> Self {
EditorState {
path: String::new(),
content: vec![String::new()],
undo_stack: Vec::new(),
cursor_line: 0,
cursor_col: 0,
}
}
}
impl EditorState {
/// Create a fresh editor state for `path`, seeded with existing content
/// (or a single empty line for a new file).
pub fn open(path: String, existing_content: Option<Vec<String>>) -> Self {
let content = existing_content.unwrap_or_else(|| vec![String::new()]);
EditorState {
path,
content,
..Default::default()
}
}
/// Insert a new empty line immediately after the cursor line.
///
/// Why: snapshots content to the undo stack first, matching every other
/// mutating method here.
pub fn insert_line_after(&mut self) {
self.save_undo();
let pos = (self.cursor_line + 1).min(self.content.len());
self.content.insert(pos, String::new());
}
/// Push a snapshot of the current content onto the undo stack, capped at 50 entries.
///
/// Why: `remove(0)` on overflow bounds memory use at the cost of O(n)
/// shifting; the cap (50) keeps that cost negligible in practice.
fn save_undo(&mut self) {
self.undo_stack.push(self.content.clone());
if self.undo_stack.len() > 50 {
self.undo_stack.remove(0);
}
}
/// Move the cursor down one line, clamping the column to the new line's length.
pub fn cursor_down(&mut self) {
if self.cursor_line + 1 < self.content.len() {
self.cursor_line += 1;
}
self.cursor_col = self.cursor_col.min(
self.content
.get(self.cursor_line)
.map_or(0, std::string::String::len),
);
}
/// Insert a character at the cursor and advance the cursor past it.
pub fn insert_char(&mut self, c: char) {
self.save_undo();
if let Some(line) = self.content.get_mut(self.cursor_line) {
line.insert(self.cursor_col, c);
self.cursor_col += 1;
}
}
/// Delete the character before the cursor (backspace).
///
/// Flow: if not at column 0, remove the preceding char on this line →
/// otherwise (start of line, not the first line) merge this line into
/// the previous one, joining at the old line's end.
pub fn delete_left(&mut self) {
self.save_undo();
if let Some(line) = self.content.get_mut(self.cursor_line) {
if self.cursor_col > 0 {
self.cursor_col -= 1;
line.remove(self.cursor_col);
} else if self.cursor_line > 0 {
let prev_len = self.content[self.cursor_line - 1].len();
let rest = self.content.remove(self.cursor_line);
self.cursor_line -= 1;
self.cursor_col = prev_len;
self.content[self.cursor_line].push_str(&rest);
}
}
}
/// Join all lines with `\n` into the full file contents, for saving.
pub fn as_string(&self) -> String {
self.content.join("\n")
}
}
/// Feed a chunk of typed text into the active editor, translating newlines
/// and tabs into editor operations.
///
/// Flow: no-op if no editor is open → for each char: `\n`/`\r` inserts a
/// line and moves down, `\t` inserts two spaces, everything else inserts
/// the char directly → mark state dirty.
pub fn handle_editor_input(state: &mut AppStateRest, text: &str) {
let editor = &mut state.misc.editor;
let Some(ed) = editor.as_mut() else {
return;
};
for c in text.chars() {
match c {
'\n' | '\r' => {
ed.insert_line_after();
ed.cursor_down();
ed.cursor_col = 0;
}
'\t' => {
ed.insert_char(' ');
ed.insert_char(' ');
}
_ => {
ed.insert_char(c);
}
}
}
state.dirty = true;
}
/// Close the editor overlay without saving, clearing editor state.
pub fn handle_editor_dismiss(state: &mut AppStateRest) {
state.misc.editor = None;
state.misc.overlay = Overlay::None;
state.dirty = true;
}
@@ -0,0 +1,55 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Effort mode: cycles the agent's reasoning effort level, which scales the
//! LLM's temperature and `max_tokens` for subsequent turns.
use crate::app::state::rest::AppStateRest;
pub const EFFORT_LEVELS: &[&str] = &["low", "medium", "high", "xhigh", "max"];
/// Multiplier applied to the user's configured `max_tokens`, and the temperature to use,
/// for each entry in `EFFORT_LEVELS` (same index). Higher effort trades a larger token
/// budget for lower temperature (more deterministic, more room to reason/act).
const MAX_TOKENS_MULTIPLIER: &[f32] = &[0.5, 1.0, 1.5, 2.0, 3.0];
const TEMPERATURE_OVERRIDE: &[f32] = &[0.9, 0.7, 0.5, 0.3, 0.1];
/// Maps an effort level index to the `(temperature, max_tokens)` pair that should be sent
/// to the LLM, scaling the user's configured `max_tokens` by the level's multiplier.
pub fn generation_params(level: usize, base_max_tokens: Option<u32>) -> (f32, Option<u32>) {
let idx = level.min(EFFORT_LEVELS.len() - 1);
let temperature = TEMPERATURE_OVERRIDE[idx];
let max_tokens = base_max_tokens.map(|t| ((t as f32) * MAX_TOKENS_MULTIPLIER[idx]) as u32);
(temperature, max_tokens.map(|t| t.max(256)))
}
/// Return the current effort level index, clamped to a valid `EFFORT_LEVELS` slot.
///
/// Why: clamping guards against a stale/out-of-range value in loaded state
/// (e.g. after `EFFORT_LEVELS` shrinks between versions).
pub fn current_effort(state: &AppStateRest) -> usize {
state.misc.effort_level.min(EFFORT_LEVELS.len() - 1)
}
/// Return the current effort level's display name (e.g. "medium").
pub fn current_effort_str(state: &AppStateRest) -> &'static str {
let idx = current_effort(state);
EFFORT_LEVELS[idx]
}
/// Advance to the next effort level, wrapping around, and toast the new value.
///
/// Flow: compute `(current + 1) % len` → store it → push an info toast with
/// the new level's label → mark state dirty.
pub fn cycle_effort(state: &mut AppStateRest) {
let current = current_effort(state);
state.misc.effort_level = (current + 1) % EFFORT_LEVELS.len();
let label = current_effort_str(state);
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Info,
format!("Effort: {label}"),
));
state.dirty = true;
}
@@ -0,0 +1,38 @@
//! Help mode: static help text and the action that opens/closes the help overlay.
use crate::app::runtime::actions::Action;
use crate::app::state::types::Overlay;
pub const HELP_TEXT: &str = "\
Keybindings:
Ctrl+C Quit
Ctrl+D Close overlay
Ctrl+H Help
Ctrl+P Settings
Ctrl+A Toggle yolo arm
Ctrl+B Bash panel
Ctrl+T Todo panel
Ctrl+W Workflow panel
Ctrl+K Key input
Ctrl+L Learning dashboard
Ctrl+U Usage dashboard
Esc Close overlay
Enter Submit / confirm
Slash commands:
/help Show this help
/quit Quit session
/mode <name> Switch mode (chat, bash, workflow)
/clear Clear transcript";
/// Route an incoming action while the help overlay is open.
///
/// Flow: `CloseOverlay` passes through unchanged; any other action is
/// treated as "open help" (idempotent — re-opens the overlay it's already on).
///
/// Return: the `Action` to actually dispatch.
pub fn handle_help_action(action: &Action) -> Action {
match action {
Action::CloseOverlay => Action::CloseOverlay,
_ => Action::OpenOverlay(Overlay::Help),
}
}
@@ -0,0 +1,8 @@
//! Key input mode: raw text capture overlay used for one-off key/text prompts.
use crate::app::state::rest::AppStateRest;
/// Replace the input buffer with the given text and mark state dirty.
pub fn handle_key_text(state: &mut AppStateRest, text: String) {
state.input.buffer = text;
state.dirty = true;
}
@@ -0,0 +1,72 @@
use crate::app::state::rest::AppStateRest;
/// A unified representation of a lesson item for the interactive TUI overlay.
#[derive(Debug, Clone)]
pub enum LearningItem {
Pending {
name: String,
content: String,
scope: String,
confidence: String,
},
Stored {
name: String,
content: String,
lifecycle: String,
scope: String,
description: String,
},
}
/// Dynamically read all pending and stored lessons.
pub fn get_learning_items(state: &AppStateRest) -> Vec<LearningItem> {
let mut items = Vec::new();
// 1. Load pending lessons from session directory
let pending = if let Some(ref rt) = state.session_runtime {
crate::app::review::load_pending_lessons(&rt.session_dir)
} else {
Vec::new()
};
for p in pending {
let scope_str = match p.lesson.scope {
crate::app::review::LessonScope::Project => "project",
crate::app::review::LessonScope::Global => "global",
}
.to_string();
let conf_str = match p.lesson.confidence {
crate::app::review::Confidence::Human => "human",
crate::app::review::Confidence::Verified => "verified",
crate::app::review::Confidence::Unverified => "unverified",
crate::app::review::Confidence::Auto => "auto",
}
.to_string();
items.push(LearningItem::Pending {
name: p.lesson.name,
content: p.lesson.content,
scope: scope_str,
confidence: conf_str,
});
}
// 2. Load stored memory lessons from long-term memory directory
let names = crate::model::memory::Memory::list(&state.memory_dir);
for name in names {
if let Ok(mem) = crate::model::memory::Memory::read(&state.memory_dir, &name) {
if mem.kind == "lesson" {
items.push(LearningItem::Stored {
name: mem.name,
content: mem.content,
lifecycle: mem.lifecycle,
scope: mem.scope.unwrap_or_else(|| "project".to_string()),
description: mem.description,
});
}
}
}
items
}
@@ -0,0 +1,14 @@
//! Loading mode: transient overlay shown while waiting on an async operation.
use crate::app::state::rest::AppStateRest;
pub const LOADING_MESSAGES: &[&str] = &[
"processing...",
"thinking...",
"working...",
"almost done...",
];
/// Mark state dirty to force a re-render (e.g. to advance the loading spinner/message).
pub fn resolve_loading(state: &mut AppStateRest) {
state.dirty = true;
}
+11
View File
@@ -0,0 +1,11 @@
//! MCP mode: overlay for connecting to a configured MCP server.
use crate::app::state::rest::AppStateRest;
/// Placeholder entry point for connecting to an MCP server by name.
///
/// Why: not yet wired to `McpManager::connect_stdio` — currently just
/// marks state dirty so the overlay re-renders.
pub fn connect_mcp(state: &mut AppStateRest, server_name: &str) {
let _ = server_name;
state.dirty = true;
}
+13
View File
@@ -0,0 +1,13 @@
//! TUI mode definitions and per-mode input/action handlers, one submodule
//! per overlay/mode (bash, editor, effort, mcp, quit confirm, rewind, etc.).
pub mod bash;
pub mod editor;
pub mod effort;
pub mod key_input;
pub mod mcp;
pub mod learning;
pub mod quit_confirm;
pub mod rewind;
pub mod settings;
pub mod todo;
@@ -0,0 +1,14 @@
//! Quit-confirm mode: the "are you sure?" overlay shown before exiting.
use crate::app::runtime::actions::Action;
/// Translate the user's yes/no answer on the quit-confirm overlay into an action.
///
/// Return: `Action::ForceQuit` if confirmed, otherwise `Action::CloseOverlay`
/// to dismiss the prompt without quitting.
pub fn handle_quit_confirm(yes: bool) -> Action {
if yes {
Action::ForceQuit
} else {
Action::CloseOverlay
}
}
@@ -0,0 +1,135 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Rewind mode: restores a file to a pre-edit snapshot stored in the
//! session's `SQLite` blob store.
use crate::app::state::rest::AppStateRest;
use sha2::Digest;
/// Returns the number of stored pre-edit blobs (snapshots) for this session.
pub fn rewind_count(state: &AppStateRest) -> usize {
let Ok(conn) = open_session_db(&state.session_dir) else {
return 0;
};
crate::model::msglog::blobs::list_blob_keys(&conn, &state.session_id)
.ok()
.map_or(0, |keys| keys.len())
}
/// Restores a file to its pre-edit state by retrieving the blob stored under index
/// `index` (0 = oldest). Opens a fresh `SQLite` connection so this works outside
/// of a running turn (e.g. from the Rewind overlay).
pub fn rewind_to(state: &mut AppStateRest, index: usize) {
let conn = match open_session_db(&state.session_dir) {
Ok(c) => c,
Err(e) => {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error,
format!("Failed to open session DB: {e}"),
));
state.dirty = true;
return;
}
};
let keys = match crate::model::msglog::blobs::list_blob_keys(&conn, &state.session_id) {
Ok(k) => k,
Err(e) => {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error,
format!("Failed to list snapshots: {e}"),
));
state.dirty = true;
return;
}
};
if keys.is_empty() || index >= keys.len() {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Warning,
"No snapshot available at that index".to_string(),
));
state.dirty = true;
return;
}
let blob_key = &keys[index];
let bytes = match crate::model::msglog::blobs::retrieve_blob(&conn, &state.session_id, blob_key)
{
Ok(Some(b)) => b,
Ok(None) => {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error,
"Snapshot data not found".to_string(),
));
state.dirty = true;
return;
}
Err(e) => {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error,
format!("Failed to retrieve snapshot: {e}"),
));
state.dirty = true;
return;
}
};
// Look up the path from the edit log — the blob key is the tool_call_id.
// The edit log doesn't store the tool_call_id directly, so fall back to the
// path from the most recent write/edit entry.
let restore_path =
find_edit_path(state, blob_key).unwrap_or_else(|| state.session_dir.join("snapshot.dat"));
match std::fs::write(&restore_path, &bytes) {
Ok(()) => {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Success,
format!("Restored {} from snapshot", restore_path.display()),
));
}
Err(e) => {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error,
format!("Failed to write restored file: {e}"),
));
}
}
// Log the rewind itself as an edit entry
let mut el = crate::model::editlog::EditLog::new(&state.session_dir);
let entry = crate::model::editlog::EditLogEntry {
ts: chrono::Utc::now().timestamp_millis(),
tool: "rewind".to_string(),
path: restore_path.to_string_lossy().to_string(),
reason: format!("rewind_to({index})"),
content_sha256: hex::encode(sha2::Sha256::digest(&bytes)),
bytes_delta: bytes.len() as i64,
origin: crate::app::state::types::Origin::Main.tag(),
session_id: state.session_id.clone(),
};
let _ = el.append(entry);
// Clear the transcript to force a refresh
state.transcript_cache.dirty = true;
state.dirty = true;
}
fn open_session_db(session_dir: &std::path::Path) -> anyhow::Result<rusqlite::Connection> {
let path = session_dir.join("messages.sqlite");
let conn = rusqlite::Connection::open(&path)?;
Ok(conn)
}
fn find_edit_path(state: &AppStateRest, _blob_key: &str) -> Option<std::path::PathBuf> {
let el = crate::model::editlog::EditLog::new(&state.session_dir);
let entry = el
.entries
.iter()
.rev()
.find(|e| e.tool == "write" || e.tool == "edit")?;
Some(std::path::PathBuf::from(&entry.path))
}
@@ -0,0 +1,22 @@
//! Settings-mode helper logic for the TUI settings overlay.
//!
//! Flow: exposes small mutation functions (currently just cycling the
//! internet access mode) invoked by keybindings while the settings overlay
//! is active.
use crate::model::settings::{InternetMode, Settings};
/// Advance the internet access mode to the next value in the cycle.
///
/// Flow: Off -> `ReadOnly` -> Full -> Off, wrapping around.
///
/// Why: used by a settings-toggle keybinding to step through modes
/// without needing a dropdown/menu.
///
/// Return: nothing; mutates `settings.internet_mode` in place.
pub fn cycle_internet_mode(settings: &mut Settings) {
settings.internet_mode = match settings.internet_mode {
InternetMode::Off => InternetMode::ReadOnly,
InternetMode::ReadOnly => InternetMode::Full,
InternetMode::Full => InternetMode::Off,
};
}
@@ -0,0 +1,23 @@
//! Todo-mode helper logic for the TUI todo-list overlay.
//!
//! Flow: exposes the toggle handler invoked by a keybinding to show/hide
//! the todo overlay.
use crate::app::state::rest::AppStateRest;
use crate::app::state::types::Overlay;
/// Toggle the todo-list overlay open or closed.
///
/// Flow: if the todo overlay is currently shown, hide it (set to `Overlay::None`);
/// otherwise show it.
///
/// Why: marks state dirty so the TUI re-renders on the next frame.
///
/// Return: nothing; mutates `state.misc.overlay` and `state.dirty` in place.
pub fn handle_todo_toggle(state: &mut AppStateRest) {
if state.misc.overlay == Overlay::Todo {
state.misc.overlay = Overlay::None;
} else {
state.misc.overlay = Overlay::Todo;
}
state.dirty = true;
}