1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct EditParams {
28 pub file_path: String,
30
31 pub old_string: String,
33
34 pub new_string: String,
36
37 #[serde(default)]
39 pub replace_all: bool,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct EditResult {
45 pub path: String,
47
48 pub replacements: usize,
50
51 pub diff_preview: String,
53}
54
55pub struct EditTool {
69 ctx: Arc<ToolContext>,
70}
71
72impl EditTool {
73 pub fn new(ctx: Arc<ToolContext>) -> Self {
75 Self { ctx }
76 }
77
78 pub async fn execute(&self, params: EditParams) -> Result<EditResult, NikaError> {
80 let path = self.ctx.validate_path(¶ms.file_path)?;
82
83 self.ctx.check_permission(ToolOperation::Edit)?;
85
86 self.ctx.validate_read_before_edit(&path)?;
88
89 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 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 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 let occurrences = content.matches(¶ms.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 let new_content = if params.replace_all {
136 content.replace(¶ms.old_string, ¶ms.new_string)
137 } else {
138 content.replacen(¶ms.old_string, ¶ms.new_string, 1)
139 };
140
141 let replacements = if params.replace_all { occurrences } else { 1 };
142
143 let diff_preview = generate_diff(&content, &new_content, ¶ms.file_path);
145
146 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 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 if let Err(e) = fs::rename(&temp_path, &path).await {
176 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 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
204fn 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 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 let start_i = i;
222 let start_j = j;
223
224 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 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#[cfg(test)]
325mod tests {
326 use super::*;
327 use crate::tools::context::testing::setup_test;
328 use tempfile::TempDir;
329
330 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 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 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 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}