1use std::fs;
12use std::path::{Path, PathBuf};
13
14use async_trait::async_trait;
15use serde::{Deserialize, Serialize};
16use tracing::debug;
17
18use super::{compute_content_hash, FileReadRecord, SharedFileReadHistory};
19use crate::tools::base::{PermissionCheckResult, Tool};
20use crate::tools::context::{ToolContext, ToolOptions, ToolResult};
21use crate::tools::error::ToolError;
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct Edit {
26 pub old_str: String,
28 pub new_str: String,
30}
31
32impl Edit {
33 pub fn new(old_str: impl Into<String>, new_str: impl Into<String>) -> Self {
35 Self {
36 old_str: old_str.into(),
37 new_str: new_str.into(),
38 }
39 }
40}
41
42#[derive(Debug, Clone)]
44pub struct MatchResult {
45 pub count: usize,
47 pub positions: Vec<usize>,
49}
50
51#[derive(Debug)]
61pub struct EditTool {
62 read_history: SharedFileReadHistory,
64 require_read_before_edit: bool,
66 smart_quote_matching: bool,
68}
69
70impl EditTool {
71 pub fn new(read_history: SharedFileReadHistory) -> Self {
73 Self {
74 read_history,
75 require_read_before_edit: true,
76 smart_quote_matching: true,
77 }
78 }
79
80 pub fn with_require_read_before_edit(mut self, require: bool) -> Self {
82 self.require_read_before_edit = require;
83 self
84 }
85
86 pub fn with_smart_quote_matching(mut self, enabled: bool) -> Self {
88 self.smart_quote_matching = enabled;
89 self
90 }
91
92 pub fn read_history(&self) -> &SharedFileReadHistory {
94 &self.read_history
95 }
96
97 fn resolve_path(&self, path: &Path, context: &ToolContext) -> PathBuf {
99 if path.is_absolute() {
100 path.to_path_buf()
101 } else {
102 context.working_directory.join(path)
103 }
104 }
105}
106
107impl EditTool {
112 pub fn normalize_quotes(s: &str) -> String {
120 s.chars()
121 .map(|c| match c {
122 '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' => '"',
128 '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}' => '\'',
134 '\u{00AB}' | '\u{00BB}' | '\u{2039}' | '\u{203A}' => '"',
140 _ => c,
141 })
142 .collect()
143 }
144
145 pub fn find_matches(&self, content: &str, search: &str) -> MatchResult {
151 if self.smart_quote_matching {
152 self.find_matches_smart(content, search)
153 } else {
154 self.find_matches_exact(content, search)
155 }
156 }
157
158 fn find_matches_exact(&self, content: &str, search: &str) -> MatchResult {
160 let positions: Vec<usize> = content.match_indices(search).map(|(pos, _)| pos).collect();
161
162 MatchResult {
163 count: positions.len(),
164 positions,
165 }
166 }
167
168 fn find_matches_smart(&self, content: &str, search: &str) -> MatchResult {
170 let normalized_content = Self::normalize_quotes(content);
171 let normalized_search = Self::normalize_quotes(search);
172
173 let exact_result = self.find_matches_exact(content, search);
175 if exact_result.count > 0 {
176 return exact_result;
177 }
178
179 let positions: Vec<usize> = normalized_content
181 .match_indices(&normalized_search)
182 .map(|(pos, _)| pos)
183 .collect();
184
185 MatchResult {
186 count: positions.len(),
187 positions,
188 }
189 }
190
191 pub fn is_unique_match(&self, content: &str, search: &str) -> bool {
193 self.find_matches(content, search).count == 1
194 }
195}
196
197impl EditTool {
202 pub async fn edit_file(
206 &self,
207 path: &Path,
208 old_str: &str,
209 new_str: &str,
210 context: &ToolContext,
211 ) -> Result<ToolResult, ToolError> {
212 let full_path = self.resolve_path(path, context);
213
214 if !full_path.exists() {
216 return Err(ToolError::execution_failed(format!(
217 "File not found: {}",
218 full_path.display()
219 )));
220 }
221
222 if self.require_read_before_edit {
224 let history = self.read_history.read().unwrap();
225 if !history.has_read(&full_path) {
226 return Err(ToolError::execution_failed(format!(
227 "File has not been read: {}. Read the file first before editing.",
228 full_path.display()
229 )));
230 }
231 }
232
233 self.check_external_modification(&full_path)?;
235
236 let content = fs::read_to_string(&full_path)?;
238
239 let match_result = self.find_matches(&content, old_str);
241
242 if match_result.count == 0 {
243 return Err(ToolError::execution_failed(format!(
244 "String not found in file: '{}'",
245 if old_str.len() > 50 {
246 format!("{}...", old_str.get(..50).unwrap_or(old_str))
247 } else {
248 old_str.to_string()
249 }
250 )));
251 }
252
253 if match_result.count > 1 {
254 return Err(ToolError::execution_failed(format!(
255 "String is not unique: found {} occurrences. \
256 Please provide more context to make the match unique.",
257 match_result.count
258 )));
259 }
260
261 let new_content = if self.smart_quote_matching {
263 let pos = match_result.positions[0];
265 let actual_old_str = content.get(pos..pos + old_str.len()).unwrap_or(old_str);
266 content.replacen(actual_old_str, new_str, 1)
267 } else {
268 content.replacen(old_str, new_str, 1)
269 };
270
271 fs::write(&full_path, &new_content)?;
273
274 self.update_read_history(&full_path, &new_content)?;
276
277 debug!(
278 "Edited file: {} (replaced {} bytes with {} bytes)",
279 full_path.display(),
280 old_str.len(),
281 new_str.len()
282 );
283
284 Ok(
285 ToolResult::success(format!("Successfully edited {}", full_path.display()))
286 .with_metadata("path", serde_json::json!(full_path.to_string_lossy()))
287 .with_metadata("old_length", serde_json::json!(old_str.len()))
288 .with_metadata("new_length", serde_json::json!(new_str.len())),
289 )
290 }
291
292 fn check_external_modification(&self, path: &Path) -> Result<(), ToolError> {
296 let history = self.read_history.read().unwrap();
297
298 if let Some(record) = history.get_record(&path.to_path_buf()) {
299 if let Ok(metadata) = fs::metadata(path) {
300 if let Ok(current_mtime) = metadata.modified() {
301 if record.is_modified(current_mtime) {
302 return Err(ToolError::execution_failed(format!(
303 "File has been modified externally since last read: {}. \
304 Read the file again before editing.",
305 path.display()
306 )));
307 }
308 }
309 }
310 }
311
312 Ok(())
313 }
314
315 fn update_read_history(&self, path: &Path, content: &str) -> Result<(), ToolError> {
317 let content_bytes = content.as_bytes();
318 let hash = compute_content_hash(content_bytes);
319 let metadata = fs::metadata(path)?;
320 let mtime = metadata.modified().ok();
321
322 let mut record = FileReadRecord::new(path.to_path_buf(), hash, metadata.len())
323 .with_line_count(content.lines().count());
324
325 if let Some(mt) = mtime {
326 record = record.with_mtime(mt);
327 }
328
329 self.read_history.write().unwrap().record_read(record);
330 Ok(())
331 }
332}
333
334impl EditTool {
339 pub async fn batch_edit(
346 &self,
347 path: &Path,
348 edits: &[Edit],
349 context: &ToolContext,
350 ) -> Result<ToolResult, ToolError> {
351 let full_path = self.resolve_path(path, context);
352
353 if !full_path.exists() {
355 return Err(ToolError::execution_failed(format!(
356 "File not found: {}",
357 full_path.display()
358 )));
359 }
360
361 if self.require_read_before_edit {
363 let history = self.read_history.read().unwrap();
364 if !history.has_read(&full_path) {
365 return Err(ToolError::execution_failed(format!(
366 "File has not been read: {}. Read the file first before editing.",
367 full_path.display()
368 )));
369 }
370 }
371
372 self.check_external_modification(&full_path)?;
374
375 let original_content = fs::read_to_string(&full_path)?;
377 let mut content = original_content.clone();
378
379 for (i, edit) in edits.iter().enumerate() {
381 let match_result = self.find_matches(&content, &edit.old_str);
382
383 if match_result.count == 0 {
384 return Err(ToolError::execution_failed(format!(
385 "Edit {}: String not found: '{}'",
386 i + 1,
387 if edit.old_str.len() > 50 {
388 format!("{}...", edit.old_str.get(..50).unwrap_or(&edit.old_str))
389 } else {
390 edit.old_str.clone()
391 }
392 )));
393 }
394
395 if match_result.count > 1 {
396 return Err(ToolError::execution_failed(format!(
397 "Edit {}: String is not unique: found {} occurrences",
398 i + 1,
399 match_result.count
400 )));
401 }
402
403 content = content.replacen(&edit.old_str, &edit.new_str, 1);
405 }
406
407 fs::write(&full_path, &content)?;
409
410 self.update_read_history(&full_path, &content)?;
412
413 debug!(
414 "Batch edited file: {} ({} edits applied)",
415 full_path.display(),
416 edits.len()
417 );
418
419 Ok(ToolResult::success(format!(
420 "Successfully applied {} edits to {}",
421 edits.len(),
422 full_path.display()
423 ))
424 .with_metadata("path", serde_json::json!(full_path.to_string_lossy()))
425 .with_metadata("edit_count", serde_json::json!(edits.len())))
426 }
427}
428
429#[async_trait]
434impl Tool for EditTool {
435 fn name(&self) -> &str {
436 "edit"
437 }
438
439 fn description(&self) -> &str {
440 "Edit a file by replacing a specific string with a new string. \
441 The string to replace must be unique in the file. \
442 Supports smart quote matching and batch edits. \
443 The file must be read first before editing."
444 }
445
446 fn input_schema(&self) -> serde_json::Value {
447 serde_json::json!({
448 "type": "object",
449 "properties": {
450 "path": {
451 "type": "string",
452 "description": "Path to the file to edit (relative to working directory or absolute)"
453 },
454 "old_str": {
455 "type": "string",
456 "description": "The string to find and replace (must be unique in the file)"
457 },
458 "new_str": {
459 "type": "string",
460 "description": "The replacement string"
461 },
462 "edits": {
463 "type": "array",
464 "description": "Array of edit operations for batch editing",
465 "items": {
466 "type": "object",
467 "properties": {
468 "old_str": { "type": "string" },
469 "new_str": { "type": "string" }
470 },
471 "required": ["old_str", "new_str"]
472 }
473 }
474 },
475 "required": ["path"]
476 })
477 }
478
479 async fn execute(
480 &self,
481 params: serde_json::Value,
482 context: &ToolContext,
483 ) -> Result<ToolResult, ToolError> {
484 if context.is_cancelled() {
486 return Err(ToolError::Cancelled);
487 }
488
489 let path_str = params
491 .get("path")
492 .and_then(|v| v.as_str())
493 .ok_or_else(|| ToolError::invalid_params("Missing required parameter: path"))?;
494
495 let path = Path::new(path_str);
496
497 if let Some(edits_value) = params.get("edits") {
499 let edits: Vec<Edit> = serde_json::from_value(edits_value.clone())
500 .map_err(|e| ToolError::invalid_params(format!("Invalid edits array: {}", e)))?;
501
502 if edits.is_empty() {
503 return Err(ToolError::invalid_params("Edits array is empty"));
504 }
505
506 return self.batch_edit(path, &edits, context).await;
507 }
508
509 let old_str = params
511 .get("old_str")
512 .and_then(|v| v.as_str())
513 .ok_or_else(|| ToolError::invalid_params("Missing required parameter: old_str"))?;
514
515 let new_str = params
516 .get("new_str")
517 .and_then(|v| v.as_str())
518 .ok_or_else(|| ToolError::invalid_params("Missing required parameter: new_str"))?;
519
520 self.edit_file(path, old_str, new_str, context).await
521 }
522
523 async fn check_permissions(
524 &self,
525 params: &serde_json::Value,
526 context: &ToolContext,
527 ) -> PermissionCheckResult {
528 let path_str = match params.get("path").and_then(|v| v.as_str()) {
530 Some(p) => p,
531 None => return PermissionCheckResult::deny("Missing path parameter"),
532 };
533
534 let path = Path::new(path_str);
535 let full_path = self.resolve_path(path, context);
536
537 if !full_path.exists() {
539 return PermissionCheckResult::deny(format!(
540 "File does not exist: {}",
541 full_path.display()
542 ));
543 }
544
545 if self.require_read_before_edit {
547 let history = self.read_history.read().unwrap();
548 if !history.has_read(&full_path) {
549 return PermissionCheckResult::ask(format!(
550 "File '{}' has not been read. \
551 Do you want to edit it without reading first?",
552 full_path.display()
553 ));
554 }
555 }
556
557 debug!("Permission check for edit: {}", full_path.display());
558 PermissionCheckResult::allow()
559 }
560
561 fn options(&self) -> ToolOptions {
562 ToolOptions::new()
563 .with_max_retries(0) .with_base_timeout(std::time::Duration::from_secs(30))
565 }
566}
567
568#[cfg(test)]
573mod tests {
574 use super::*;
575 use tempfile::TempDir;
576
577 fn create_test_context(dir: &Path) -> ToolContext {
578 ToolContext::new(dir.to_path_buf())
579 .with_session_id("test-session")
580 .with_user("test-user")
581 }
582
583 fn create_edit_tool() -> EditTool {
584 EditTool::new(super::super::create_shared_history())
585 }
586
587 fn create_edit_tool_with_history(history: SharedFileReadHistory) -> EditTool {
588 EditTool::new(history)
589 }
590
591 #[test]
592 fn test_edit_new() {
593 let edit = Edit::new("old", "new");
594 assert_eq!(edit.old_str, "old");
595 assert_eq!(edit.new_str, "new");
596 }
597
598 #[test]
599 fn test_normalize_quotes() {
600 let smart_double_open = "\u{201C}"; let smart_double_close = "\u{201D}"; let smart_single_open = "\u{2018}"; let smart_single_close = "\u{2019}"; let input = format!("{}hello{}", smart_double_open, smart_double_close);
608 assert_eq!(EditTool::normalize_quotes(&input), "\"hello\"");
609
610 let input = format!("{}hello{}", smart_single_open, smart_single_close);
612 assert_eq!(EditTool::normalize_quotes(&input), "'hello'");
613
614 let input = format!(
616 "{}it{}s{}",
617 smart_double_open, smart_single_close, smart_double_close
618 );
619 assert_eq!(EditTool::normalize_quotes(&input), "\"it's\"");
620
621 assert_eq!(EditTool::normalize_quotes("hello"), "hello");
623 }
624
625 #[test]
626 fn test_find_matches_exact() {
627 let tool = create_edit_tool().with_smart_quote_matching(false);
628 let content = "hello world hello";
629
630 let result = tool.find_matches(content, "hello");
631 assert_eq!(result.count, 2);
632 assert_eq!(result.positions, vec![0, 12]);
633 }
634
635 #[test]
636 fn test_find_matches_unique() {
637 let tool = create_edit_tool();
638 let content = "hello world";
639
640 let result = tool.find_matches(content, "world");
641 assert_eq!(result.count, 1);
642 assert!(tool.is_unique_match(content, "world"));
643 }
644
645 #[test]
646 fn test_find_matches_not_found() {
647 let tool = create_edit_tool();
648 let content = "hello world";
649
650 let result = tool.find_matches(content, "foo");
651 assert_eq!(result.count, 0);
652 }
653
654 #[test]
655 fn test_find_matches_smart_quotes() {
656 let tool = create_edit_tool();
657 let smart_double_open = "\u{201C}"; let smart_double_close = "\u{201D}"; let content = format!("say {}hello{}", smart_double_open, smart_double_close);
661
662 let result = tool.find_matches(&content, "\"hello\"");
664 assert_eq!(result.count, 1);
665 }
666
667 #[tokio::test]
668 async fn test_edit_file_success() {
669 let temp_dir = TempDir::new().unwrap();
670 let file_path = temp_dir.path().join("test.txt");
671 fs::write(&file_path, "hello world").unwrap();
672
673 let history = super::super::create_shared_history();
674 let tool = create_edit_tool_with_history(history.clone());
675 let context = create_test_context(temp_dir.path());
676
677 let content = fs::read(&file_path).unwrap();
679 let metadata = fs::metadata(&file_path).unwrap();
680 let hash = compute_content_hash(&content);
681 let mut record = FileReadRecord::new(file_path.clone(), hash, metadata.len());
682 if let Ok(mtime) = metadata.modified() {
683 record = record.with_mtime(mtime);
684 }
685 history.write().unwrap().record_read(record);
686
687 let result = tool
688 .edit_file(&file_path, "world", "universe", &context)
689 .await
690 .unwrap();
691
692 assert!(result.is_success());
693 assert_eq!(fs::read_to_string(&file_path).unwrap(), "hello universe");
694 }
695
696 #[tokio::test]
697 async fn test_edit_file_not_read() {
698 let temp_dir = TempDir::new().unwrap();
699 let file_path = temp_dir.path().join("test.txt");
700 fs::write(&file_path, "hello world").unwrap();
701
702 let tool = create_edit_tool();
703 let context = create_test_context(temp_dir.path());
704
705 let result = tool
706 .edit_file(&file_path, "world", "universe", &context)
707 .await;
708
709 assert!(result.is_err());
710 }
711
712 #[tokio::test]
713 async fn test_edit_file_not_found() {
714 let temp_dir = TempDir::new().unwrap();
715 let file_path = temp_dir.path().join("nonexistent.txt");
716
717 let tool = create_edit_tool();
718 let context = create_test_context(temp_dir.path());
719
720 let result = tool.edit_file(&file_path, "old", "new", &context).await;
721
722 assert!(result.is_err());
723 }
724
725 #[tokio::test]
726 async fn test_edit_file_string_not_found() {
727 let temp_dir = TempDir::new().unwrap();
728 let file_path = temp_dir.path().join("test.txt");
729 fs::write(&file_path, "hello world").unwrap();
730
731 let history = super::super::create_shared_history();
732 let tool = create_edit_tool_with_history(history.clone());
733 let context = create_test_context(temp_dir.path());
734
735 let content = fs::read(&file_path).unwrap();
737 let metadata = fs::metadata(&file_path).unwrap();
738 let hash = compute_content_hash(&content);
739 let mut record = FileReadRecord::new(file_path.clone(), hash, metadata.len());
740 if let Ok(mtime) = metadata.modified() {
741 record = record.with_mtime(mtime);
742 }
743 history.write().unwrap().record_read(record);
744
745 let result = tool.edit_file(&file_path, "foo", "bar", &context).await;
746
747 assert!(result.is_err());
748 }
749
750 #[tokio::test]
751 async fn test_edit_file_not_unique() {
752 let temp_dir = TempDir::new().unwrap();
753 let file_path = temp_dir.path().join("test.txt");
754 fs::write(&file_path, "hello hello world").unwrap();
755
756 let history = super::super::create_shared_history();
757 let tool = create_edit_tool_with_history(history.clone());
758 let context = create_test_context(temp_dir.path());
759
760 let content = fs::read(&file_path).unwrap();
762 let metadata = fs::metadata(&file_path).unwrap();
763 let hash = compute_content_hash(&content);
764 let mut record = FileReadRecord::new(file_path.clone(), hash, metadata.len());
765 if let Ok(mtime) = metadata.modified() {
766 record = record.with_mtime(mtime);
767 }
768 history.write().unwrap().record_read(record);
769
770 let result = tool.edit_file(&file_path, "hello", "hi", &context).await;
771
772 assert!(result.is_err());
773 assert_eq!(fs::read_to_string(&file_path).unwrap(), "hello hello world");
775 }
776
777 #[tokio::test]
778 async fn test_batch_edit_success() {
779 let temp_dir = TempDir::new().unwrap();
780 let file_path = temp_dir.path().join("test.txt");
781 fs::write(&file_path, "hello world foo").unwrap();
782
783 let history = super::super::create_shared_history();
784 let tool = create_edit_tool_with_history(history.clone());
785 let context = create_test_context(temp_dir.path());
786
787 let content = fs::read(&file_path).unwrap();
789 let metadata = fs::metadata(&file_path).unwrap();
790 let hash = compute_content_hash(&content);
791 let mut record = FileReadRecord::new(file_path.clone(), hash, metadata.len());
792 if let Ok(mtime) = metadata.modified() {
793 record = record.with_mtime(mtime);
794 }
795 history.write().unwrap().record_read(record);
796
797 let edits = vec![
798 Edit::new("hello", "hi"),
799 Edit::new("world", "universe"),
800 Edit::new("foo", "bar"),
801 ];
802
803 let result = tool.batch_edit(&file_path, &edits, &context).await.unwrap();
804
805 assert!(result.is_success());
806 assert_eq!(fs::read_to_string(&file_path).unwrap(), "hi universe bar");
807 }
808
809 #[tokio::test]
810 async fn test_batch_edit_atomic_rollback() {
811 let temp_dir = TempDir::new().unwrap();
812 let file_path = temp_dir.path().join("test.txt");
813 fs::write(&file_path, "hello world").unwrap();
814
815 let history = super::super::create_shared_history();
816 let tool = create_edit_tool_with_history(history.clone());
817 let context = create_test_context(temp_dir.path());
818
819 let content = fs::read(&file_path).unwrap();
821 let metadata = fs::metadata(&file_path).unwrap();
822 let hash = compute_content_hash(&content);
823 let mut record = FileReadRecord::new(file_path.clone(), hash, metadata.len());
824 if let Ok(mtime) = metadata.modified() {
825 record = record.with_mtime(mtime);
826 }
827 history.write().unwrap().record_read(record);
828
829 let edits = vec![Edit::new("hello", "hi"), Edit::new("nonexistent", "bar")];
831
832 let result = tool.batch_edit(&file_path, &edits, &context).await;
833
834 assert!(result.is_err());
835 assert_eq!(fs::read_to_string(&file_path).unwrap(), "hello world");
837 }
838
839 #[tokio::test]
840 async fn test_tool_execute_single_edit() {
841 let temp_dir = TempDir::new().unwrap();
842 let file_path = temp_dir.path().join("test.txt");
843 fs::write(&file_path, "hello world").unwrap();
844
845 let history = super::super::create_shared_history();
846 let tool = create_edit_tool_with_history(history.clone());
847 let context = create_test_context(temp_dir.path());
848
849 let content = fs::read(&file_path).unwrap();
851 let metadata = fs::metadata(&file_path).unwrap();
852 let hash = compute_content_hash(&content);
853 let mut record = FileReadRecord::new(file_path.clone(), hash, metadata.len());
854 if let Ok(mtime) = metadata.modified() {
855 record = record.with_mtime(mtime);
856 }
857 history.write().unwrap().record_read(record);
858
859 let params = serde_json::json!({
860 "path": file_path.to_str().unwrap(),
861 "old_str": "world",
862 "new_str": "universe"
863 });
864
865 let result = tool.execute(params, &context).await.unwrap();
866
867 assert!(result.is_success());
868 assert_eq!(fs::read_to_string(&file_path).unwrap(), "hello universe");
869 }
870
871 #[tokio::test]
872 async fn test_tool_execute_batch_edit() {
873 let temp_dir = TempDir::new().unwrap();
874 let file_path = temp_dir.path().join("test.txt");
875 fs::write(&file_path, "hello world").unwrap();
876
877 let history = super::super::create_shared_history();
878 let tool = create_edit_tool_with_history(history.clone());
879 let context = create_test_context(temp_dir.path());
880
881 let content = fs::read(&file_path).unwrap();
883 let metadata = fs::metadata(&file_path).unwrap();
884 let hash = compute_content_hash(&content);
885 let mut record = FileReadRecord::new(file_path.clone(), hash, metadata.len());
886 if let Ok(mtime) = metadata.modified() {
887 record = record.with_mtime(mtime);
888 }
889 history.write().unwrap().record_read(record);
890
891 let params = serde_json::json!({
892 "path": file_path.to_str().unwrap(),
893 "edits": [
894 { "old_str": "hello", "new_str": "hi" },
895 { "old_str": "world", "new_str": "universe" }
896 ]
897 });
898
899 let result = tool.execute(params, &context).await.unwrap();
900
901 assert!(result.is_success());
902 assert_eq!(fs::read_to_string(&file_path).unwrap(), "hi universe");
903 }
904
905 #[tokio::test]
906 async fn test_tool_execute_missing_path() {
907 let temp_dir = TempDir::new().unwrap();
908 let tool = create_edit_tool();
909 let context = create_test_context(temp_dir.path());
910 let params = serde_json::json!({
911 "old_str": "old",
912 "new_str": "new"
913 });
914
915 let result = tool.execute(params, &context).await;
916 assert!(result.is_err());
917 assert!(matches!(result.unwrap_err(), ToolError::InvalidParams(_)));
918 }
919
920 #[test]
921 fn test_tool_name() {
922 let tool = create_edit_tool();
923 assert_eq!(tool.name(), "edit");
924 }
925
926 #[test]
927 fn test_tool_description() {
928 let tool = create_edit_tool();
929 assert!(!tool.description().is_empty());
930 assert!(tool.description().contains("Edit"));
931 }
932
933 #[test]
934 fn test_tool_input_schema() {
935 let tool = create_edit_tool();
936 let schema = tool.input_schema();
937 assert_eq!(schema["type"], "object");
938 assert!(schema["properties"]["path"].is_object());
939 assert!(schema["properties"]["old_str"].is_object());
940 assert!(schema["properties"]["new_str"].is_object());
941 assert!(schema["properties"]["edits"].is_object());
942 }
943
944 #[tokio::test]
945 async fn test_check_permissions_file_not_read() {
946 let temp_dir = TempDir::new().unwrap();
947 let file_path = temp_dir.path().join("test.txt");
948 fs::write(&file_path, "content").unwrap();
949
950 let tool = create_edit_tool();
951 let context = create_test_context(temp_dir.path());
952 let params = serde_json::json!({
953 "path": file_path.to_str().unwrap(),
954 "old_str": "content",
955 "new_str": "new"
956 });
957
958 let result = tool.check_permissions(¶ms, &context).await;
959 assert!(result.requires_confirmation());
960 }
961
962 #[tokio::test]
963 async fn test_check_permissions_file_not_exists() {
964 let temp_dir = TempDir::new().unwrap();
965 let tool = create_edit_tool();
966 let context = create_test_context(temp_dir.path());
967 let params = serde_json::json!({
968 "path": "nonexistent.txt",
969 "old_str": "old",
970 "new_str": "new"
971 });
972
973 let result = tool.check_permissions(¶ms, &context).await;
974 assert!(result.is_denied());
975 }
976}