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:
@@ -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}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user