//! 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 { 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(); } }