Skip to main content

nika_engine/tools/
edit.rs

1//! Edit Tool - Modify existing files
2//!
3//! Claude Code-like file editing with:
4//! - Read-before-edit validation
5//! - Unique string matching
6//! - Atomic updates (temp file + rename)
7//! - Diff preview
8
9use std::sync::Arc;
10
11use async_trait::async_trait;
12use serde::{Deserialize, Serialize};
13use serde_json::{json, Value};
14use tokio::fs;
15use tokio::io::AsyncWriteExt;
16
17use super::context::{ToolContext, ToolEvent, ToolOperation};
18use super::{FileTool, ToolErrorCode, ToolOutput};
19use crate::error::NikaError;
20
21// ═══════════════════════════════════════════════════════════════════════════
22// PARAMETERS & RESULT
23// ═══════════════════════════════════════════════════════════════════════════
24
25/// Parameters for the Edit tool
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct EditParams {
28    /// Absolute path to the file to edit
29    pub file_path: String,
30
31    /// Text to find and replace
32    pub old_string: String,
33
34    /// Replacement text
35    pub new_string: String,
36
37    /// Replace all occurrences (default: false)
38    #[serde(default)]
39    pub replace_all: bool,
40}
41
42/// Result from editing a file
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct EditResult {
45    /// Path of the edited file
46    pub path: String,
47
48    /// Number of replacements made
49    pub replacements: usize,
50
51    /// Diff preview (unified format)
52    pub diff_preview: String,
53}
54
55// ═══════════════════════════════════════════════════════════════════════════
56// EDIT TOOL
57// ═══════════════════════════════════════════════════════════════════════════
58
59/// Edit tool for modifying existing files
60///
61/// # Features
62///
63/// - Read-before-edit validation (must read file first)
64/// - Unique string matching (fails if old_string appears multiple times)
65/// - replace_all mode for bulk replacements
66/// - Atomic write (temp file + rename)
67/// - Diff preview for verification
68pub struct EditTool {
69    ctx: Arc<ToolContext>,
70}
71
72impl EditTool {
73    /// Create a new Edit tool
74    pub fn new(ctx: Arc<ToolContext>) -> Self {
75        Self { ctx }
76    }
77
78    /// Execute the edit operation
79    pub async fn execute(&self, params: EditParams) -> Result<EditResult, NikaError> {
80        // Validate path
81        let path = self.ctx.validate_path(&params.file_path)?;
82
83        // Check permission
84        self.ctx.check_permission(ToolOperation::Edit)?;
85
86        // Validate read-before-edit
87        self.ctx.validate_read_before_edit(&path)?;
88
89        // Check file exists
90        if !path.exists() {
91            return Err(NikaError::ToolError {
92                code: ToolErrorCode::FileNotFound.code(),
93                message: format!("File not found: {}", params.file_path),
94            });
95        }
96
97        // Read current content
98        let content = fs::read_to_string(&path)
99            .await
100            .map_err(|e| NikaError::ToolError {
101                code: ToolErrorCode::EditFailed.code(),
102                message: format!("Failed to read file: {}", e),
103            })?;
104
105        // Reject empty old_string — str::replace("", x) inserts between every character
106        if params.old_string.is_empty() {
107            return Err(NikaError::ToolError {
108                code: ToolErrorCode::EditFailed.code(),
109                message: "old_string cannot be empty".to_string(),
110            });
111        }
112
113        // Count occurrences
114        let occurrences = content.matches(&params.old_string).count();
115
116        if occurrences == 0 {
117            return Err(NikaError::ToolError {
118                code: ToolErrorCode::EditFailed.code(),
119                message: "old_string not found in file. Make sure the string matches exactly, including whitespace and indentation.".to_string(),
120            });
121        }
122
123        if occurrences > 1 && !params.replace_all {
124            return Err(NikaError::ToolError {
125                code: ToolErrorCode::OldStringNotUnique.code(),
126                message: format!(
127                    "old_string appears {} times in file. Use replace_all: true to replace all occurrences, \
128                     or provide a more specific string that appears only once.",
129                    occurrences
130                ),
131            });
132        }
133
134        // Perform replacement
135        let new_content = if params.replace_all {
136            content.replace(&params.old_string, &params.new_string)
137        } else {
138            content.replacen(&params.old_string, &params.new_string, 1)
139        };
140
141        let replacements = if params.replace_all { occurrences } else { 1 };
142
143        // Generate diff preview
144        let diff_preview = generate_diff(&content, &new_content, &params.file_path);
145
146        // Atomic write: temp file + rename
147        let temp_path = path.with_extension("tmp.nika.edit");
148
149        let mut file = fs::File::create(&temp_path)
150            .await
151            .map_err(|e| NikaError::ToolError {
152                code: ToolErrorCode::EditFailed.code(),
153                message: format!("Failed to create temp file: {}", e),
154            })?;
155
156        file.write_all(new_content.as_bytes())
157            .await
158            .map_err(|e| NikaError::ToolError {
159                code: ToolErrorCode::EditFailed.code(),
160                message: format!("Failed to write content: {}", e),
161            })?;
162
163        file.flush().await.map_err(|e| NikaError::ToolError {
164            code: ToolErrorCode::EditFailed.code(),
165            message: format!("Failed to flush file: {}", e),
166        })?;
167
168        // Ensure data hits disk before rename (durability)
169        file.sync_all().await.map_err(|e| NikaError::ToolError {
170            code: ToolErrorCode::EditFailed.code(),
171            message: format!("Failed to sync file: {}", e),
172        })?;
173
174        // Atomic rename with async cleanup on error
175        if let Err(e) = fs::rename(&temp_path, &path).await {
176            // Async cleanup to avoid blocking the executor
177            let temp_clone = temp_path.clone();
178            tokio::spawn(async move {
179                let _ = fs::remove_file(temp_clone).await;
180            });
181            return Err(NikaError::ToolError {
182                code: ToolErrorCode::EditFailed.code(),
183                message: format!("Failed to finalize edit: {}", e),
184            });
185        }
186
187        // Emit event
188        self.ctx
189            .emit(ToolEvent::FileEdited {
190                path: params.file_path.clone(),
191                replacements,
192                diff_preview: diff_preview.clone(),
193            })
194            .await;
195
196        Ok(EditResult {
197            path: params.file_path,
198            replacements,
199            diff_preview,
200        })
201    }
202}
203
204/// Generate a simple unified diff preview
205fn generate_diff(old: &str, new: &str, file_path: &str) -> String {
206    let old_lines: Vec<&str> = old.lines().collect();
207    let new_lines: Vec<&str> = new.lines().collect();
208
209    let mut diff = format!("--- {}\n+++ {}\n", file_path, file_path);
210
211    // Find changed regions (simple approach)
212    let mut i = 0;
213    let mut j = 0;
214
215    while i < old_lines.len() || j < new_lines.len() {
216        if i < old_lines.len() && j < new_lines.len() && old_lines[i] == new_lines[j] {
217            i += 1;
218            j += 1;
219        } else {
220            // Found a difference
221            let start_i = i;
222            let start_j = j;
223
224            // Find where they converge again
225            while i < old_lines.len() && !new_lines[start_j..].contains(&old_lines[i]) {
226                i += 1;
227            }
228            while j < new_lines.len()
229                && (i >= old_lines.len() || new_lines[j] != old_lines.get(i).copied().unwrap_or(""))
230            {
231                j += 1;
232            }
233
234            // Output the hunk
235            diff.push_str(&format!(
236                "@@ -{},{} +{},{} @@\n",
237                start_i + 1,
238                i - start_i,
239                start_j + 1,
240                j - start_j
241            ));
242
243            for line in &old_lines[start_i..i] {
244                diff.push_str(&format!("-{}\n", line));
245            }
246            for line in &new_lines[start_j..j] {
247                diff.push_str(&format!("+{}\n", line));
248            }
249        }
250    }
251
252    if diff.ends_with(&format!("--- {}\n+++ {}\n", file_path, file_path)) {
253        "No changes".to_string()
254    } else {
255        diff
256    }
257}
258
259#[async_trait]
260impl FileTool for EditTool {
261    fn name(&self) -> &'static str {
262        "edit"
263    }
264
265    fn description(&self) -> &'static str {
266        "Edit an existing file by replacing text. IMPORTANT: You must read the file first using \
267         the Read tool before editing. The old_string must be unique in the file unless \
268         replace_all is true. Preserves exact indentation and whitespace."
269    }
270
271    fn parameters_schema(&self) -> Value {
272        json!({
273            "type": "object",
274            "properties": {
275                "file_path": {
276                    "type": "string",
277                    "description": "Absolute path to the file to edit"
278                },
279                "old_string": {
280                    "type": "string",
281                    "description": "Exact text to find and replace (must be unique unless replace_all is true)"
282                },
283                "new_string": {
284                    "type": "string",
285                    "description": "Replacement text"
286                },
287                "replace_all": {
288                    "type": "boolean",
289                    "description": "Replace all occurrences (default: false)",
290                    "default": false
291                }
292            },
293            "required": ["file_path", "old_string", "new_string", "replace_all"],
294            "additionalProperties": false
295        })
296    }
297
298    async fn call(&self, params: Value) -> Result<ToolOutput, NikaError> {
299        let params: EditParams =
300            serde_json::from_value(params).map_err(|e| NikaError::ToolError {
301                code: ToolErrorCode::EditFailed.code(),
302                message: format!("Invalid parameters: {}", e),
303            })?;
304
305        let result = self.execute(params).await?;
306
307        Ok(ToolOutput::success_with_data(
308            format!(
309                "Edited file: {} ({} replacement{})\n\n{}",
310                result.path,
311                result.replacements,
312                if result.replacements == 1 { "" } else { "s" },
313                result.diff_preview
314            ),
315            serde_json::to_value(&result).unwrap_or_default(),
316        ))
317    }
318}
319
320// ═══════════════════════════════════════════════════════════════════════════
321// TESTS
322// ═══════════════════════════════════════════════════════════════════════════
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use crate::tools::context::testing::setup_test;
328    use tempfile::TempDir;
329
330    /// Helper specific to edit tests: creates file and marks it as read
331    async fn create_and_read_file(
332        temp_dir: &TempDir,
333        ctx: &Arc<ToolContext>,
334        name: &str,
335        content: &str,
336    ) -> String {
337        let path = temp_dir.path().join(name);
338        fs::write(&path, content).await.unwrap();
339
340        // Mark as read (simulating Read tool usage)
341        ctx.mark_as_read(&path);
342
343        path.to_string_lossy().to_string()
344    }
345
346    #[tokio::test]
347    async fn test_edit_simple_replacement() {
348        let (temp_dir, ctx) = setup_test().await;
349        let file_path = create_and_read_file(&temp_dir, &ctx, "test.txt", "Hello, World!").await;
350
351        let tool = EditTool::new(ctx);
352        let result = tool
353            .execute(EditParams {
354                file_path: file_path.clone(),
355                old_string: "World".to_string(),
356                new_string: "Rust".to_string(),
357                replace_all: false,
358            })
359            .await
360            .unwrap();
361
362        assert_eq!(result.replacements, 1);
363
364        let content = fs::read_to_string(&file_path).await.unwrap();
365        assert_eq!(content, "Hello, Rust!");
366    }
367
368    #[tokio::test]
369    async fn test_edit_replace_all() {
370        let (temp_dir, ctx) = setup_test().await;
371        let file_path =
372            create_and_read_file(&temp_dir, &ctx, "test.txt", "foo bar foo baz foo").await;
373
374        let tool = EditTool::new(ctx);
375        let result = tool
376            .execute(EditParams {
377                file_path: file_path.clone(),
378                old_string: "foo".to_string(),
379                new_string: "qux".to_string(),
380                replace_all: true,
381            })
382            .await
383            .unwrap();
384
385        assert_eq!(result.replacements, 3);
386
387        let content = fs::read_to_string(&file_path).await.unwrap();
388        assert_eq!(content, "qux bar qux baz qux");
389    }
390
391    #[tokio::test]
392    async fn test_edit_fails_without_read() {
393        let (temp_dir, ctx) = setup_test().await;
394        let file_path = temp_dir
395            .path()
396            .join("test.txt")
397            .to_string_lossy()
398            .to_string();
399        fs::write(&file_path, "content").await.unwrap();
400
401        // Don't mark as read
402
403        let tool = EditTool::new(ctx);
404        let result = tool
405            .execute(EditParams {
406                file_path,
407                old_string: "content".to_string(),
408                new_string: "new".to_string(),
409                replace_all: false,
410            })
411            .await;
412
413        assert!(result.is_err());
414        assert!(result.unwrap_err().to_string().contains("Must read file"));
415    }
416
417    #[tokio::test]
418    async fn test_edit_fails_not_unique() {
419        let (temp_dir, ctx) = setup_test().await;
420        let file_path = create_and_read_file(&temp_dir, &ctx, "test.txt", "foo foo foo").await;
421
422        let tool = EditTool::new(ctx);
423        let result = tool
424            .execute(EditParams {
425                file_path,
426                old_string: "foo".to_string(),
427                new_string: "bar".to_string(),
428                replace_all: false,
429            })
430            .await;
431
432        assert!(result.is_err());
433        assert!(result.unwrap_err().to_string().contains("3 times"));
434    }
435
436    #[tokio::test]
437    async fn test_edit_not_found() {
438        let (temp_dir, ctx) = setup_test().await;
439        let file_path = create_and_read_file(&temp_dir, &ctx, "test.txt", "Hello World").await;
440
441        let tool = EditTool::new(ctx);
442        let result = tool
443            .execute(EditParams {
444                file_path,
445                old_string: "Goodbye".to_string(),
446                new_string: "Hi".to_string(),
447                replace_all: false,
448            })
449            .await;
450
451        assert!(result.is_err());
452        assert!(result.unwrap_err().to_string().contains("not found"));
453    }
454
455    #[tokio::test]
456    async fn test_edit_preserves_whitespace() {
457        let (temp_dir, ctx) = setup_test().await;
458        let file_path = create_and_read_file(
459            &temp_dir,
460            &ctx,
461            "test.txt",
462            "fn main() {\n    let x = 1;\n}",
463        )
464        .await;
465
466        let tool = EditTool::new(ctx);
467        let result = tool
468            .execute(EditParams {
469                file_path: file_path.clone(),
470                old_string: "    let x = 1;".to_string(),
471                new_string: "    let x = 42;".to_string(),
472                replace_all: false,
473            })
474            .await
475            .unwrap();
476
477        assert_eq!(result.replacements, 1);
478
479        let content = fs::read_to_string(&file_path).await.unwrap();
480        assert!(content.contains("    let x = 42;"));
481    }
482
483    #[tokio::test]
484    async fn test_edit_permission_accept_edits() {
485        let (temp_dir, _) = setup_test().await;
486        let ctx = Arc::new(ToolContext::new(
487            temp_dir.path().to_path_buf(),
488            super::super::context::PermissionMode::AcceptEdits,
489        ));
490        let file_path = temp_dir.path().join("test.txt");
491        fs::write(&file_path, "content").await.unwrap();
492        ctx.mark_as_read(&file_path);
493
494        let tool = EditTool::new(ctx);
495        let result = tool
496            .execute(EditParams {
497                file_path: file_path.to_string_lossy().to_string(),
498                old_string: "content".to_string(),
499                new_string: "new".to_string(),
500                replace_all: false,
501            })
502            .await;
503
504        // AcceptEdits mode allows edits
505        assert!(result.is_ok());
506    }
507
508    #[tokio::test]
509    async fn test_file_tool_trait() {
510        let (temp_dir, ctx) = setup_test().await;
511        let file_path = create_and_read_file(&temp_dir, &ctx, "test.txt", "hello").await;
512
513        let tool = EditTool::new(ctx);
514
515        assert_eq!(tool.name(), "edit");
516        assert!(tool.description().contains("Edit"));
517        assert!(tool.description().contains("read the file first"));
518
519        let result = tool
520            .call(json!({
521                "file_path": file_path,
522                "old_string": "hello",
523                "new_string": "world"
524            }))
525            .await
526            .unwrap();
527
528        assert!(!result.is_error);
529        assert!(result.content.contains("Edited file"));
530    }
531}