1use std::fs;
11use std::path::{Path, PathBuf};
12
13use async_trait::async_trait;
14use tracing::{debug, warn};
15
16use super::{compute_content_hash, FileReadRecord, SharedFileReadHistory};
17use crate::tools::base::{PermissionCheckResult, Tool};
18use crate::tools::context::{ToolContext, ToolOptions, ToolResult};
19use crate::tools::error::ToolError;
20
21pub const MAX_WRITE_SIZE: usize = 50 * 1024 * 1024;
23
24#[derive(Debug)]
33pub struct WriteTool {
34 read_history: SharedFileReadHistory,
36 require_read_before_overwrite: bool,
38}
39
40impl WriteTool {
41 pub fn new(read_history: SharedFileReadHistory) -> Self {
43 Self {
44 read_history,
45 require_read_before_overwrite: true,
46 }
47 }
48
49 pub fn with_require_read_before_overwrite(mut self, require: bool) -> Self {
51 self.require_read_before_overwrite = require;
52 self
53 }
54
55 pub fn read_history(&self) -> &SharedFileReadHistory {
57 &self.read_history
58 }
59
60 fn resolve_path(&self, path: &Path, context: &ToolContext) -> PathBuf {
62 if path.is_absolute() {
63 path.to_path_buf()
64 } else {
65 context.working_directory.join(path)
66 }
67 }
68}
69
70impl WriteTool {
75 pub async fn write_file(
82 &self,
83 path: &Path,
84 content: &str,
85 context: &ToolContext,
86 ) -> Result<ToolResult, ToolError> {
87 let full_path = self.resolve_path(path, context);
88
89 if content.len() > MAX_WRITE_SIZE {
91 return Err(ToolError::execution_failed(format!(
92 "Content too large: {} bytes (max: {} bytes)",
93 content.len(),
94 MAX_WRITE_SIZE
95 )));
96 }
97
98 if full_path.exists() && self.require_read_before_overwrite {
100 let history = self.read_history.read().unwrap();
101 if !history.has_read(&full_path) {
102 return Err(ToolError::execution_failed(format!(
103 "File exists but has not been read: {}. \
104 Read the file first before overwriting.",
105 full_path.display()
106 )));
107 }
108
109 if let Ok(metadata) = fs::metadata(&full_path) {
111 if let Ok(mtime) = metadata.modified() {
112 if let Some(true) = history.is_file_modified(&full_path, mtime) {
113 warn!(
114 "File has been modified externally since last read: {}",
115 full_path.display()
116 );
117 return Err(ToolError::execution_failed(format!(
118 "File has been modified externally since last read: {}. \
119 Read the file again before overwriting.",
120 full_path.display()
121 )));
122 }
123 }
124 }
125 }
126
127 if let Some(parent) = full_path.parent() {
129 if !parent.exists() {
130 fs::create_dir_all(parent)?;
131 debug!("Created parent directories: {}", parent.display());
132 }
133 }
134
135 fs::write(&full_path, content)?;
137
138 let content_bytes = content.as_bytes();
140 let hash = compute_content_hash(content_bytes);
141 let metadata = fs::metadata(&full_path)?;
142 let mtime = metadata.modified().ok();
143
144 let mut record = FileReadRecord::new(full_path.clone(), hash, metadata.len())
145 .with_line_count(content.lines().count());
146
147 if let Some(mt) = mtime {
148 record = record.with_mtime(mt);
149 }
150
151 self.read_history.write().unwrap().record_read(record);
152
153 debug!(
154 "Wrote file: {} ({} bytes)",
155 full_path.display(),
156 content.len()
157 );
158
159 Ok(ToolResult::success(format!(
160 "Successfully wrote {} bytes to {}",
161 content.len(),
162 full_path.display()
163 ))
164 .with_metadata("path", serde_json::json!(full_path.to_string_lossy()))
165 .with_metadata("size", serde_json::json!(content.len())))
166 }
167
168 pub fn can_write(&self, path: &Path, context: &ToolContext) -> bool {
170 let full_path = self.resolve_path(path, context);
171
172 if !full_path.exists() {
173 return true;
174 }
175
176 if !self.require_read_before_overwrite {
177 return true;
178 }
179
180 self.read_history.read().unwrap().has_read(&full_path)
181 }
182}
183
184#[async_trait]
189impl Tool for WriteTool {
190 fn name(&self) -> &str {
191 "write"
192 }
193
194 fn description(&self) -> &str {
195 "Write content to a file. Creates parent directories if needed. \
196 For existing files, the file must be read first before overwriting \
197 to prevent accidental data loss."
198 }
199
200 fn input_schema(&self) -> serde_json::Value {
201 serde_json::json!({
202 "type": "object",
203 "properties": {
204 "path": {
205 "type": "string",
206 "description": "Path to the file to write (relative to working directory or absolute)"
207 },
208 "content": {
209 "type": "string",
210 "description": "Content to write to the file"
211 }
212 },
213 "required": ["path", "content"]
214 })
215 }
216
217 async fn execute(
218 &self,
219 params: serde_json::Value,
220 context: &ToolContext,
221 ) -> Result<ToolResult, ToolError> {
222 if context.is_cancelled() {
224 return Err(ToolError::Cancelled);
225 }
226
227 let path_str = params
229 .get("path")
230 .and_then(|v| v.as_str())
231 .ok_or_else(|| ToolError::invalid_params("Missing required parameter: path"))?;
232
233 let content = params
235 .get("content")
236 .and_then(|v| v.as_str())
237 .ok_or_else(|| ToolError::invalid_params("Missing required parameter: content"))?;
238
239 let path = Path::new(path_str);
240 self.write_file(path, content, context).await
241 }
242
243 async fn check_permissions(
244 &self,
245 params: &serde_json::Value,
246 context: &ToolContext,
247 ) -> PermissionCheckResult {
248 let path_str = match params.get("path").and_then(|v| v.as_str()) {
250 Some(p) => p,
251 None => return PermissionCheckResult::deny("Missing path parameter"),
252 };
253
254 let path = Path::new(path_str);
255 let full_path = self.resolve_path(path, context);
256
257 if full_path.exists() && self.require_read_before_overwrite {
259 let history = self.read_history.read().unwrap();
260 if !history.has_read(&full_path) {
261 return PermissionCheckResult::ask(format!(
262 "File '{}' exists but has not been read. \
263 Do you want to overwrite it without reading first?",
264 full_path.display()
265 ));
266 }
267 }
268
269 debug!("Permission check for write: {}", full_path.display());
270 PermissionCheckResult::allow()
271 }
272
273 fn options(&self) -> ToolOptions {
274 ToolOptions::new()
275 .with_max_retries(1)
276 .with_base_timeout(std::time::Duration::from_secs(30))
277 }
278}
279
280#[cfg(test)]
285mod tests {
286 use super::*;
287 use tempfile::TempDir;
288
289 fn create_test_context(dir: &Path) -> ToolContext {
290 ToolContext::new(dir.to_path_buf())
291 .with_session_id("test-session")
292 .with_user("test-user")
293 }
294
295 fn create_write_tool() -> WriteTool {
296 WriteTool::new(super::super::create_shared_history())
297 }
298
299 #[tokio::test]
300 async fn test_write_new_file() {
301 let temp_dir = TempDir::new().unwrap();
302 let file_path = temp_dir.path().join("new_file.txt");
303
304 let tool = create_write_tool();
305 let context = create_test_context(temp_dir.path());
306
307 let result = tool
308 .write_file(&file_path, "Hello, World!", &context)
309 .await
310 .unwrap();
311
312 assert!(result.is_success());
313 assert!(file_path.exists());
314 assert_eq!(fs::read_to_string(&file_path).unwrap(), "Hello, World!");
315 }
316
317 #[tokio::test]
318 async fn test_write_creates_parent_directories() {
319 let temp_dir = TempDir::new().unwrap();
320 let file_path = temp_dir.path().join("subdir/nested/file.txt");
321
322 let tool = create_write_tool();
323 let context = create_test_context(temp_dir.path());
324
325 let result = tool
326 .write_file(&file_path, "Nested content", &context)
327 .await
328 .unwrap();
329
330 assert!(result.is_success());
331 assert!(file_path.exists());
332 assert_eq!(fs::read_to_string(&file_path).unwrap(), "Nested content");
333 }
334
335 #[tokio::test]
336 async fn test_write_existing_file_without_read() {
337 let temp_dir = TempDir::new().unwrap();
338 let file_path = temp_dir.path().join("existing.txt");
339
340 fs::write(&file_path, "Original content").unwrap();
342
343 let tool = create_write_tool();
344 let context = create_test_context(temp_dir.path());
345
346 let result = tool.write_file(&file_path, "New content", &context).await;
348
349 assert!(result.is_err());
350 assert_eq!(fs::read_to_string(&file_path).unwrap(), "Original content");
352 }
353
354 #[tokio::test]
355 async fn test_write_existing_file_after_read() {
356 let temp_dir = TempDir::new().unwrap();
357 let file_path = temp_dir.path().join("existing.txt");
358
359 fs::write(&file_path, "Original content").unwrap();
361
362 let history = super::super::create_shared_history();
363 let tool = WriteTool::new(history.clone());
364 let context = create_test_context(temp_dir.path());
365
366 let content = fs::read(&file_path).unwrap();
368 let metadata = fs::metadata(&file_path).unwrap();
369 let hash = compute_content_hash(&content);
370 let mut record = FileReadRecord::new(file_path.clone(), hash, metadata.len());
371 if let Ok(mtime) = metadata.modified() {
372 record = record.with_mtime(mtime);
373 }
374 history.write().unwrap().record_read(record);
375
376 let result = tool
378 .write_file(&file_path, "New content", &context)
379 .await
380 .unwrap();
381
382 assert!(result.is_success());
383 assert_eq!(fs::read_to_string(&file_path).unwrap(), "New content");
384 }
385
386 #[tokio::test]
387 async fn test_write_without_read_requirement() {
388 let temp_dir = TempDir::new().unwrap();
389 let file_path = temp_dir.path().join("existing.txt");
390
391 fs::write(&file_path, "Original content").unwrap();
393
394 let tool = create_write_tool().with_require_read_before_overwrite(false);
395 let context = create_test_context(temp_dir.path());
396
397 let result = tool
399 .write_file(&file_path, "New content", &context)
400 .await
401 .unwrap();
402
403 assert!(result.is_success());
404 assert_eq!(fs::read_to_string(&file_path).unwrap(), "New content");
405 }
406
407 #[tokio::test]
408 async fn test_write_content_too_large() {
409 let temp_dir = TempDir::new().unwrap();
410 let file_path = temp_dir.path().join("large.txt");
411
412 let tool = create_write_tool();
413 let context = create_test_context(temp_dir.path());
414
415 let large_content = "x".repeat(MAX_WRITE_SIZE + 1);
417
418 let result = tool.write_file(&file_path, &large_content, &context).await;
419
420 assert!(result.is_err());
421 }
422
423 #[tokio::test]
424 async fn test_can_write_new_file() {
425 let temp_dir = TempDir::new().unwrap();
426 let file_path = temp_dir.path().join("new.txt");
427
428 let tool = create_write_tool();
429 let context = create_test_context(temp_dir.path());
430
431 assert!(tool.can_write(&file_path, &context));
432 }
433
434 #[tokio::test]
435 async fn test_can_write_existing_file_without_read() {
436 let temp_dir = TempDir::new().unwrap();
437 let file_path = temp_dir.path().join("existing.txt");
438 fs::write(&file_path, "content").unwrap();
439
440 let tool = create_write_tool();
441 let context = create_test_context(temp_dir.path());
442
443 assert!(!tool.can_write(&file_path, &context));
444 }
445
446 #[tokio::test]
447 async fn test_tool_execute() {
448 let temp_dir = TempDir::new().unwrap();
449 let file_path = temp_dir.path().join("test.txt");
450
451 let tool = create_write_tool();
452 let context = create_test_context(temp_dir.path());
453 let params = serde_json::json!({
454 "path": file_path.to_str().unwrap(),
455 "content": "Test content"
456 });
457
458 let result = tool.execute(params, &context).await.unwrap();
459
460 assert!(result.is_success());
461 assert!(file_path.exists());
462 }
463
464 #[tokio::test]
465 async fn test_tool_execute_missing_path() {
466 let temp_dir = TempDir::new().unwrap();
467 let tool = create_write_tool();
468 let context = create_test_context(temp_dir.path());
469 let params = serde_json::json!({
470 "content": "Test content"
471 });
472
473 let result = tool.execute(params, &context).await;
474 assert!(result.is_err());
475 assert!(matches!(result.unwrap_err(), ToolError::InvalidParams(_)));
476 }
477
478 #[tokio::test]
479 async fn test_tool_execute_missing_content() {
480 let temp_dir = TempDir::new().unwrap();
481 let tool = create_write_tool();
482 let context = create_test_context(temp_dir.path());
483 let params = serde_json::json!({
484 "path": "test.txt"
485 });
486
487 let result = tool.execute(params, &context).await;
488 assert!(result.is_err());
489 assert!(matches!(result.unwrap_err(), ToolError::InvalidParams(_)));
490 }
491
492 #[test]
493 fn test_tool_name() {
494 let tool = create_write_tool();
495 assert_eq!(tool.name(), "write");
496 }
497
498 #[test]
499 fn test_tool_description() {
500 let tool = create_write_tool();
501 assert!(!tool.description().is_empty());
502 assert!(tool.description().contains("Write"));
503 }
504
505 #[test]
506 fn test_tool_input_schema() {
507 let tool = create_write_tool();
508 let schema = tool.input_schema();
509 assert_eq!(schema["type"], "object");
510 assert!(schema["properties"]["path"].is_object());
511 assert!(schema["properties"]["content"].is_object());
512 }
513
514 #[tokio::test]
515 async fn test_check_permissions_new_file() {
516 let temp_dir = TempDir::new().unwrap();
517 let tool = create_write_tool();
518 let context = create_test_context(temp_dir.path());
519 let params = serde_json::json!({
520 "path": "new_file.txt",
521 "content": "content"
522 });
523
524 let result = tool.check_permissions(¶ms, &context).await;
525 assert!(result.is_allowed());
526 }
527
528 #[tokio::test]
529 async fn test_check_permissions_existing_file_not_read() {
530 let temp_dir = TempDir::new().unwrap();
531 let file_path = temp_dir.path().join("existing.txt");
532 fs::write(&file_path, "content").unwrap();
533
534 let tool = create_write_tool();
535 let context = create_test_context(temp_dir.path());
536 let params = serde_json::json!({
537 "path": file_path.to_str().unwrap(),
538 "content": "new content"
539 });
540
541 let result = tool.check_permissions(¶ms, &context).await;
542 assert!(result.requires_confirmation());
543 }
544
545 #[tokio::test]
546 async fn test_check_permissions_missing_path() {
547 let temp_dir = TempDir::new().unwrap();
548 let tool = create_write_tool();
549 let context = create_test_context(temp_dir.path());
550 let params = serde_json::json!({});
551
552 let result = tool.check_permissions(¶ms, &context).await;
553 assert!(result.is_denied());
554 }
555
556 #[tokio::test]
557 async fn test_write_updates_read_history() {
558 let temp_dir = TempDir::new().unwrap();
559 let file_path = temp_dir.path().join("new.txt");
560
561 let tool = create_write_tool();
562 let context = create_test_context(temp_dir.path());
563
564 tool.write_file(&file_path, "content", &context)
565 .await
566 .unwrap();
567
568 assert!(tool.read_history.read().unwrap().has_read(&file_path));
570 }
571}