Skip to main content

aster/tools/file/
edit.rs

1//! Edit Tool Implementation
2//!
3//! This module implements the `EditTool` for editing files with:
4//! - Smart string matching with quote normalization
5//! - Batch edits with atomic rollback
6//! - External file modification detection
7//! - Match uniqueness validation
8//!
9//! Requirements: 4.7, 4.8, 4.9, 4.10
10
11use 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/// A single edit operation
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct Edit {
26    /// The string to find and replace
27    pub old_str: String,
28    /// The replacement string
29    pub new_str: String,
30}
31
32impl Edit {
33    /// Create a new edit operation
34    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/// Result of a string match operation
43#[derive(Debug, Clone)]
44pub struct MatchResult {
45    /// Number of matches found
46    pub count: usize,
47    /// Positions of matches (byte offsets)
48    pub positions: Vec<usize>,
49}
50
51/// Edit Tool for modifying files
52///
53/// Supports:
54/// - Smart string matching with quote normalization
55/// - Batch edits with atomic rollback
56/// - External modification detection
57/// - Match uniqueness validation
58///
59/// Requirements: 4.7, 4.8, 4.9, 4.10
60#[derive(Debug)]
61pub struct EditTool {
62    /// Shared file read history
63    read_history: SharedFileReadHistory,
64    /// Whether to require file to be read before editing
65    require_read_before_edit: bool,
66    /// Whether to enable smart quote matching
67    smart_quote_matching: bool,
68}
69
70impl EditTool {
71    /// Create a new EditTool with shared history
72    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    /// Set whether to require read before edit
81    pub fn with_require_read_before_edit(mut self, require: bool) -> Self {
82        self.require_read_before_edit = require;
83        self
84    }
85
86    /// Set whether to enable smart quote matching
87    pub fn with_smart_quote_matching(mut self, enabled: bool) -> Self {
88        self.smart_quote_matching = enabled;
89        self
90    }
91
92    /// Get the shared read history
93    pub fn read_history(&self) -> &SharedFileReadHistory {
94        &self.read_history
95    }
96
97    /// Resolve a path relative to the working directory
98    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
107// =============================================================================
108// Smart String Matching (Requirements: 4.7)
109// =============================================================================
110
111impl EditTool {
112    /// Normalize quotes in a string for matching
113    ///
114    /// Converts various quote styles to standard ASCII quotes:
115    /// - Smart quotes (" " ' ') -> ASCII quotes (" ')
116    /// - Curly quotes -> straight quotes
117    ///
118    /// Requirements: 4.7
119    pub fn normalize_quotes(s: &str) -> String {
120        s.chars()
121            .map(|c| match c {
122                // Double quotes (using Unicode code points)
123                // U+201C LEFT DOUBLE QUOTATION MARK
124                // U+201D RIGHT DOUBLE QUOTATION MARK
125                // U+201E DOUBLE LOW-9 QUOTATION MARK
126                // U+201F DOUBLE HIGH-REVERSED-9 QUOTATION MARK
127                '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' => '"',
128                // Single quotes
129                // U+2018 LEFT SINGLE QUOTATION MARK
130                // U+2019 RIGHT SINGLE QUOTATION MARK
131                // U+201A SINGLE LOW-9 QUOTATION MARK
132                // U+201B SINGLE HIGH-REVERSED-9 QUOTATION MARK
133                '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}' => '\'',
134                // Guillemets (optional)
135                // U+00AB LEFT-POINTING DOUBLE ANGLE QUOTATION MARK
136                // U+00BB RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK
137                // U+2039 SINGLE LEFT-POINTING ANGLE QUOTATION MARK
138                // U+203A SINGLE RIGHT-POINTING ANGLE QUOTATION MARK
139                '\u{00AB}' | '\u{00BB}' | '\u{2039}' | '\u{203A}' => '"',
140                _ => c,
141            })
142            .collect()
143    }
144
145    /// Find all matches of a string in content
146    ///
147    /// If smart_quote_matching is enabled, normalizes quotes before matching.
148    ///
149    /// Requirements: 4.7
150    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    /// Find exact matches without quote normalization
159    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    /// Find matches with smart quote normalization
169    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        // First try exact match
174        let exact_result = self.find_matches_exact(content, search);
175        if exact_result.count > 0 {
176            return exact_result;
177        }
178
179        // Try normalized match
180        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    /// Check if a match is unique (exactly one occurrence)
192    pub fn is_unique_match(&self, content: &str, search: &str) -> bool {
193        self.find_matches(content, search).count == 1
194    }
195}
196
197// =============================================================================
198// Single Edit Implementation
199// =============================================================================
200
201impl EditTool {
202    /// Apply a single edit to a file
203    ///
204    /// Requirements: 4.7, 4.9, 4.10
205    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        // Check file exists
215        if !full_path.exists() {
216            return Err(ToolError::execution_failed(format!(
217                "File not found: {}",
218                full_path.display()
219            )));
220        }
221
222        // Check read history
223        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        // Check for external modifications (Requirements: 4.9)
234        self.check_external_modification(&full_path)?;
235
236        // Read current content
237        let content = fs::read_to_string(&full_path)?;
238
239        // Find matches (Requirements: 4.10)
240        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        // Apply the edit
262        let new_content = if self.smart_quote_matching {
263            // Use the actual position from normalized matching
264            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        // Write the file
272        fs::write(&full_path, &new_content)?;
273
274        // Update read history
275        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    /// Check for external file modifications since last read
293    ///
294    /// Requirements: 4.9
295    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    /// Update read history after editing
316    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
334// =============================================================================
335// Batch Edit Implementation (Requirements: 4.8)
336// =============================================================================
337
338impl EditTool {
339    /// Apply multiple edits to a file atomically
340    ///
341    /// All edits are validated before any are applied.
342    /// If any edit fails validation, no changes are made.
343    ///
344    /// Requirements: 4.8
345    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        // Check file exists
354        if !full_path.exists() {
355            return Err(ToolError::execution_failed(format!(
356                "File not found: {}",
357                full_path.display()
358            )));
359        }
360
361        // Check read history
362        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        // Check for external modifications
373        self.check_external_modification(&full_path)?;
374
375        // Read current content
376        let original_content = fs::read_to_string(&full_path)?;
377        let mut content = original_content.clone();
378
379        // Validate all edits first
380        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            // Apply edit to working content for subsequent validation
404            content = content.replacen(&edit.old_str, &edit.new_str, 1);
405        }
406
407        // All validations passed, write the final content
408        fs::write(&full_path, &content)?;
409
410        // Update read history
411        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// =============================================================================
430// Tool Trait Implementation
431// =============================================================================
432
433#[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        // Check for cancellation
485        if context.is_cancelled() {
486            return Err(ToolError::Cancelled);
487        }
488
489        // Extract path parameter
490        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        // Check for batch edits
498        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        // Single edit
510        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        // Extract path for permission check
529        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        // Check if file exists
538        if !full_path.exists() {
539            return PermissionCheckResult::deny(format!(
540                "File does not exist: {}",
541                full_path.display()
542            ));
543        }
544
545        // Check if file has been read
546        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) // Don't retry edits
564            .with_base_timeout(std::time::Duration::from_secs(30))
565    }
566}
567
568// =============================================================================
569// Unit Tests
570// =============================================================================
571
572#[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        // Smart double quotes - using Unicode escape sequences
601        let smart_double_open = "\u{201C}"; // "
602        let smart_double_close = "\u{201D}"; // "
603        let smart_single_open = "\u{2018}"; // '
604        let smart_single_close = "\u{2019}"; // '
605
606        // Test smart double quotes
607        let input = format!("{}hello{}", smart_double_open, smart_double_close);
608        assert_eq!(EditTool::normalize_quotes(&input), "\"hello\"");
609
610        // Test smart single quotes
611        let input = format!("{}hello{}", smart_single_open, smart_single_close);
612        assert_eq!(EditTool::normalize_quotes(&input), "'hello'");
613
614        // Test mixed
615        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        // No quotes
622        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        // Using Unicode escape sequences for smart quotes
658        let smart_double_open = "\u{201C}"; // "
659        let smart_double_close = "\u{201D}"; // "
660        let content = format!("say {}hello{}", smart_double_open, smart_double_close);
661
662        // Should match with straight quotes
663        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        // Simulate reading the file first
678        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        // Simulate reading
736        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        // Simulate reading
761        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        // Original content should be preserved
774        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        // Simulate reading
788        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        // Simulate reading
820        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        // Second edit will fail (string not found after first edit)
830        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        // Original content should be preserved (atomic rollback)
836        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        // Simulate reading
850        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        // Simulate reading
882        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(&params, &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(&params, &context).await;
974        assert!(result.is_denied());
975    }
976}