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::confusables;
const TRUNCATION_MARKER: &str = "…";
const MAX_LISTED_LINES: usize = 8;
pub(crate) struct GrokSearchReplace {
host: Arc<Host>,
}
impl GrokSearchReplace {
pub(crate) fn new(host: Arc<Host>) -> Self {
Self { host }
}
}
#[derive(Debug, Deserialize, JsonSchema)]
pub(crate) struct SearchReplaceArgs {
#[schemars(
description = "The path to the file to modify. You can use either a relative path in the workspace or an absolute path."
)]
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,
#[serde(default)]
#[schemars(description = "Replace all occurrences of old_string (default false)")]
replace_all: bool,
}
#[derive(Debug, Serialize)]
pub(crate) struct SearchReplaceOutput {
path: String,
created: bool,
replacements: usize,
#[serde(skip)]
message: String,
}
impl ToolOutput for SearchReplaceOutput {
fn to_prompt_text(&self) -> String {
self.message.clone()
}
}
#[async_trait]
impl Tool for GrokSearchReplace {
type Args = SearchReplaceArgs;
type Output = SearchReplaceOutput;
fn kind(&self) -> ToolKind {
ToolKind::Edit
}
#[allow(clippy::unnecessary_literal_bound)] fn description(&self) -> &str {
"Replace an exact string in a file.\n\n- Read the file with `read_file` before editing it.\n- `read_file` prefixes each line with \"LINE_NUMBER→\". That prefix is not part of the file: match only what comes after the →, with its exact indentation.\n- `old_string` must match exactly one place in the file. If it appears more than once, add surrounding lines to make it unique, or set `replace_all` to change every occurrence (handy for renaming an identifier)."
}
#[allow(clippy::too_many_lines)] async fn run(&self, ctx: &ToolCtx, args: SearchReplaceArgs) -> Result<Self::Output, ToolError> {
if let Some(too_long) = Path::new(&args.file_path)
.components()
.filter_map(|c| c.as_os_str().to_str())
.find(|c| c.len() > 255)
{
return Err(ToolError::Respond(format!(
"Error: file name exceeds the 255-character limit ({} characters). Please use a shorter file name.",
too_long.len()
)));
}
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()))?;
if resolved.is_dir() {
return Err(ToolError::Respond("File path is a directory".to_string()));
}
if self
.host
.is_path_ignored(&ctx.cwd, path)
.await
.unwrap_or(false)
{
return Err(ToolError::Respond(format!(
"Error: {} is ignored by .gitignore and cannot be edited.",
args.file_path
)));
}
if args.old_string == args.new_string {
return Err(ToolError::Respond(
"Old string and new string are the same".into(),
));
}
let display = resolved.display().to_string();
if args.old_string.is_empty() {
self.host
.write_file(&ctx.cwd, path, &args.new_string)
.await
.map_err(|e| ToolError::Respond(e.to_string()))?;
return Ok(SearchReplaceOutput {
path: display,
created: true,
replacements: 0,
message: format!("The file {} has been created successfully.", args.file_path),
});
}
let read = match self.host.read_file(&ctx.cwd, path).await {
Ok(read) => read,
Err(FsError::Io { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => {
return Err(ToolError::Respond(format!(
"Error: {} does not exist.",
args.file_path
)));
}
Err(e) => return Err(ToolError::Respond(e.to_string())),
};
let content = read.contents;
let is_crlf = content.contains("\r\n");
let match_text = if is_crlf {
content.replace("\r\n", "\n")
} else {
content.clone()
};
let count = match_text.matches(&args.old_string).count();
if count == 0 {
let nearest = build_nearest_match_hint(&match_text, &args.old_string);
let confusable = build_confusable_hint(&match_text, &args.old_string);
return Err(ToolError::Respond(format!(
"The string to replace was not found in the file, use the read_file tool to see the correct string. The user may have changed the file since you last read it.{nearest}{confusable}"
)));
}
if count > 1 && !args.replace_all {
return Err(ToolError::Respond(
"The string to replace was found multiple times in the file. Use replace_all to replace all occurrences, or include more context to only edit one occurrence.".into(),
));
}
let new_text = if args.replace_all {
match_text.replace(&args.old_string, &args.new_string)
} else {
match_text.replacen(&args.old_string, &args.new_string, 1)
};
let write_back = if is_crlf {
new_text.replace("\r\n", "\n").replace('\n', "\r\n")
} else {
new_text
};
self.host
.write_file(&ctx.cwd, path, &write_back)
.await
.map_err(|e| ToolError::Respond(e.to_string()))?;
let message = if args.replace_all && count > 1 {
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(SearchReplaceOutput {
path: display,
created: false,
replacements: count,
message,
})
}
}
fn build_nearest_match_hint(file: &str, old_string: &str) -> String {
let keyword = old_string
.lines()
.next()
.unwrap_or("")
.split_whitespace()
.max_by_key(|w| w.len())
.unwrap_or("");
if keyword.is_empty() {
return String::new();
}
file.lines()
.enumerate()
.find(|(_, l)| l.contains(keyword))
.map(|(i, l)| {
let full = format!("\n\nNearest match: line {}: {}", i + 1, l.trim_end());
truncate_str_with_marker(&full, 200)
})
.unwrap_or_default()
}
fn truncate_str_with_marker(s: &str, max_bytes: usize) -> String {
if s.len() <= max_bytes {
return s.to_string();
}
let mut end = max_bytes - TRUNCATION_MARKER.len();
while !s.is_char_boundary(end) {
end -= 1;
}
format!("{}{TRUNCATION_MARKER}", &s[..end])
}
fn detected_edit_tools() -> &'static [String] {
use std::sync::OnceLock;
static DETECTED: OnceLock<Vec<String>> = OnceLock::new();
DETECTED.get_or_init(|| {
let present = |name: &str| {
std::env::var_os("PATH").is_some_and(|paths| {
std::env::split_paths(&paths).any(|dir| dir.join(name).is_file())
})
};
let mut tools = Vec::new();
if present("python3") || present("python") {
let py = if present("python3") {
"python3"
} else {
"python"
};
tools.push(format!("`{py}`"));
}
if present("sed") {
tools.push("`sed`".to_string());
}
tools
})
}
fn examples_clause(tools: &[String]) -> String {
match tools {
[] => String::new(),
[a] => format!(" (e.g. {a})"),
[a, b] => format!(" (e.g. {a} or {b})"),
[rest @ .., last] => format!(" (e.g. {}, or {last})", rest.join(", ")),
}
}
fn build_confusable_hint(file: &str, old_string: &str) -> String {
if !confusables::has_confusables(file) {
return String::new();
}
let (norm_file, offset_map) = confusables::build_offset_map(file);
let norm_old = confusables::normalize_confusables(old_string);
let Some(norm_start) = norm_file.find(&norm_old) else {
return String::new();
};
let orig_start = offset_map[norm_start];
let orig_end = offset_map[norm_start + norm_old.len()];
let match_start_line = file[..orig_start].matches('\n').count() + 1;
let match_end_line = file[..orig_end].matches('\n').count() + 1;
let hits = confusables::detect_confusables(file);
let mut affected_lines: Vec<usize> = hits
.iter()
.filter(|h| h.line_number >= match_start_line && h.line_number <= match_end_line)
.map(|h| h.line_number)
.collect();
affected_lines.dedup();
if affected_lines.is_empty() {
return String::new();
}
let line_summary = if affected_lines.len() <= MAX_LISTED_LINES {
affected_lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
} else {
let shown: Vec<String> = affected_lines[..MAX_LISTED_LINES]
.iter()
.map(ToString::to_string)
.collect();
format!(
"{} (and {} more)",
shown.join(", "),
affected_lines.len() - MAX_LISTED_LINES
)
};
let edit_tools = detected_edit_tools();
let terminal_fallback = if edit_tools.is_empty() {
String::new()
} else {
format!(
", or use run_terminal_cmd with a short script{} to edit the file directly",
examples_clause(edit_tools)
)
};
format!(
"\n\nThe nearest matching region contains Unicode typography characters \
(smart quotes, em-dashes, etc.) on lines {line_summary} that look identical to \
ASCII in read_file output but differ at the byte level. Re-read the file and \
use a shorter old_string anchored on nearby ASCII-only context{terminal_fallback}."
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nearest_match_hint_finds_longest_token_line() {
let file = "alpha\nthe quick brownfox jumps\nomega\n";
let hint = build_nearest_match_hint(file, "quick brownfox leaps");
assert_eq!(hint, "\n\nNearest match: line 2: the quick brownfox jumps");
assert_eq!(build_nearest_match_hint(file, ""), "");
assert_eq!(build_nearest_match_hint(file, "zzz"), "");
}
#[test]
fn nearest_match_hint_caps_at_200_bytes_with_marker() {
let long_line = format!("keyword {}", "x".repeat(400));
let file = format!("{long_line}\n");
let hint = build_nearest_match_hint(&file, "keyword");
assert!(hint.len() <= 200);
assert!(hint.ends_with(TRUNCATION_MARKER));
}
#[test]
fn confusable_hint_fires_on_smart_quotes() {
let file = "let s = \u{201c}hello\u{201d};\n";
let hint = build_confusable_hint(file, "let s = \"hello\";");
assert!(
hint.contains("Unicode typography characters"),
"hint: {hint:?}"
);
assert!(hint.contains("on lines 1"), "hint: {hint:?}");
}
#[test]
fn confusable_hint_silent_without_confusables() {
assert_eq!(build_confusable_hint("plain ascii\n", "missing"), "");
}
#[test]
fn examples_clause_formats_match_grok() {
assert_eq!(examples_clause(&[]), "");
assert_eq!(examples_clause(&["`sed`".into()]), " (e.g. `sed`)");
assert_eq!(
examples_clause(&["`python3`".into(), "`sed`".into()]),
" (e.g. `python3` or `sed`)"
);
}
}