use crate::tool::{Tool, ToolResultRenderOptions};
use crate::types::*;
use crate::utils::diff::{self, StructuredPatchHunk};
use std::fs;
pub const FILE_EDIT_TOOL_NAME: &str = "Edit";
pub const AI_FOLDER_PERMISSION_PATTERN: &str = "/.ai/**";
pub const GLOBAL_AI_FOLDER_PERMISSION_PATTERN: &str = "~/.ai/**";
pub const FILE_UNEXPECTEDLY_MODIFIED_ERROR: &str =
"File has been unexpectedly modified. Read it again before attempting to write it.";
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct FileEditResult {
pub file_path: String,
pub old_string: String,
pub new_string: String,
pub original_file: String,
pub structured_patch: Vec<StructuredPatchHunk>,
#[serde(default)]
pub replace_all: bool,
#[serde(default)]
pub additions: usize,
#[serde(default)]
pub removals: usize,
}
pub struct FileEditTool;
impl FileEditTool {
pub fn new() -> Self {
Self
}
pub fn name(&self) -> &str {
"FileEdit"
}
pub fn description(&self) -> &str {
"Edit files by performing exact string replacements"
}
pub fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: serde_json::json!({
"file_path": {
"type": "string",
"description": "The absolute path to the file to modify"
},
"old_string": {
"type": "string",
"description": "The exact text to find and replace"
},
"new_string": {
"type": "string",
"description": "The replacement text"
},
"replace_all": {
"type": "boolean",
"description": "Replace all occurrences (default false)"
}
}),
required: Some(vec![
"file_path".to_string(),
"old_string".to_string(),
"new_string".to_string(),
]),
}
}
pub async fn execute(
&self,
input: serde_json::Value,
context: &ToolContext,
) -> Result<ToolResult, crate::error::AgentError> {
let file_path = input["file_path"]
.as_str()
.ok_or_else(|| crate::error::AgentError::Tool("file_path is required".to_string()))?;
let old_string = input["old_string"]
.as_str()
.ok_or_else(|| crate::error::AgentError::Tool("old_string is required".to_string()))?;
let new_string = input["new_string"]
.as_str()
.ok_or_else(|| crate::error::AgentError::Tool("new_string is required".to_string()))?;
let replace_all = input["replace_all"].as_bool().unwrap_or(false);
let file_path = if std::path::Path::new(file_path).is_relative() {
std::path::Path::new(&context.cwd).join(file_path)
} else {
std::path::PathBuf::from(file_path)
};
let file_path_buf = file_path.clone();
let content =
fs::read_to_string(&file_path).map_err(|e| crate::error::AgentError::Io(e))?;
let new_content = if old_string.is_empty() {
format!("{}\n{}", new_string, content)
} else {
if old_string == new_string {
return Ok(ToolResult {
result_type: "text".to_string(),
tool_use_id: "".to_string(),
content: "Error: old_string and new_string are identical".to_string(),
is_error: Some(true),
was_persisted: None,
});
}
if !content.contains(old_string) {
return Ok(ToolResult {
result_type: "text".to_string(),
tool_use_id: "".to_string(),
content: format!(
"Error: old_string not found in {}. Make sure it matches exactly including whitespace.",
file_path.display()
),
is_error: Some(true),
was_persisted: None,
});
}
if replace_all {
content.replace(old_string, new_string)
} else {
let count = content.matches(old_string).count();
if count > 1 {
return Ok(ToolResult {
result_type: "text".to_string(),
tool_use_id: "".to_string(),
content: format!(
"Error: old_string appears {} times in the file. Provide more context to make it unique, or set replace_all: true.",
count
),
is_error: Some(true),
was_persisted: None,
});
}
content.replacen(old_string, new_string, 1)
}
};
fs::write(&file_path_buf, &new_content).map_err(|e| crate::error::AgentError::Io(e))?;
let patch = diff::generate_patch(&content, &new_content);
let (additions, removals) = diff::count_lines_changed(&patch, Some(&new_content));
let result = FileEditResult {
file_path: file_path_buf.to_string_lossy().to_string(),
old_string: old_string.to_string(),
new_string: new_string.to_string(),
original_file: content,
structured_patch: patch,
replace_all,
additions,
removals,
};
let content_json = serde_json::to_string(&result).map_err(|e| {
crate::error::AgentError::Tool(format!("Failed to serialize result: {}", e))
})?;
Ok(ToolResult {
result_type: "text".to_string(),
tool_use_id: "".to_string(),
content: content_json,
is_error: None,
was_persisted: None,
})
}
pub fn user_facing_name(&self, input: Option<&serde_json::Value>) -> String {
match input {
Some(inp) => {
let old_string = inp["old_string"].as_str().unwrap_or("");
if old_string.is_empty() {
"Create".to_string()
} else {
"Update".to_string()
}
}
None => "Edit".to_string(),
}
}
pub fn get_tool_use_summary(&self, input: Option<&serde_json::Value>) -> Option<String> {
input.and_then(|inp| inp["file_path"].as_str().map(|s| s.to_string()))
}
pub fn render_tool_result_message(&self, content: &serde_json::Value) -> Option<String> {
let result: FileEditResult = serde_json::from_value(content.clone()).ok()?;
let file_path = &result.file_path;
if file_path.contains("/.ai/plans/") || file_path.contains("/.ai/plan/") {
return Some(format!("Updated plan: {}", file_path));
}
if result.old_string.is_empty() {
return Some(format!("Added {} lines in {}", result.additions, file_path));
}
if result.removals == 0 && result.additions == 0 {
return Some(format!("No visible changes to {}", file_path));
}
let mut msg = format!(
"Updated {} ({} {})",
file_path,
result.additions,
if result.additions == 1 {
"line"
} else {
"lines"
}
);
if result.removals > 0 {
msg.push_str(&format!(
", {} {} removed",
result.removals,
if result.removals == 1 {
"line"
} else {
"lines"
}
));
}
Some(msg)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_file_edit_tool_name() {
let tool = FileEditTool::new();
assert_eq!(tool.name(), "FileEdit");
}
#[test]
fn test_user_facing_name_edit() {
let tool = FileEditTool::new();
let input = serde_json::json!({
"file_path": "/test.txt",
"old_string": "old",
"new_string": "new"
});
assert_eq!(tool.user_facing_name(Some(&input)), "Update");
}
#[test]
fn test_user_facing_name_create() {
let tool = FileEditTool::new();
let input = serde_json::json!({
"file_path": "/test.txt",
"old_string": "",
"new_string": "new content"
});
assert_eq!(tool.user_facing_name(Some(&input)), "Create");
}
#[test]
fn test_user_facing_name_no_input() {
let tool = FileEditTool::new();
assert_eq!(tool.user_facing_name(None), "Edit");
}
#[test]
fn test_get_tool_use_summary() {
let tool = FileEditTool::new();
let input = serde_json::json!({
"file_path": "/path/to/file.rs",
"old_string": "test",
"new_string": "value"
});
assert_eq!(
tool.get_tool_use_summary(Some(&input)),
Some("/path/to/file.rs".to_string())
);
}
#[test]
fn test_get_tool_use_summary_no_path() {
let tool = FileEditTool::new();
let input = serde_json::json!({
"old_string": "test",
"new_string": "value"
});
assert_eq!(tool.get_tool_use_summary(Some(&input)), None);
}
#[test]
fn test_render_tool_result_message_edit() {
let tool = FileEditTool::new();
let result = FileEditResult {
file_path: "/test.txt".to_string(),
old_string: "old".to_string(),
new_string: "new".to_string(),
original_file: "old\nline2".to_string(),
structured_patch: vec![],
replace_all: false,
additions: 1,
removals: 1,
};
let rendered = tool.render_tool_result_message(&serde_json::json!(result));
assert!(rendered.is_some());
let msg = rendered.unwrap();
assert!(msg.contains("Updated"));
assert!(msg.contains("1 line"));
}
#[test]
fn test_render_tool_result_message_create() {
let tool = FileEditTool::new();
let result = FileEditResult {
file_path: "/new.txt".to_string(),
old_string: "".to_string(),
new_string: "new content".to_string(),
original_file: "".to_string(),
structured_patch: vec![],
replace_all: false,
additions: 3,
removals: 0,
};
let rendered = tool.render_tool_result_message(&serde_json::json!(result));
assert!(rendered.is_some());
assert!(rendered.unwrap().contains("Added 3 lines"));
}
}