use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use tracing::{info_span, instrument, Instrument};
use crate::tools::context::ToolContext;
use crate::tools::error::ToolError;
use crate::tools::registry::{Tool, ToolDefinition};
#[derive(Clone)]
pub struct EditTool {
context: ToolContext,
}
impl EditTool {
pub fn new(context: ToolContext) -> Self {
Self { context }
}
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct EditArgs {
pub file_path: String,
pub old_string: String,
pub new_string: String,
#[serde(default)]
pub replace_all: bool,
}
#[derive(Debug, Serialize)]
pub struct EditOutput {
pub file_path: String,
pub replacements: usize,
pub old_length: usize,
pub new_length: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
}
impl Tool for EditTool {
const NAME: &'static str = "edit";
type Args = EditArgs;
type Output = EditOutput;
type Error = ToolError;
async fn definition(&self, _prompt: String) -> ToolDefinition {
let schema = schemars::schema_for!(EditArgs);
ToolDefinition {
name: Self::NAME.to_string(),
description: r#"Edit file by replacing text with exact string matching.
## BEFORE CALLING THIS TOOL
Think step-by-step:
1. Have I read the file first? (REQUIRED - tool will fail otherwise)
2. What exact text do I need to replace?
3. Is the old_string unique in the file?
IMPORTANT: You MUST read the file using the 'read' tool BEFORE calling edit.
## PARAMETERS
- `file_path` (REQUIRED, STRING): Path to the file as a plain string
CORRECT: "src/contracts/Token.sol"
CORRECT: "./contracts/Pool.sol"
WRONG: {"file_path": "..."} <-- Do NOT pass JSON objects!
WRONG: {} <-- Empty object is invalid!
- `old_string` (REQUIRED, STRING): Exact text to find and replace
CORRECT: "function withdraw()"
CORRECT: " uint256 balance;"
WRONG: Line numbers from read output like " 42\t..."
WRONG: Approximate text that doesn't match exactly
- `new_string` (REQUIRED, STRING): Replacement text
- `replace_all` (optional, BOOLEAN): Replace all occurrences (default: false)
## EXAMPLES
Replace a function name:
file_path: "src/lib.rs"
old_string: "fn old_name()"
new_string: "fn new_name()"
Add a new line after existing code:
file_path: "src/main.rs"
old_string: "use std::io;"
new_string: "use std::io;\nuse std::fs;"
## WORKFLOW WITH READ TOOL
1. Read the file: read tool with file_path: "myfile.rs"
2. Find the text (after the tab, NOT including line numbers)
3. Copy EXACT text for old_string
4. Call edit with old_string and new_string
## COMMON MISTAKES TO AVOID
1. Do NOT call edit without reading the file first
2. Do NOT include line number prefixes (like " 42\t") in old_string
3. Do NOT use approximate text - old_string must match EXACTLY
4. If old_string appears multiple times, use replace_all=true or provide more context
5. Do NOT pass JSON objects as parameters - use plain strings
"#
.to_string(),
parameters: serde_json::to_value(schema).unwrap_or_default(),
}
}
#[instrument(skip(self, args), fields(tool = "edit", file_path = %args.file_path))]
async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
let (file_path, resolution) = self
.context
.validate_path_with_resolution(&args.file_path)?;
if self.context.is_excluded(&file_path) {
return Err(ToolError::Validation(format!(
"Access denied: '{}' is in an excluded directory",
file_path.display()
)));
}
let resolution_msg = resolution.as_ref().map(|r| {
let msg = format!("Resolved: '{}' -> '{}'", r.original_path, r.resolved_path);
tracing::info!(resolution = %msg, "Path resolved");
msg
});
let content = tokio::fs::read_to_string(&file_path)
.instrument(info_span!("read_file"))
.await
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
ToolError::FileNotFound(file_path.display().to_string())
} else {
ToolError::Io(e)
}
})?;
let old_length = content.len();
let occurrence_count = content.matches(&args.old_string).count();
if occurrence_count == 0 {
return Err(ToolError::Validation(format!(
"String not found in file: '{}'",
args.old_string
)));
}
if !args.replace_all && occurrence_count > 1 {
return Err(ToolError::Validation(format!(
"String '{}' appears {} times. Use replace_all=true or provide more context.",
args.old_string, occurrence_count
)));
}
let (new_content, replacements) = if args.replace_all {
(
content.replace(&args.old_string, &args.new_string),
occurrence_count,
)
} else {
(content.replacen(&args.old_string, &args.new_string, 1), 1)
};
let new_length = new_content.len();
tokio::fs::write(&file_path, &new_content)
.instrument(info_span!("write_file"))
.await
.map_err(ToolError::Io)?;
Ok(EditOutput {
file_path: file_path.display().to_string(),
replacements,
old_length,
new_length,
resolution: resolution_msg,
})
}
}