152 lines
4.9 KiB
Rust
152 lines
4.9 KiB
Rust
// FileEdit tool: exact string replacement with old/new strings (like sed but
|
|
// deterministic). Mirrors the TypeScript Edit tool behaviour.
|
|
|
|
use crate::{PermissionLevel, Tool, ToolContext, ToolResult};
|
|
use async_trait::async_trait;
|
|
use serde::Deserialize;
|
|
use serde_json::{json, Value};
|
|
use tracing::debug;
|
|
|
|
pub struct FileEditTool;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct FileEditInput {
|
|
file_path: String,
|
|
old_string: String,
|
|
new_string: String,
|
|
#[serde(default)]
|
|
replace_all: bool,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Tool for FileEditTool {
|
|
fn name(&self) -> &str {
|
|
cc_core::constants::TOOL_NAME_FILE_EDIT
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"Performs exact string replacements in files. The edit will FAIL if \
|
|
`old_string` is not unique in the file (unless `replace_all` is true). \
|
|
You MUST read the file first before editing. Preserve the exact \
|
|
indentation as it appears in the file."
|
|
}
|
|
|
|
fn permission_level(&self) -> PermissionLevel {
|
|
PermissionLevel::Write
|
|
}
|
|
|
|
fn input_schema(&self) -> Value {
|
|
json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"file_path": {
|
|
"type": "string",
|
|
"description": "The absolute path to the file to modify"
|
|
},
|
|
"old_string": {
|
|
"type": "string",
|
|
"description": "The text to replace (must be unique in the file unless replace_all is true)"
|
|
},
|
|
"new_string": {
|
|
"type": "string",
|
|
"description": "The text to replace it with (must be different from old_string)"
|
|
},
|
|
"replace_all": {
|
|
"type": "boolean",
|
|
"description": "Replace all occurrences of old_string (default false)"
|
|
}
|
|
},
|
|
"required": ["file_path", "old_string", "new_string"]
|
|
})
|
|
}
|
|
|
|
async fn execute(&self, input: Value, ctx: &ToolContext) -> ToolResult {
|
|
let params: FileEditInput = match serde_json::from_value(input) {
|
|
Ok(p) => p,
|
|
Err(e) => return ToolResult::error(format!("Invalid input: {}", e)),
|
|
};
|
|
|
|
// Validate old != new
|
|
if params.old_string == params.new_string {
|
|
return ToolResult::error(
|
|
"old_string and new_string must be different".to_string(),
|
|
);
|
|
}
|
|
|
|
let path = ctx.resolve_path(¶ms.file_path);
|
|
debug!(path = %path.display(), "Editing file");
|
|
|
|
// Permission check
|
|
if let Err(e) = ctx.check_permission(
|
|
self.name(),
|
|
&format!("Edit {}", path.display()),
|
|
false,
|
|
) {
|
|
return ToolResult::error(e.to_string());
|
|
}
|
|
|
|
// Read current content
|
|
let content = match tokio::fs::read_to_string(&path).await {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
return ToolResult::error(format!(
|
|
"Failed to read file {}: {}",
|
|
path.display(),
|
|
e
|
|
))
|
|
}
|
|
};
|
|
|
|
// Count occurrences
|
|
let count = content.matches(¶ms.old_string).count();
|
|
|
|
if count == 0 {
|
|
return ToolResult::error(format!(
|
|
"old_string not found in {}. Make sure the string matches exactly, \
|
|
including whitespace and indentation.",
|
|
path.display()
|
|
));
|
|
}
|
|
|
|
if count > 1 && !params.replace_all {
|
|
return ToolResult::error(format!(
|
|
"old_string appears {} times in {}. Either provide a larger string \
|
|
with more surrounding context to make it unique, or set replace_all \
|
|
to true to replace every occurrence.",
|
|
count,
|
|
path.display()
|
|
));
|
|
}
|
|
|
|
// Perform replacement
|
|
let new_content = if params.replace_all {
|
|
content.replace(¶ms.old_string, ¶ms.new_string)
|
|
} else {
|
|
// Replace only the first occurrence
|
|
content.replacen(¶ms.old_string, ¶ms.new_string, 1)
|
|
};
|
|
|
|
// Write back
|
|
if let Err(e) = tokio::fs::write(&path, &new_content).await {
|
|
return ToolResult::error(format!(
|
|
"Failed to write file {}: {}",
|
|
path.display(),
|
|
e
|
|
));
|
|
}
|
|
|
|
// Build a diff snippet for the response
|
|
let replacements = if params.replace_all { count } else { 1 };
|
|
let msg = format!(
|
|
"Successfully edited {} ({} replacement{}).",
|
|
path.display(),
|
|
replacements,
|
|
if replacements != 1 { "s" } else { "" }
|
|
);
|
|
|
|
ToolResult::success(msg).with_metadata(json!({
|
|
"file_path": path.display().to_string(),
|
|
"replacements": replacements,
|
|
}))
|
|
}
|
|
}
|