use std::path::Path;
use std::sync::Arc;
use async_trait::async_trait;
use locode_host::{FsError, Host};
use locode_tools::{Tool, ToolCtx, ToolError, ToolKind, ToolOutput};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::state::ClaudeSessionState;
pub(crate) struct ClaudeEdit {
host: Arc<Host>,
state: Arc<ClaudeSessionState>,
}
impl ClaudeEdit {
pub(crate) fn new(host: Arc<Host>, state: Arc<ClaudeSessionState>) -> Self {
Self { host, state }
}
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct EditArgs {
#[schemars(description = "The absolute path to the file to modify")]
file_path: String,
#[schemars(description = "The text to replace")]
old_string: String,
#[schemars(description = "The text to replace it with (must be different from old_string)")]
new_string: String,
#[schemars(description = "Replace all occurrences of old_string (default false)")]
#[serde(default)]
replace_all: bool,
}
#[derive(Debug, Serialize)]
pub(crate) struct EditOutput {
path: String,
created: bool,
replacements: usize,
#[serde(skip)]
message: String,
}
impl ToolOutput for EditOutput {
fn to_prompt_text(&self) -> String {
self.message.clone()
}
}
fn is_not_found(err: &FsError) -> bool {
matches!(err, FsError::Io { source, .. } if source.kind() == std::io::ErrorKind::NotFound)
}
#[async_trait]
impl Tool for ClaudeEdit {
type Args = EditArgs;
type Output = EditOutput;
fn kind(&self) -> ToolKind {
ToolKind::Edit
}
#[allow(clippy::unnecessary_literal_bound)] fn description(&self) -> &str {
include_str!("descriptions/edit.md")
}
async fn run(&self, ctx: &ToolCtx, args: EditArgs) -> Result<Self::Output, ToolError> {
if args.old_string == args.new_string {
return Err(ToolError::Respond(
"No changes to make: old_string and new_string are exactly the same.".to_string(),
));
}
let path = Path::new(&args.file_path);
let resolved = self
.host
.resolve_in_jail(&ctx.cwd, path)
.await
.map_err(|e| ToolError::Respond(e.to_string()))?;
let read = match self.host.read_file(&ctx.cwd, path).await {
Ok(read) => Some(read),
Err(e) if is_not_found(&e) => None,
Err(e) => return Err(ToolError::Respond(e.to_string())),
};
let Some(read) = read else {
if args.old_string.is_empty() {
return self
.write_result(ctx, &resolved, &args, &args.new_string, true, 1)
.await;
}
return Err(ToolError::Respond(format!(
"File does not exist. Note: your current working directory is {}.",
ctx.cwd.display()
)));
};
let has_crlf = read.contents.contains("\r\n");
let content = read.contents.replace("\r\n", "\n");
let mtime = read.stat.modified;
if args.old_string.is_empty() {
if !content.trim().is_empty() {
return Err(ToolError::Respond(
"Cannot create new file - file already exists.".to_string(),
));
}
let write_back = reexpand(&args.new_string, has_crlf);
return self
.write_result(ctx, &resolved, &args, &write_back, false, 1)
.await;
}
#[allow(clippy::case_sensitive_file_extension_comparisons)]
if args.file_path.ends_with(".ipynb") {
return Err(ToolError::Respond(
"File is a Jupyter Notebook. Use the NotebookEdit to edit this file.".to_string(),
));
}
match self.state.check_fresh(&resolved, mtime) {
None => {
return Err(ToolError::Respond(
"File has not been read yet. Read it first before writing to it.".to_string(),
));
}
Some(false) => {
return Err(ToolError::Respond(
"File has been modified since read, either by the user or by a linter. Read it \
again before attempting to write it."
.to_string(),
));
}
Some(true) => {}
}
let matches = content.matches(&args.old_string).count();
if matches == 0 {
return Err(ToolError::Respond(format!(
"String to replace not found in file.\nString: {}",
args.old_string
)));
}
if matches > 1 && !args.replace_all {
return Err(ToolError::Respond(format!(
"Found {matches} matches of the string to replace, but replace_all is false. To \
replace all occurrences, set replace_all to true. To replace only one occurrence, \
please provide more context to uniquely identify the instance.\nString: {}",
args.old_string
)));
}
let updated = if args.replace_all {
content.replace(&args.old_string, &args.new_string)
} else {
content.replacen(&args.old_string, &args.new_string, 1)
};
let write_back = reexpand(&updated, has_crlf);
self.write_result(ctx, &resolved, &args, &write_back, false, matches)
.await
}
}
fn reexpand(text: &str, has_crlf: bool) -> String {
if has_crlf {
text.replace('\n', "\r\n")
} else {
text.to_string()
}
}
impl ClaudeEdit {
async fn write_result(
&self,
ctx: &ToolCtx,
resolved: &Path,
args: &EditArgs,
contents: &str,
created: bool,
replacements: usize,
) -> Result<EditOutput, ToolError> {
let path = Path::new(&args.file_path);
if created && let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
self.host
.create_dir(&ctx.cwd, parent, true, true)
.await
.map_err(|e| ToolError::Respond(e.to_string()))?;
}
let stat = self
.host
.write_file(&ctx.cwd, path, contents)
.await
.map_err(|e| ToolError::Respond(e.to_string()))?;
self.state
.record_write(resolved.to_path_buf(), stat.modified);
let message = if args.replace_all {
format!(
"The file {} has been updated. All occurrences were successfully replaced.",
args.file_path
)
} else {
format!("The file {} has been updated successfully.", args.file_path)
};
Ok(EditOutput {
path: args.file_path.clone(),
created,
replacements,
message,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn description_is_the_pinned_edit_md() {
let desc = include_str!("descriptions/edit.md");
assert_eq!(desc.len(), 1095, "edit.md byte length changed");
assert!(desc.starts_with("Performs exact string replacements in files."));
assert!(desc.contains("You must use your `Read` tool at least once"));
}
#[test]
fn reexpand_only_on_crlf() {
assert_eq!(reexpand("a\nb", false), "a\nb");
assert_eq!(reexpand("a\nb", true), "a\r\nb");
}
}