Ketika write tool membuat file baru (bukan overwrite), path file sekarang di-push ke mention_index untuk autocomplete @file mention. - Capture file existence status sebelum write - Push ke mention_index hanya untuk genuinely new files - Add 2 tests: one untuk new file push, one untuk overwrite non-duplication Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
173 lines
6.8 KiB
Rust
173 lines
6.8 KiB
Rust
//! Tool: `write` — write content to a file, creating parent directories on demand.
|
|
|
|
use std::fs;
|
|
use serde_json::{json, Value};
|
|
use anyhow::{Result, anyhow};
|
|
use super::super::Tool;
|
|
use super::super::ToolCtx;
|
|
use super::super::resolve_path;
|
|
use super::super::check_graduated_checks;
|
|
use super::helpers::{self, arg_str};
|
|
use similar::TextDiff;
|
|
|
|
/// 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();
|
|
}
|
|
}
|