use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tokio::fs;
use tokio::io::AsyncWriteExt;
use super::context::{ToolContext, ToolEvent, ToolOperation};
use super::{FileTool, ToolErrorCode, ToolOutput};
use crate::error::NikaError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EditParams {
pub file_path: String,
pub old_string: String,
pub new_string: String,
#[serde(default)]
pub replace_all: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EditResult {
pub path: String,
pub replacements: usize,
pub diff_preview: String,
}
pub struct EditTool {
ctx: Arc<ToolContext>,
}
impl EditTool {
pub fn new(ctx: Arc<ToolContext>) -> Self {
Self { ctx }
}
pub async fn execute(&self, params: EditParams) -> Result<EditResult, NikaError> {
let path = self.ctx.validate_path(¶ms.file_path)?;
self.ctx.check_permission(ToolOperation::Edit)?;
self.ctx.validate_read_before_edit(&path)?;
if !path.exists() {
return Err(NikaError::ToolError {
code: ToolErrorCode::FileNotFound.code(),
message: format!("File not found: {}", params.file_path),
});
}
let content = fs::read_to_string(&path)
.await
.map_err(|e| NikaError::ToolError {
code: ToolErrorCode::EditFailed.code(),
message: format!("Failed to read file: {}", e),
})?;
if params.old_string.is_empty() {
return Err(NikaError::ToolError {
code: ToolErrorCode::EditFailed.code(),
message: "old_string cannot be empty".to_string(),
});
}
let occurrences = content.matches(¶ms.old_string).count();
if occurrences == 0 {
return Err(NikaError::ToolError {
code: ToolErrorCode::EditFailed.code(),
message: "old_string not found in file. Make sure the string matches exactly, including whitespace and indentation.".to_string(),
});
}
if occurrences > 1 && !params.replace_all {
return Err(NikaError::ToolError {
code: ToolErrorCode::OldStringNotUnique.code(),
message: format!(
"old_string appears {} times in file. Use replace_all: true to replace all occurrences, \
or provide a more specific string that appears only once.",
occurrences
),
});
}
let new_content = if params.replace_all {
content.replace(¶ms.old_string, ¶ms.new_string)
} else {
content.replacen(¶ms.old_string, ¶ms.new_string, 1)
};
let replacements = if params.replace_all { occurrences } else { 1 };
let diff_preview = generate_diff(&content, &new_content, ¶ms.file_path);
let temp_path = path.with_extension("tmp.nika.edit");
let mut file = fs::File::create(&temp_path)
.await
.map_err(|e| NikaError::ToolError {
code: ToolErrorCode::EditFailed.code(),
message: format!("Failed to create temp file: {}", e),
})?;
file.write_all(new_content.as_bytes())
.await
.map_err(|e| NikaError::ToolError {
code: ToolErrorCode::EditFailed.code(),
message: format!("Failed to write content: {}", e),
})?;
file.flush().await.map_err(|e| NikaError::ToolError {
code: ToolErrorCode::EditFailed.code(),
message: format!("Failed to flush file: {}", e),
})?;
file.sync_all().await.map_err(|e| NikaError::ToolError {
code: ToolErrorCode::EditFailed.code(),
message: format!("Failed to sync file: {}", e),
})?;
if let Err(e) = fs::rename(&temp_path, &path).await {
let temp_clone = temp_path.clone();
tokio::spawn(async move {
let _ = fs::remove_file(temp_clone).await;
});
return Err(NikaError::ToolError {
code: ToolErrorCode::EditFailed.code(),
message: format!("Failed to finalize edit: {}", e),
});
}
self.ctx
.emit(ToolEvent::FileEdited {
path: params.file_path.clone(),
replacements,
diff_preview: diff_preview.clone(),
})
.await;
Ok(EditResult {
path: params.file_path,
replacements,
diff_preview,
})
}
}
fn generate_diff(old: &str, new: &str, file_path: &str) -> String {
let old_lines: Vec<&str> = old.lines().collect();
let new_lines: Vec<&str> = new.lines().collect();
let mut diff = format!("--- {}\n+++ {}\n", file_path, file_path);
let mut i = 0;
let mut j = 0;
while i < old_lines.len() || j < new_lines.len() {
if i < old_lines.len() && j < new_lines.len() && old_lines[i] == new_lines[j] {
i += 1;
j += 1;
} else {
let start_i = i;
let start_j = j;
while i < old_lines.len() && !new_lines[start_j..].contains(&old_lines[i]) {
i += 1;
}
while j < new_lines.len()
&& (i >= old_lines.len() || new_lines[j] != old_lines.get(i).copied().unwrap_or(""))
{
j += 1;
}
diff.push_str(&format!(
"@@ -{},{} +{},{} @@\n",
start_i + 1,
i - start_i,
start_j + 1,
j - start_j
));
for line in &old_lines[start_i..i] {
diff.push_str(&format!("-{}\n", line));
}
for line in &new_lines[start_j..j] {
diff.push_str(&format!("+{}\n", line));
}
}
}
if diff.ends_with(&format!("--- {}\n+++ {}\n", file_path, file_path)) {
"No changes".to_string()
} else {
diff
}
}
#[async_trait]
impl FileTool for EditTool {
fn name(&self) -> &'static str {
"edit"
}
fn description(&self) -> &'static str {
"Edit an existing file by replacing text. IMPORTANT: You must read the file first using \
the Read tool before editing. The old_string must be unique in the file unless \
replace_all is true. Preserves exact indentation and whitespace."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute path to the file to edit"
},
"old_string": {
"type": "string",
"description": "Exact text to find and replace (must be unique unless replace_all is true)"
},
"new_string": {
"type": "string",
"description": "Replacement text"
},
"replace_all": {
"type": "boolean",
"description": "Replace all occurrences (default: false)",
"default": false
}
},
"required": ["file_path", "old_string", "new_string", "replace_all"],
"additionalProperties": false
})
}
async fn call(&self, params: Value) -> Result<ToolOutput, NikaError> {
let params: EditParams =
serde_json::from_value(params).map_err(|e| NikaError::ToolError {
code: ToolErrorCode::EditFailed.code(),
message: format!("Invalid parameters: {}", e),
})?;
let result = self.execute(params).await?;
Ok(ToolOutput::success_with_data(
format!(
"Edited file: {} ({} replacement{})\n\n{}",
result.path,
result.replacements,
if result.replacements == 1 { "" } else { "s" },
result.diff_preview
),
serde_json::to_value(&result).unwrap_or_default(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::context::testing::setup_test;
use tempfile::TempDir;
async fn create_and_read_file(
temp_dir: &TempDir,
ctx: &Arc<ToolContext>,
name: &str,
content: &str,
) -> String {
let path = temp_dir.path().join(name);
fs::write(&path, content).await.unwrap();
ctx.mark_as_read(&path);
path.to_string_lossy().to_string()
}
#[tokio::test]
async fn test_edit_simple_replacement() {
let (temp_dir, ctx) = setup_test().await;
let file_path = create_and_read_file(&temp_dir, &ctx, "test.txt", "Hello, World!").await;
let tool = EditTool::new(ctx);
let result = tool
.execute(EditParams {
file_path: file_path.clone(),
old_string: "World".to_string(),
new_string: "Rust".to_string(),
replace_all: false,
})
.await
.unwrap();
assert_eq!(result.replacements, 1);
let content = fs::read_to_string(&file_path).await.unwrap();
assert_eq!(content, "Hello, Rust!");
}
#[tokio::test]
async fn test_edit_replace_all() {
let (temp_dir, ctx) = setup_test().await;
let file_path =
create_and_read_file(&temp_dir, &ctx, "test.txt", "foo bar foo baz foo").await;
let tool = EditTool::new(ctx);
let result = tool
.execute(EditParams {
file_path: file_path.clone(),
old_string: "foo".to_string(),
new_string: "qux".to_string(),
replace_all: true,
})
.await
.unwrap();
assert_eq!(result.replacements, 3);
let content = fs::read_to_string(&file_path).await.unwrap();
assert_eq!(content, "qux bar qux baz qux");
}
#[tokio::test]
async fn test_edit_fails_without_read() {
let (temp_dir, ctx) = setup_test().await;
let file_path = temp_dir
.path()
.join("test.txt")
.to_string_lossy()
.to_string();
fs::write(&file_path, "content").await.unwrap();
let tool = EditTool::new(ctx);
let result = tool
.execute(EditParams {
file_path,
old_string: "content".to_string(),
new_string: "new".to_string(),
replace_all: false,
})
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Must read file"));
}
#[tokio::test]
async fn test_edit_fails_not_unique() {
let (temp_dir, ctx) = setup_test().await;
let file_path = create_and_read_file(&temp_dir, &ctx, "test.txt", "foo foo foo").await;
let tool = EditTool::new(ctx);
let result = tool
.execute(EditParams {
file_path,
old_string: "foo".to_string(),
new_string: "bar".to_string(),
replace_all: false,
})
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("3 times"));
}
#[tokio::test]
async fn test_edit_not_found() {
let (temp_dir, ctx) = setup_test().await;
let file_path = create_and_read_file(&temp_dir, &ctx, "test.txt", "Hello World").await;
let tool = EditTool::new(ctx);
let result = tool
.execute(EditParams {
file_path,
old_string: "Goodbye".to_string(),
new_string: "Hi".to_string(),
replace_all: false,
})
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[tokio::test]
async fn test_edit_preserves_whitespace() {
let (temp_dir, ctx) = setup_test().await;
let file_path = create_and_read_file(
&temp_dir,
&ctx,
"test.txt",
"fn main() {\n let x = 1;\n}",
)
.await;
let tool = EditTool::new(ctx);
let result = tool
.execute(EditParams {
file_path: file_path.clone(),
old_string: " let x = 1;".to_string(),
new_string: " let x = 42;".to_string(),
replace_all: false,
})
.await
.unwrap();
assert_eq!(result.replacements, 1);
let content = fs::read_to_string(&file_path).await.unwrap();
assert!(content.contains(" let x = 42;"));
}
#[tokio::test]
async fn test_edit_permission_accept_edits() {
let (temp_dir, _) = setup_test().await;
let ctx = Arc::new(ToolContext::new(
temp_dir.path().to_path_buf(),
super::super::context::PermissionMode::AcceptEdits,
));
let file_path = temp_dir.path().join("test.txt");
fs::write(&file_path, "content").await.unwrap();
ctx.mark_as_read(&file_path);
let tool = EditTool::new(ctx);
let result = tool
.execute(EditParams {
file_path: file_path.to_string_lossy().to_string(),
old_string: "content".to_string(),
new_string: "new".to_string(),
replace_all: false,
})
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_file_tool_trait() {
let (temp_dir, ctx) = setup_test().await;
let file_path = create_and_read_file(&temp_dir, &ctx, "test.txt", "hello").await;
let tool = EditTool::new(ctx);
assert_eq!(tool.name(), "edit");
assert!(tool.description().contains("Edit"));
assert!(tool.description().contains("read the file first"));
let result = tool
.call(json!({
"file_path": file_path,
"old_string": "hello",
"new_string": "world"
}))
.await
.unwrap();
assert!(!result.is_error);
assert!(result.content.contains("Edited file"));
}
}