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,77 @@
//! Tool: `delete` — remove a file or empty directory relative to a workspace root.
use super::super::resolve_path;
use super::super::Tool;
use super::super::ToolCtx;
use super::helpers::arg_str;
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
use std::fs;
use std::path::PathBuf;
/// Tool: delete a file or empty directory. Refuses non-empty directories.
pub struct Delete;
impl Tool for Delete {
fn name(&self) -> &'static str {
"delete"
}
fn description(&self) -> &'static str {
"Delete a file or empty directory. Will not delete non-empty directories."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file or directory to delete (relative to workspace root)"
},
"reason": {
"type": "string",
"description": "Reason for the deletion (must be non-empty, >= 8 chars)"
}
},
"required": ["path", "reason"]
})
}
/// Delete a file or empty directory. Returns success message or errors on failure.
///
/// Flow: resolve path → check existence → check dir/file → remove.
/// Only empty directories are deletable (non-empty returns an error).
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel = arg_str(args, "path")?;
let path: PathBuf = resolve_path(&ctx.workspaces, &rel)?;
if !path.exists() {
return Ok(format!(
"path '{}' does not exist (resolved to {})",
rel,
path.display()
));
}
let metadata = path
.metadata()
.map_err(|e| anyhow!("failed to read metadata for '{rel}': {e}"))?;
if metadata.is_dir() {
let is_empty = fs::read_dir(&path)
.map_err(|e| anyhow!("failed to read directory '{rel}': {e}"))?
.next()
.is_none();
if is_empty {
fs::remove_dir(&path)
.map_err(|e| anyhow!("failed to remove directory '{rel}': {e}"))?;
Ok(format!("removed empty directory {rel}"))
} else {
anyhow::bail!("directory '{rel}' is not empty (refusing to delete)");
}
} else {
fs::remove_file(&path).map_err(|e| anyhow!("failed to delete '{rel}': {e}"))?;
Ok(format!("deleted {rel}"))
}
}
}
+203
View File
@@ -0,0 +1,203 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Tool: `edit` — replace a substring in a file with a new string.
use super::super::check_graduated_checks;
use super::super::resolve_path;
use super::super::Tool;
use super::super::ToolCtx;
use super::helpers::{self, arg_str};
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
use similar::TextDiff;
use std::fs;
use std::path::PathBuf;
/// Tool: replace text in a file. Requires the old string to be unique unless `replace_all` is true.
pub struct Edit;
impl Tool for Edit {
fn name(&self) -> &'static str {
"edit"
}
fn description(&self) -> &'static str {
"Replace a string in a file with a new string. The old string must be unique unless replace_all is true."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file to edit (relative to workspace root)"
},
"old": {
"type": "string",
"description": "The exact text to replace"
},
"new": {
"type": "string",
"description": "The replacement text"
},
"replace_all": {
"type": "boolean",
"description": "Replace all occurrences instead of requiring uniqueness"
},
"reason": {
"type": "string",
"description": "Reason for the change (must be non-empty)"
}
},
"required": ["path", "old", "new", "reason"]
})
}
/// Perform the in-file string replacement.
///
/// Flow: validate args → resolve path → read file → count occurrences →
/// replace one or all → write back → report byte delta (+ optional graduated checks).
///
/// Why: requires a non-empty `reason` and a non-empty `old` string to prevent
/// accidental identity edits. Enforces uniqueness unless `replace_all` is set.
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel = arg_str(args, "path")?;
let old = arg_str(args, "old")?;
let new_str = arg_str(args, "new")?;
let reason = arg_str(args, "reason")?;
if reason.trim().is_empty() {
anyhow::bail!("reason must be a non-empty string");
}
if old.is_empty() {
anyhow::bail!(
"'old' must be a non-empty string; use 'write' to replace entire file contents"
);
}
let check_matches = check_graduated_checks(&rel, &new_str, &ctx.graduated_checks);
let replace_all = args
.get("replace_all")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let path: PathBuf = resolve_path(&ctx.workspaces, &rel)?;
if !path.exists() {
anyhow::bail!(
"file '{}' does not exist at resolved path {}",
rel,
path.display()
);
}
if path.is_dir() {
anyhow::bail!("'{rel}' is a directory, not a file");
}
let content =
fs::read_to_string(&path).map_err(|e| anyhow!("failed to read '{rel}': {e}"))?;
if !content.contains(&old) {
anyhow::bail!("old string not found in '{rel}'");
}
if !replace_all {
let count = content.matches(&old).count();
if count > 1 {
anyhow::bail!(
"old string appears {count} times in '{rel}'. Set replace_all=true to replace all occurrences, or provide a more specific match."
);
}
}
let new_content = if replace_all {
content.replace(&old, &new_str)
} else {
content.replacen(&old, &new_str, 1)
};
fs::write(&path, &new_content).map_err(|e| anyhow!("failed to write '{rel}': {e}"))?;
let text_diff = TextDiff::from_lines(content.as_str(), new_content.as_str());
let diff_text = format!(
"{}",
text_diff
.unified_diff()
.context_radius(3)
.header(&rel, &rel)
);
let diff_block = format!("```diff\n{}\n```", helpers::truncate_diff(&diff_text));
// Notify the LSP server of the on-disk change so diagnostics stay fresh.
// Never fail the edit because of this — LSP errors are surfaced as a
// trailing annotation on the success message instead.
let lsp_note = if let Ok(mut lsp) = ctx.lsp_manager.lock() {
lsp.did_change_file(&path);
String::new()
} else {
String::new()
};
if check_matches.is_empty() {
Ok(format!("edited {rel}\n{diff_block}{lsp_note}"))
} else {
Ok(format!(
"edited {rel}. Graduated checks matched: {}\n{diff_block}{lsp_note}",
check_matches.join(", ")
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_ctx(workspace: std::path::PathBuf) -> crate::tool::ToolCtx {
crate::tool::ToolCtx::builder()
.workspaces(vec![workspace])
.build()
}
fn temp_workspace() -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("zesdex-edit-test-{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn edit_returns_a_diff_block_for_a_single_replace() {
let workspace = temp_workspace();
fs::write(workspace.join("a.txt"), "line1\nline2\nline3\n").unwrap();
let ctx = test_ctx(workspace.clone());
let args = json!({
"path": "a.txt",
"old": "line2",
"new": "changed",
"reason": "test edit"
});
let result = Edit.run(&ctx, &args).unwrap();
assert!(result.contains("```diff"));
assert!(result.contains("-line2"));
assert!(result.contains("+changed"));
fs::remove_dir_all(&workspace).ok();
}
#[test]
fn edit_truncates_a_very_large_diff() {
let workspace = temp_workspace();
let old_content: String = (0..300).fold(String::new(), |mut acc, i| {
use std::fmt::Write;
let _ = writeln!(acc, "line{i}");
acc
});
let new_content: String = (0..300).fold(String::new(), |mut acc, i| {
use std::fmt::Write;
let _ = writeln!(acc, "changed{i}");
acc
});
fs::write(workspace.join("big.txt"), &old_content).unwrap();
let ctx = test_ctx(workspace.clone());
let args = json!({
"path": "big.txt",
"old": &old_content,
"new": &new_content,
"reason": "test large replace"
});
let result = Edit.run(&ctx, &args).unwrap();
assert!(result.contains("more lines truncated"));
fs::remove_dir_all(&workspace).ok();
}
}
@@ -0,0 +1,119 @@
//! Shared helpers for filesystem tools: extracting string arguments from JSON
//! and producing user-friendly "not found" diagnostics.
use anyhow::{anyhow, Result};
use serde_json::Value;
use std::path::Path;
/// Extract a required string argument from a JSON args map.
///
/// Return: the value as `String` if present and a string type; `Err` if missing
/// or of a different JSON type (null, number, boolean, array, object).
pub fn arg_str(args: &Value, name: &str) -> Result<String> {
args.get(name)
.and_then(|v| v.as_str())
.map(std::string::ToString::to_string)
.ok_or_else(|| anyhow!("missing required argument: {name}"))
}
/// Produce a user-friendly diagnostic string when a path doesn't resolve or exist.
///
/// Checks whether the resolved path canonically falls inside any workspace root
/// and reports either "path outside workspaces" or "path does not exist" accordingly.
///
/// Return: a one-line description of the resolution failure.
pub fn not_found_help(ctx: &super::super::ToolCtx, path: &Path, rel: &str) -> String {
let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let in_ws = ctx.workspaces.iter().any(|w| {
let wc = w.canonicalize().unwrap_or_else(|_| w.clone());
canon.starts_with(&wc)
});
if in_ws {
format!(
"path '{}' does not exist (resolved to {})",
rel,
canon.display()
)
} else {
format!(
"path '{}' is outside all workspace roots. Workspace roots: {}",
rel,
ctx.workspaces
.iter()
.map(|w| w.display().to_string())
.collect::<Vec<_>>()
.join(", ")
)
}
}
/// Maximum number of lines a diff block may contain before being truncated.
pub const MAX_DIFF_LINES: usize = 200;
/// Cap a unified diff at `MAX_DIFF_LINES` lines, appending a truncation note.
///
/// Return: `diff` unchanged if it's within the limit; otherwise the first
/// `MAX_DIFF_LINES` lines followed by `"... ({N} more lines truncated)"`.
pub fn truncate_diff(diff: &str) -> String {
let lines: Vec<&str> = diff.lines().collect();
if lines.len() <= MAX_DIFF_LINES {
return diff.to_string();
}
let remaining = lines.len() - MAX_DIFF_LINES;
format!(
"{}\n... ({remaining} more lines truncated)",
lines[..MAX_DIFF_LINES].join("\n")
)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_arg_str_found() {
let args = json!({"key": "value"});
assert_eq!(arg_str(&args, "key").unwrap(), "value");
}
#[test]
fn test_arg_str_missing() {
let args = json!({"other": "value"});
assert!(arg_str(&args, "key").is_err());
}
#[test]
fn test_arg_str_empty_string() {
let args = json!({"key": ""});
assert_eq!(arg_str(&args, "key").unwrap(), "");
}
#[test]
fn test_arg_str_wrong_type() {
let args = json!({"key": 42});
assert!(arg_str(&args, "key").is_err());
}
#[test]
fn test_arg_str_null() {
let args = json!({"key": null});
assert!(arg_str(&args, "key").is_err());
}
#[test]
fn test_truncate_diff_under_limit_unchanged() {
let diff = "line1\nline2\nline3";
assert_eq!(truncate_diff(diff), diff);
}
#[test]
fn test_truncate_diff_over_limit_truncates() {
let diff = (0..250)
.map(|i| format!("line{i}"))
.collect::<Vec<_>>()
.join("\n");
let result = truncate_diff(&diff);
assert!(result.contains("... (50 more lines truncated)"));
assert_eq!(result.lines().count(), MAX_DIFF_LINES + 1);
}
}
+7
View File
@@ -0,0 +1,7 @@
//! Filesystem tool implementations: read, write, edit, and delete operations
//! on workspace-rooted paths.
pub mod delete;
pub mod edit;
pub mod helpers;
pub mod read;
pub mod write;
+95
View File
@@ -0,0 +1,95 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Tool: `read` — display file contents with line numbers.
use super::super::resolve_path;
use super::super::Tool;
use super::super::ToolCtx;
use super::helpers::{arg_str, not_found_help};
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
use std::fs;
use std::path::PathBuf;
/// Tool: read a file and display it with line numbers, optionally truncated to `limit` lines.
pub struct Read;
impl Tool for Read {
fn name(&self) -> &'static str {
"read"
}
fn description(&self) -> &'static str {
"Read the contents of a file and display it with line numbers"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file to read (relative to workspace root, or [N]prefix for other workspaces)"
},
"limit": {
"type": "integer",
"description": "Maximum number of lines to return (optional)"
}
},
"required": ["path"]
})
}
/// Read and display a file with line numbers.
///
/// Flow: resolve path → if not found, call `not_found_help` for diagnostic →
/// read entire file → enumerate and format lines → optionally truncate by `limit`.
///
/// Return: line-numbered content; `not_found_help` message if the path doesn't
/// exist; a "is a directory" message if the path points at a directory.
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel = arg_str(args, "path")?;
let limit = args
.get("limit")
.and_then(serde_json::Value::as_u64)
.map(|v| v as usize);
let path: PathBuf = match resolve_path(&ctx.workspaces, &rel) {
Ok(p) => p,
Err(_e) => return Ok(not_found_help(ctx, &PathBuf::from(&rel), &rel)),
};
if !path.exists() {
return Ok(not_found_help(ctx, &path, &rel));
}
if path.is_dir() {
return Ok(format!(
"'{rel}' is a directory, not a file. Use ls or glob to list directory contents."
));
}
let content =
fs::read_to_string(&path).map_err(|e| anyhow!("failed to read '{rel}': {e}"))?;
let lines: Vec<&str> = content.lines().collect();
let total = lines.len();
let take = limit.unwrap_or(total).min(total);
let result: String = lines[..take]
.iter()
.enumerate()
.map(|(i, line)| format!("{}\t{}", i + 1, line))
.collect::<Vec<_>>()
.join("\n");
if take < total {
Ok(format!(
"{}\n... ({} more lines, total {})",
result,
total - take,
total
))
} else if total == 0 {
Ok(String::new())
} else {
Ok(result)
}
}
}
+194
View File
@@ -0,0 +1,194 @@
//! Tool: `write` — write content to a file, creating parent directories on demand.
use super::super::check_graduated_checks;
use super::super::resolve_path;
use super::super::Tool;
use super::super::ToolCtx;
use super::helpers::{self, arg_str};
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
use similar::TextDiff;
use std::fs;
/// Tool: write content to a file, auto-creating parent directories as needed.
pub struct Write;
impl Tool for Write {
fn name(&self) -> &'static str {
"write"
}
fn description(&self) -> &'static str {
"Write content to a file, creating parent directories as needed"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file to write (relative to workspace root)"
},
"content": {
"type": "string",
"description": "Content to write to the file"
},
"reason": {
"type": "string",
"description": "Reason for the change (must be non-empty)"
}
},
"required": ["path", "content", "reason"]
})
}
/// Write content to a file, creating parent directories as needed.
///
/// Flow: validate args (non-empty reason) → resolve path → create parent
/// dirs → write file → report byte count (+ optional graduated checks).
///
/// Why: requires a non-empty `reason` to discourage stray writes; parent
/// directories are created silently so the tool works for new paths.
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel = arg_str(args, "path")?;
let content = arg_str(args, "content")?;
let reason = arg_str(args, "reason")?;
if reason.trim().is_empty() {
anyhow::bail!("reason must be a non-empty string");
}
let check_matches = check_graduated_checks(&rel, &content, &ctx.graduated_checks);
let path = resolve_path(&ctx.workspaces, &rel)?;
let old_content = fs::read_to_string(&path).ok();
let existed_before = path.exists();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| anyhow!("failed to create parent directories for '{rel}': {e}"))?;
}
fs::write(&path, &content).map_err(|e| anyhow!("failed to write '{rel}': {e}"))?;
if !existed_before {
ctx.mention_index.push(rel.clone());
}
// Notify the LSP server of the on-disk change so diagnostics stay in
// sync. Never fails the write itself: a lock failure or LSP error is
// folded into the returned message instead of propagated as an Err.
let lsp_note = if let Ok(mut lsp) = ctx.lsp_manager.lock() {
lsp.did_change_file(&path);
String::new()
} else {
String::new()
};
// Only emit a diff when the file existed before and was valid UTF-8;
// new files and binary overwrites fall back to the byte-count message.
let diff_note = if let Some(old) = old_content {
let text_diff = TextDiff::from_lines(old.as_str(), content.as_str());
let diff_text = format!(
"{}",
text_diff
.unified_diff()
.context_radius(3)
.header(&rel, &rel)
);
format!("\n```diff\n{}\n```", helpers::truncate_diff(&diff_text))
} else {
String::new()
};
if check_matches.is_empty() {
Ok(format!(
"wrote {} bytes to {}{}{}",
content.len(),
rel,
lsp_note,
diff_note
))
} else {
Ok(format!(
"wrote {} bytes to {}{}. Graduated checks matched: {}{}",
content.len(),
rel,
lsp_note,
check_matches.join(", "),
diff_note
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_ctx(workspace: std::path::PathBuf) -> crate::tool::ToolCtx {
crate::tool::ToolCtx::builder()
.workspaces(vec![workspace])
.build()
}
fn temp_workspace() -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("zesdex-write-test-{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn write_to_a_new_file_has_no_diff_block() {
let workspace = temp_workspace();
let ctx = test_ctx(workspace.clone());
let args = json!({"path": "new.txt", "content": "hello\n", "reason": "test new file"});
let result = Write.run(&ctx, &args).unwrap();
assert!(result.contains("wrote 6 bytes"));
assert!(!result.contains("```diff"));
fs::remove_dir_all(&workspace).ok();
}
#[test]
fn write_overwriting_an_existing_utf8_file_includes_a_diff_block() {
let workspace = temp_workspace();
fs::write(workspace.join("existing.txt"), "old content\n").unwrap();
let ctx = test_ctx(workspace.clone());
let args =
json!({"path": "existing.txt", "content": "new content\n", "reason": "test overwrite"});
let result = Write.run(&ctx, &args).unwrap();
assert!(result.contains("```diff"));
assert!(result.contains("-old content"));
assert!(result.contains("+new content"));
fs::remove_dir_all(&workspace).ok();
}
#[test]
fn write_overwriting_a_non_utf8_file_has_no_diff_block() {
let workspace = temp_workspace();
fs::write(workspace.join("binary.dat"), [0xFFu8, 0xFE, 0xFD]).unwrap();
let ctx = test_ctx(workspace.clone());
let args = json!({"path": "binary.dat", "content": "now text\n", "reason": "test binary overwrite"});
let result = Write.run(&ctx, &args).unwrap();
assert!(!result.contains("```diff"));
assert!(result.contains("wrote"));
fs::remove_dir_all(&workspace).ok();
}
#[test]
fn write_creating_a_new_file_appends_to_the_mention_index() {
let workspace = temp_workspace();
let ctx = test_ctx(workspace.clone());
let args =
json!({"path": "brand_new.txt", "content": "hi\n", "reason": "test mention index"});
Write.run(&ctx, &args).unwrap();
assert_eq!(
ctx.mention_index.snapshot(),
vec!["brand_new.txt".to_string()]
);
fs::remove_dir_all(&workspace).ok();
}
#[test]
fn write_overwriting_a_file_does_not_duplicate_the_mention_index_entry() {
let workspace = temp_workspace();
fs::write(workspace.join("existing.txt"), "old\n").unwrap();
let ctx = test_ctx(workspace.clone());
let args =
json!({"path": "existing.txt", "content": "new\n", "reason": "test no duplicate"});
Write.run(&ctx, &args).unwrap();
assert!(ctx.mention_index.snapshot().is_empty());
fs::remove_dir_all(&workspace).ok();
}
}