use crate::{Tool, Workspace};
use anyhow::Result;
use async_trait::async_trait;
use serde_json::json;
use std::path::Path;
#[cfg(test)]
use std::path::PathBuf;
pub struct EditTool;
#[async_trait]
impl Tool for EditTool {
fn name(&self) -> &'static str {
"edit"
}
fn parameters_schema(&self) -> serde_json::Value {
super::tool_params_schema(
&json!({
"path": {
"type": "string",
"description": "Path to the file (required on every call). Relative paths resolve from workspace; absolute paths are validated against the workspace boundary."
},
"old_string": {
"type": "string",
"description": "If omitted or empty: creates a new file with `new_string` (refuses if file exists). If provided and non-empty: this text is replaced by `new_string`. Its line endings never have to match the file's (LF and CRLF are interchangeable, for every file type), and it is semi-insensitive to whitespace in code files; it must appear exactly once unless multiple is true."
},
"new_string": {
"type": "string",
"description": "When old_string is omitted or empty: the content to write to the new file. When old_string is provided and non-empty: the replacement text (may be empty to delete the matched text); its line endings are rewritten to the ending the file uses around it. Must differ from old_string — identical old and new strings are rejected as a no-op."
},
"multiple": {
"type": "boolean",
"description": "Only used when old_string is provided. Allow replacing multiple occurrences of old_string (default: false). When true, replaces all occurrences instead of requiring exactly one.",
"default": false
}
}),
&["path", "new_string"],
)
}
async fn execute(&self, ws: &Workspace, args: serde_json::Value) -> Result<String> {
let path = super::get_str(&args, "path")?.to_string();
let new_string = super::get_str(&args, "new_string")?;
let old_string = super::get_opt_str(&args, "old_string");
match old_string {
None | Some("") => self.execute_write(ws, &path, new_string).await,
Some(old) => {
let multiple = super::get_bool(&args, "multiple", false)?;
self.execute_edit(ws, &path, old, new_string, multiple)
.await
}
}
}
}
fn not_found_error(path: &str, detail: &str) -> anyhow::Error {
anyhow::anyhow!(
"not-found: cannot edit {path}: old_string not found in file{detail} \
— hint: re-read the file and copy old_string exactly from its current contents"
)
}
impl EditTool {
async fn execute_write(&self, ws: &Workspace, path: &str, new_string: &str) -> Result<String> {
let resolved_target = super::path::resolve_write_target(ws.as_path(), path, true).await?;
if tokio::fs::try_exists(&resolved_target)
.await
.map_err(|e| anyhow::anyhow!("Cannot verify whether {path} exists: {e}"))?
{
anyhow::bail!(
"File already exists: {path}. Use `old_string` to edit it instead of overwriting."
);
}
tokio::fs::write(&resolved_target, new_string)
.await
.map_err(|e| anyhow::anyhow!("io: cannot write {path}: failed to write file: {e} — hint: check that the parent directory exists and is writable"))?;
update_search_index_after_write(ws, &resolved_target);
Ok(format!("Written {} bytes to {path}", new_string.len()))
}
async fn execute_edit(
&self,
ws: &Workspace,
path: &str,
old_string: &str,
new_string: &str,
multiple: bool,
) -> Result<String> {
if old_string == new_string {
anyhow::bail!("old_string equals new_string — no change needed");
}
let resolved_target = super::path::resolve_write_target(ws.as_path(), path, false).await?;
let use_ws_matching = is_ws_insensitive_extension(path);
match tokio::fs::metadata(&resolved_target).await {
Ok(meta) => {
super::check_size_within(&meta, super::MAX_FILE_SIZE_BYTES, "File too large")?;
}
Err(e) => anyhow::bail!("Cannot access file {path}: {e}"),
}
let content = match tokio::fs::read_to_string(&resolved_target).await {
Ok(c) => c,
Err(e) => {
anyhow::bail!(
"io: cannot edit {path}: failed to read file: {e} — hint: verify the file exists and contains valid UTF-8 text"
);
}
};
let plan = plan_edits(
path,
&content,
old_string,
new_string,
multiple,
use_ws_matching,
)?;
let edits = &plan.edits;
let mut new_content = String::with_capacity(content.len());
let mut cursor = 0;
for edit in edits {
new_content.push_str(&content[cursor..edit.span.start]);
new_content.push_str(&edit.replacement);
cursor = edit.span.end;
}
new_content.push_str(&content[cursor..]);
if new_content == content && !plan.whitespace_fallback {
anyhow::bail!(
"old_string equals new_string once line endings are accounted for — no change needed"
);
}
tokio::fs::write(&resolved_target, &new_content)
.await
.map_err(|e| anyhow::anyhow!("io: cannot edit {path}: failed to write file: {e} — hint: check disk space and file permissions"))?;
update_search_index_after_write(ws, &resolved_target);
Ok(format!(
"Edited {path}: replaced {} occurrence{} ({} bytes)",
edits.len(),
if edits.len() == 1 { "" } else { "s" },
new_content.len()
))
}
}
struct Edit {
span: std::ops::Range<usize>,
replacement: String,
}
impl Edit {
fn new(
content: &str,
span: std::ops::Range<usize>,
new_string: &str,
prevailing: Option<LineEnding>,
) -> Self {
let replacement = match local_ending(content, span.start).or(prevailing) {
Some(ending) => {
let adapted = ending.apply(new_string);
if adapted.starts_with("\r\n") && content[..span.start].ends_with('\r') {
adapted[1..].to_string()
} else {
adapted
}
}
None => new_string.to_string(),
};
Self { span, replacement }
}
}
struct Plan {
edits: Vec<Edit>,
whitespace_fallback: bool,
}
fn plan_edits(
path: &str,
content: &str,
old_string: &str,
new_string: &str,
multiple: bool,
use_ws_matching: bool,
) -> Result<Plan> {
let exact = exact_spans(content, old_string);
if !exact.is_empty() {
return Ok(Plan {
edits: resolve(exact, content, new_string, multiple)?,
whitespace_fallback: false,
});
}
let by_ending = line_ending_spans(content, old_string);
if !by_ending.is_empty() {
return Ok(Plan {
edits: resolve(by_ending, content, new_string, multiple)?,
whitespace_fallback: false,
});
}
if !multiple && use_ws_matching {
return match find_ws_insensitive(content, old_string)? {
Some(span) => Ok(Plan {
edits: resolve(vec![span], content, new_string, multiple)?,
whitespace_fallback: true,
}),
None => Err(not_found_error(
path,
" (whitespace-insensitive matching tried)",
)),
};
}
if multiple {
if use_ws_matching && find_ws_insensitive(content, old_string).is_ok_and(|s| s.is_some()) {
return Err(not_found_error(
path,
" (whitespace differs; try without multiple=true)",
));
}
return Err(not_found_error(path, " (multiple=true mode)"));
}
Err(not_found_error(
path,
" (exact match required apart from line endings)",
))
}
fn resolve(
spans: Vec<std::ops::Range<usize>>,
content: &str,
new_string: &str,
multiple: bool,
) -> Result<Vec<Edit>> {
let spans = if multiple {
spans
} else {
match spans.as_slice() {
[span] => vec![span.clone()],
spans => anyhow::bail!(
"old_string matches {} times; must match exactly once (or pass multiple=true to replace all)",
spans.len()
),
}
};
let mut previous_end = 0;
let spans: Vec<_> = spans
.into_iter()
.map(|span| {
let widened = span.start > previous_end
&& content[..span.start].ends_with('\r')
&& content[span.start..].starts_with('\n');
previous_end = span.end;
let start = if widened { span.start - 1 } else { span.start };
start..span.end
})
.collect();
let prevailing = prevailing_ending(content);
Ok(spans
.into_iter()
.map(|span| Edit::new(content, span, new_string, prevailing))
.collect())
}
fn exact_spans(content: &str, old_string: &str) -> Vec<std::ops::Range<usize>> {
content
.match_indices(old_string)
.map(|(start, matched)| start..start + matched.len())
.collect()
}
fn line_ending_spans(content: &str, old_string: &str) -> Vec<std::ops::Range<usize>> {
if !content.contains("\r\n") && !old_string.contains("\r\n") {
return Vec::new();
}
let view = LfView::new(content);
let needle = LfView::new(old_string).text;
view.text
.match_indices(&needle)
.map(|(start, matched)| view.raw_span(start, start + matched.len()))
.collect()
}
#[derive(Clone, Copy, Debug)]
enum LineEnding {
Lf,
CrLf,
}
impl LineEnding {
fn apply(self, text: &str) -> String {
let lf_only = text.replace("\r\n", "\n");
match self {
Self::Lf => lf_only,
Self::CrLf => lf_only.replace('\n', "\r\n"),
}
}
}
fn ending_of(content: &str, newline: usize) -> LineEnding {
if newline > 0 && content.as_bytes()[newline - 1] == b'\r' {
LineEnding::CrLf
} else {
LineEnding::Lf
}
}
fn local_ending(content: &str, at: usize) -> Option<LineEnding> {
content[at..]
.find('\n')
.map(|offset| ending_of(content, at + offset))
}
fn prevailing_ending(content: &str) -> Option<LineEnding> {
let bytes = content.as_bytes();
let (mut crlf, mut lf) = (0usize, 0usize);
let mut first = None;
for (i, byte) in bytes.iter().enumerate() {
if *byte != b'\n' {
continue;
}
let ending = ending_of(content, i);
first.get_or_insert(ending);
match ending {
LineEnding::CrLf => crlf += 1,
LineEnding::Lf => lf += 1,
}
}
match crlf.cmp(&lf) {
std::cmp::Ordering::Greater => Some(LineEnding::CrLf),
std::cmp::Ordering::Less => Some(LineEnding::Lf),
std::cmp::Ordering::Equal => first,
}
}
struct LfView {
text: String,
removed_cr: Vec<usize>,
}
impl LfView {
fn new(content: &str) -> Self {
let mut text = String::with_capacity(content.len());
let mut removed_cr = Vec::new();
let mut chars = content.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\r' && chars.peek() == Some(&'\n') {
removed_cr.push(text.len());
continue;
}
text.push(ch);
}
Self { text, removed_cr }
}
fn raw_span(&self, start: usize, end: usize) -> std::ops::Range<usize> {
let removed_before = |at: usize| self.removed_cr.partition_point(|&cr| cr < at);
start + removed_before(start)..end + removed_before(end)
}
}
fn update_search_index_after_write(ws: &Workspace, file_path: &std::path::Path) {
let Some(entry) = crate::search_engine::get_engine_by_name(&ws.name) else {
return;
};
if let Ok(mut guard) = entry.picker.write()
&& let Some(ref mut picker) = *guard
&& picker.handle_create_or_modify(file_path).is_none()
{
tracing::warn!(
workspace = ws.name,
path = %file_path.display(),
"Search index capacity exhausted after file write — \
background rescan needed"
);
}
}
const WS_INSENSITIVE_EXTENSIONS: &[&str] = &[
"rs", "js", "jsx", "ts", "tsx", "c", "h", "cpp", "hpp", "cc", "cxx", "java", "kt", "kts", "go",
"swift", "dart", "cs", "zig", "scala",
];
fn is_ws_insensitive_extension(path: &str) -> bool {
let ext = Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
WS_INSENSITIVE_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str())
}
#[derive(Debug, Clone)]
struct Segment {
norm_range: std::ops::Range<usize>,
orig_range: std::ops::Range<usize>,
}
fn normalize_ws(s: &str) -> (String, Vec<Segment>) {
let mut normalized = String::new();
let mut segments = Vec::new();
let mut chars = s.char_indices().peekable();
while let Some((i, ch)) = chars.next() {
let norm_start = normalized.len();
let orig_start = i;
let mut orig_end = i;
match ch {
'"' | '\'' | '`' => {
normalized.push(ch);
while let Some((j, next_ch)) = chars.next() {
normalized.push(next_ch);
orig_end = j.saturating_add(next_ch.len_utf8());
if next_ch == '\\' {
if let Some((k, esc_ch)) = chars.next() {
normalized.push(esc_ch);
orig_end = k.saturating_add(esc_ch.len_utf8());
}
} else if next_ch == ch {
break;
}
}
}
_ if ch.is_ascii_whitespace() => {
orig_end = i.saturating_add(ch.len_utf8());
normalized.push(' ');
while let Some(&(j, next_ch)) = chars.peek() {
if next_ch.is_ascii_whitespace() {
chars.next();
orig_end = j.saturating_add(next_ch.len_utf8());
} else {
break;
}
}
}
_ => {
normalized.push(ch);
orig_end = i.saturating_add(ch.len_utf8());
while let Some(&(j, next_ch)) = chars.peek() {
if next_ch.is_ascii_whitespace()
|| next_ch == '"'
|| next_ch == '\''
|| next_ch == '`'
{
break;
}
normalized.push(next_ch);
chars.next();
orig_end = j.saturating_add(next_ch.len_utf8());
}
}
}
let norm_end = normalized.len();
if norm_end > norm_start {
segments.push(Segment {
norm_range: norm_start..norm_end,
orig_range: orig_start..orig_end,
});
}
}
(normalized, segments)
}
fn segment_at(pos: usize, segments: &[Segment]) -> Result<&Segment> {
segments
.iter()
.find(|seg| pos < seg.norm_range.end && pos >= seg.norm_range.start)
.ok_or_else(|| {
anyhow::anyhow!(
"segment_at: position {pos} not found in {len} segments",
len = segments.len()
)
})
}
fn map_norm_span(
norm_start: usize,
norm_end: usize,
segments: &[Segment],
) -> Result<std::ops::Range<usize>> {
let seg = segment_at(norm_start, segments)?;
let orig_start = if seg.orig_range.len() == seg.norm_range.len() {
seg.orig_range
.start
.saturating_add(norm_start.saturating_sub(seg.norm_range.start))
} else {
seg.orig_range.start
};
let end_seg = segment_at(norm_end.saturating_sub(1), segments)?;
let orig_end = if end_seg.orig_range.len() == end_seg.norm_range.len() {
end_seg
.orig_range
.start
.saturating_add(norm_end.saturating_sub(end_seg.norm_range.start))
} else {
end_seg.orig_range.end
};
Ok(orig_start..orig_end)
}
fn find_ws_insensitive(content: &str, old_string: &str) -> Result<Option<std::ops::Range<usize>>> {
if old_string.is_empty() || content.is_empty() {
return Ok(None);
}
let (norm_content, segments) = normalize_ws(content);
let (norm_old, _) = normalize_ws(old_string);
let Some(norm_pos) = norm_content.find(&norm_old) else {
return Ok(None);
};
let norm_end = norm_pos + norm_old.len();
let first_char_len = norm_old.chars().next().unwrap().len_utf8();
let search_start = norm_pos + first_char_len;
if norm_content[search_start..].find(&norm_old).is_some() {
anyhow::bail!(
"old_string matches multiple times after whitespace normalization; \
provide more surrounding context to disambiguate"
);
}
Ok(Some(map_norm_span(norm_pos, norm_end, &segments)?))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::workspace::test_ws;
use tempfile::TempDir;
async fn with_temp_workspace<F, Fut>(files: &[(&str, &str)], test: F)
where
F: FnOnce(PathBuf) -> Fut,
Fut: std::future::Future<Output = ()>,
{
let dir = TempDir::new().unwrap();
for (filename, content) in files {
let full_path = dir.path().join(filename);
tokio::fs::create_dir_all(
full_path
.parent()
.expect("TempDir-joined path always has a parent"),
)
.await
.unwrap();
tokio::fs::write(&full_path, content).await.unwrap();
}
let path = dir.path().to_path_buf();
test(path).await;
}
#[test]
fn ws_insensitive_extensions() {
let cases: &[(&str, bool)] = &[
("main.rs", true),
("src/lib.rs", true),
("app.js", true),
("component.jsx", true),
("app.ts", true),
("component.tsx", true),
("main.c", true),
("main.h", true),
("main.cpp", true),
("main.hpp", true),
("main.cc", true),
("main.cxx", true),
("Main.java", true),
("Main.kt", true),
("Main.kts", true),
("main.go", true),
("main.swift", true),
("main.dart", true),
("Program.cs", true),
("main.zig", true),
("Main.scala", true),
("Main.rs", true),
("Main.RS", true),
("App.JS", true),
("Main.Rs", true),
("config.toml", false),
("config.json", false),
("config.yaml", false),
("config.yml", false),
("readme.md", false),
("Dockerfile", false),
("Makefile", false),
("main.py", false),
("main.rb", false),
("main.php", false),
("style.css", false),
("script.sh", false),
("docker-compose", false),
];
for &(path, expected_match) in cases {
assert_eq!(
is_ws_insensitive_extension(path),
expected_match,
"case: {path}"
);
}
}
const MATCH_CASES: &[(&str, &str, &str)] = &[
("let x = 5;", "let x = 5;", "let x = 5;"),
("let x = 5;", "let x = 5;", "let x = 5;"),
("let\tx\t=\t5;", "let x = 5;", "let\tx\t=\t5;"),
("let\nx\n=\n5;", "let x = 5;", "let\nx\n=\n5;"),
("\nlet x = 5;", "let x = 5;", "let x = 5;"),
(" \n\t ", " ", " \n\t "),
(" \n\t ", " \n ", " \n\t "),
("let x = \"\";", "let x = \"\";", "let x = \"\";"),
(
"let msg = \"hello world\"; let y = 5;",
"let msg = \"hello world\";",
"let msg = \"hello world\";",
),
(
"function hello() {\n return 42;\n}",
"hello() {",
"hello() {",
),
(
"pub fn foo<T>(x: T) -> T where T: Debug { x }",
"pub fn foo<T>(x: T) -> T where T: Debug { x }",
"pub fn foo<T>(x: T) -> T where T: Debug { x }",
),
(
"fn main() {\n let x = 5;\n let y = 10;\n x + y\n}",
"fn main() {\n let x = 5;\n let y = 10;\n x + y\n}",
"fn main() {\n let x = 5;\n let y = 10;\n x + y\n}",
),
(
"let c: char = 'x';",
"let c: char = 'x';",
"let c: char = 'x';",
),
(
"const fn = (x) => { return x * 2; };",
"const fn = (x) => { return x * 2; };",
"const fn = (x) => { return x * 2; };",
),
(
"let\t x\t= 5;\n\tlet\ty = 10;",
"let x = 5;\nlet y = 10;",
"let\t x\t= 5;\n\tlet\ty = 10;",
),
(
" let x = helper( arg1, arg2 );",
"helper( arg1, arg2 )",
"helper( arg1, arg2 )",
),
("a\nb", "a b", "a\nb"),
("x y ", "x y ", "x y "),
("abc123", "abc123", "abc123"),
(
"let name = \"café créme\";",
"let name = \"café créme\";",
"let name = \"café créme\";",
),
(
"let x = \"a\" + \"b\";",
"let x = \"a\" + \"b\";",
"let x = \"a\" + \"b\";",
),
(
"fn foo() {\n\tlet x = 1;\n}",
"fn foo() {\n\tlet x = 1;\n}",
"fn foo() {\n\tlet x = 1;\n}",
),
(
"const x = \"hello world\";",
"const x = \"hello world\";",
"const x = \"hello world\";",
),
("fn foo() {}", "fn foo()", "fn foo()"),
("fn foo() {}", "foo() {}", "foo() {}"),
("x + y", "x + y", "x + y"),
(" a + b", " a + b", " a + b"),
("a + b ", "a + b ", "a + b "),
(
"fn main() {}\nfn other() {}",
"fn main() {}",
"fn main() {}",
),
(
"fn main() {}\nfn other() {}",
"fn other() {}",
"fn other() {}",
),
(
"let x = \"hello \\\"world\\\" foo\";",
"let x = \"hello \\\"world\\\" foo\";",
"let x = \"hello \\\"world\\\" foo\";",
),
(
"let s1 = 'simple', s2 = \"double\", s3 = `template`;",
"let s1 = 'simple', s2 = \"double\", s3 = `template`;",
"let s1 = 'simple', s2 = \"double\", s3 = `template`;",
),
("hello world", "hello world", "hello world"),
("let x = 5;\n", "let x = 5;", "let x = 5;"),
];
const NOMATCH_CASES: &[(&str, &str)] = &[
("let x = \"hello world\";", "hello world"),
("let msg = \"a b\";", "let msg = \"a b\""),
(
"let x = \"hello \\\"world\\\" foo\";",
"let x = \"hello \\\"world\\\" foo\";",
),
("let x = 5;", "let y = 5;"),
("hello", ""),
("", "hello"),
("let msg = \"hello world\";", "let msg = \"hello world\";"),
("let msg = \"hello\nworld\";", "let msg = \"hello world\";"),
("let x = 'hello world';", "let x = 'hello world';"),
("let x = `hello world`;", "let x = `hello world`;"),
];
#[test]
fn ws_insensitive_should_match() {
for (content, old, expected) in MATCH_CASES {
let m = find_ws_insensitive(content, old)
.unwrap()
.unwrap_or_else(|| panic!("Expected match: content={content:?} old={old:?}"));
assert_eq!(&content[m], *expected, "content={content:?} old={old:?}");
}
}
#[test]
fn ws_insensitive_should_not_match() {
for (content, old) in NOMATCH_CASES {
assert!(
find_ws_insensitive(content, old).unwrap().is_none(),
"Expected no match: content={content:?} old={old:?}"
);
}
}
#[test]
fn ws_match_is_ambiguous() {
let cases: &[(&str, &str, &str, bool)] = &[
("repeated_pattern", "a b a b a b", "a b", true),
(
"two_lines_normalize_same",
"let x = 1;\nlet x = 1;",
"let x = 1;",
true,
),
("overlapping_repeated_tokens", "a a a", "a a", true),
("multibyte_2byte_latin", "ñ b ñ b", "ñ b", true),
("multibyte_3byte_cjk", "字 符 字 符", "字 符", true),
("multibyte_4byte_emoji", "🚀 b 🚀 b", "🚀 b", true),
("single_match", "fn foo() {}", "fn foo() {}", false),
(
"two_functions_one_matches",
"fn foo() {}\nfn bar() {}",
"fn bar() {}",
false,
),
(
"appears_once_after_normalization",
"let x = 5;\nlet y = 10;\n x + y",
"let x = 5;",
false,
),
("empty_old_string", "anything", "", false),
("not_found", "fn foo() {}", "fn bar() {}", false),
(
"multibyte_2byte_latin_single",
"fn ñ foo() {}",
"fn ñ foo()",
false,
),
(
"multibyte_3byte_cjk_single",
"let 字 = 1;",
"let 字 = 1;",
false,
),
(
"multibyte_4byte_emoji_single",
"let 🚀 = 1;",
"let 🚀 = 1;",
false,
),
];
for &(name, content, old, expected_ambiguous) in cases {
let result = find_ws_insensitive(content, old);
if expected_ambiguous {
assert!(
result.is_err(),
"case: {name} — expected ambiguous error, content={content:?} old={old:?} got {result:?}"
);
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("multiple times after whitespace normalization"),
"case: {name} — error should mention ambiguity, got: {err}"
);
} else {
assert!(
result.is_ok(),
"case: {name} — expected no ambiguity error, content={content:?} old={old:?} got {result:?}"
);
}
}
}
#[test]
fn segment_at_rejects_malformed_segments() {
let segments = vec![
Segment {
norm_range: 0..5,
orig_range: 0..5,
},
Segment {
norm_range: 7..10,
orig_range: 10..13,
},
];
assert!(segment_at(15, &segments).is_err());
assert!(segment_at(0, &[]).is_err());
assert!(
segment_at(6, &segments).is_err(),
"position 6 is in the gap (0..5, 7..10)"
);
assert!(segment_at(3, &segments).is_ok());
assert!(segment_at(7, &segments).is_ok());
}
#[tokio::test]
async fn file_edit_multiple_replacements() {
let dir = TempDir::new().unwrap();
tokio::fs::write(dir.path().join("test.txt"), "a b a c a d")
.await
.unwrap();
let result = EditTool
.execute(
&test_ws(dir.path()),
json!({"path": "test.txt", "old_string": "b", "new_string": "x"}),
)
.await;
assert!(result.is_ok(), "edit should succeed: {result:?}");
let result = result.unwrap();
assert!(result.contains("replaced 1 occurrence"));
assert_eq!(
tokio::fs::read_to_string(dir.path().join("test.txt"))
.await
.unwrap(),
"a x a c a d"
);
let result = EditTool
.execute(
&test_ws(dir.path()),
json!({"path": "test.txt", "old_string": "a", "new_string": "y", "multiple": true}),
)
.await;
assert!(result.is_ok(), "multiple edit should succeed: {result:?}");
let result = result.unwrap();
assert!(result.contains("replaced 3 occurrences"));
assert_eq!(
tokio::fs::read_to_string(dir.path().join("test.txt"))
.await
.unwrap(),
"y x y c y d"
);
let result = EditTool
.execute(
&test_ws(dir.path()),
json!({"path": "test.txt", "old_string": "z", "new_string": "w", "multiple": true}),
)
.await;
assert!(
result.is_err(),
"edit with no matches should fail: {result:?}"
);
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("not-found: cannot edit test.txt: old_string not found in file"),
"canonical not-found shape: {err}"
);
tokio::fs::write(dir.path().join("test.txt"), "only one")
.await
.unwrap();
let result = EditTool
.execute(&Workspace::from_path(dir.path()), json!({"path": "test.txt", "old_string": "one", "new_string": "two", "multiple": true}))
.await;
assert!(
result.is_ok(),
"single match with multiple flag: {result:?}"
);
let result = result.unwrap();
assert!(result.contains("replaced 1 occurrence"));
assert_eq!(
tokio::fs::read_to_string(dir.path().join("test.txt"))
.await
.unwrap(),
"only two"
);
}
#[tokio::test]
async fn file_edit_match_operations() {
let dir = TempDir::new().unwrap();
tokio::fs::write(dir.path().join("test.txt"), "hello world")
.await
.unwrap();
let result = EditTool
.execute(
&test_ws(dir.path()),
json!({"path": "test.txt", "old_string": "hello", "new_string": "goodbye"}),
)
.await;
assert!(result.is_ok(), "edit should succeed: {result:?}");
let result = result.unwrap();
assert!(result.contains("replaced 1 occurrence"));
assert_eq!(
tokio::fs::read_to_string(dir.path().join("test.txt"))
.await
.unwrap(),
"goodbye world"
);
let result = EditTool.execute(&Workspace::from_path(dir.path()), json!({"path": "test.txt", "old_string": "nonexistent", "new_string": "replacement"})).await;
assert!(
result.is_err(),
"edit with nonexistent string should fail: {result:?}"
);
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("not-found: cannot edit test.txt: old_string not found in file"),
"canonical not-found shape: {err}"
);
tokio::fs::write(dir.path().join("test.txt"), "aaa bbb aaa")
.await
.unwrap();
let result = EditTool
.execute(
&test_ws(dir.path()),
json!({"path": "test.txt", "old_string": "aaa", "new_string": "ccc"}),
)
.await;
assert!(result.is_err(), "multiple matches should fail: {result:?}");
let err = format!("{}", result.unwrap_err());
assert!(err.contains("matches 2 times"));
assert_eq!(
tokio::fs::read_to_string(dir.path().join("test.txt"))
.await
.unwrap(),
"aaa bbb aaa"
);
}
#[tokio::test]
async fn file_edit_delete_via_empty_new_string() {
with_temp_workspace(&[("test.txt", "keep remove keep")], |dir| async move {
let result = EditTool
.execute(
&test_ws(&dir),
json!({"path": "test.txt", "old_string": " remove", "new_string": ""}),
)
.await;
assert!(
result.is_ok(),
"delete edit should succeed: {:?}",
result.as_ref().unwrap_err()
);
let content = tokio::fs::read_to_string(dir.join("test.txt"))
.await
.unwrap();
assert_eq!(content, "keep keep");
})
.await;
}
#[tokio::test]
async fn edit_write_mode_treats_omitted_and_empty_old_string_alike() {
struct Case {
name: &'static str,
old_string: Option<&'static str>,
}
for case in [
Case {
name: "omitted old_string",
old_string: None,
},
Case {
name: "empty old_string",
old_string: Some(""),
},
] {
let dir = TempDir::new().unwrap();
let mut args = json!({"path": "out.txt", "new_string": "written!"});
if let Some(old) = case.old_string {
args["old_string"] = json!(old);
}
let result = EditTool.execute(&test_ws(dir.path()), args).await;
assert!(result.is_ok(), "{} should succeed: {result:?}", case.name);
assert!(
result.unwrap().contains("8 bytes"),
"{} should report written size",
case.name
);
let content = tokio::fs::read_to_string(dir.path().join("out.txt"))
.await
.unwrap();
assert_eq!(content, "written!", "{}", case.name);
}
}
#[tokio::test]
async fn edit_write_mode_creates_parent_dirs() {
let dir = TempDir::new().unwrap();
let result = EditTool
.execute(
&test_ws(dir.path()),
json!({"path": "a/b/c/deep.txt", "new_string": "deep"}),
)
.await;
assert!(result.is_ok(), "write with parent dirs: {result:?}");
let content = tokio::fs::read_to_string(dir.path().join("a/b/c/deep.txt"))
.await
.unwrap();
assert_eq!(content, "deep");
}
#[tokio::test]
async fn file_edit_blocks_dangerous_paths() {
with_temp_workspace(&[], |dir| async move {
let result = EditTool
.execute(
&test_ws(&dir),
json!({"path": "../../etc/passwd", "old_string": "root", "new_string": "x"}),
)
.await;
assert!(result.is_err(), "traversal should be blocked: {result:?}");
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("forbidden: cannot write to ../../etc/passwd"),
"canonical forbidden shape: {err}"
);
let result = EditTool
.execute(
&test_ws(&dir),
json!({"path": "/etc/passwd", "old_string": "root", "new_string": "x"}),
)
.await;
assert!(
result.is_err(),
"absolute path should be blocked: {result:?}"
);
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("forbidden: cannot write to /etc/passwd"),
"canonical forbidden shape: {err}"
);
})
.await;
}
#[tokio::test]
async fn file_edit_normalizes_relative_path() {
with_temp_workspace(
&[("workspace/nested/target.txt", "hello world")],
|root| async move {
let workspace = root.join("workspace");
let result = EditTool
.execute(
&test_ws(&workspace), json!({"path": "nested/target.txt", "old_string": "world", "new_string": "mahbot"}),
)
.await;
assert!(result.is_ok(), "relative path edit: {result:?}");
let content = tokio::fs::read_to_string(workspace.join("nested/target.txt")).await.unwrap();
assert_eq!(content, "hello mahbot");
},
)
.await;
}
#[cfg(unix)]
#[tokio::test]
async fn file_edit_blocks_symlink_target_file() {
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
let workspace = root.path().join("workspace");
let outside = root.path().join("outside");
tokio::fs::create_dir_all(&workspace).await.unwrap();
tokio::fs::create_dir_all(&outside).await.unwrap();
tokio::fs::write(outside.join("target.txt"), "original")
.await
.unwrap();
symlink(outside.join("target.txt"), workspace.join("linked.txt")).unwrap();
let result = EditTool
.execute(
&test_ws(&workspace),
json!({
"path": "linked.txt",
"old_string": "original",
"new_string": "hacked"
}),
)
.await;
assert!(
result.is_err(),
"editing through symlink must be blocked: {result:?}"
);
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("symlink"),
"error should mention symlink, got: {err}"
);
let content = tokio::fs::read_to_string(outside.join("target.txt"))
.await
.unwrap();
assert_eq!(content, "original", "original file must not be modified");
}
#[tokio::test]
async fn file_edit_nonexistent_file() {
with_temp_workspace(&[], |dir| async move {
let result = EditTool
.execute(
&test_ws(&dir),
json!({"path": "missing.txt", "old_string": "a", "new_string": "b"}),
)
.await;
assert!(result.is_err(), "edit of nonexistent file: {result:?}");
let err = format!("{}", result.unwrap_err());
assert!(err.contains("Cannot access file"));
})
.await;
}
#[tokio::test]
async fn file_edit_absolute_path_in_workspace() {
with_temp_workspace(
&[("target.txt", "old content")],
|dir| async move {
let dir = tokio::fs::canonicalize(&dir).await.unwrap();
let abs_path = dir.join("target.txt");
let result = EditTool
.execute(
&test_ws(&dir), json!({"path": abs_path.to_string_lossy().to_string(), "old_string": "old content", "new_string": "new content"}),
)
.await;
assert!(result.is_ok(), "editing via absolute workspace path should succeed, error: {:?}", result.as_ref().unwrap_err());
let content = tokio::fs::read_to_string(dir.join("target.txt")).await.unwrap();
assert_eq!(content, "new content");
},
)
.await;
}
#[tokio::test]
async fn ws_ambiguous_rejects_multiple_matches() {
with_temp_workspace(
&[("lib.rs", "let x = 1;\nlet x = 1;\nlet y = 2;\n")],
|dir| async move {
let result = EditTool
.execute(
&test_ws(&dir),
json!({
"path": "lib.rs",
"old_string": "let x = 1;", "new_string": "let x = 42;"
}),
)
.await;
assert!(result.is_err(), "WS-ambiguous edit should fail: {result:?}");
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("multiple times after whitespace normalization"),
"Error should mention whitespace normalization ambiguity, got: {err}"
);
assert!(
err.contains("surrounding context"),
"Error should suggest adding surrounding context, got: {err}"
);
let content = tokio::fs::read_to_string(dir.join("lib.rs")).await.unwrap();
assert_eq!(content, "let x = 1;\nlet x = 1;\nlet y = 2;\n");
},
)
.await;
}
#[tokio::test]
async fn ws_unambiguous_single_match_still_works() {
with_temp_workspace(
&[("lib.rs", "let x = 1;\nlet y = 2;\n")],
|dir| async move {
let result = EditTool
.execute(
&test_ws(&dir),
json!({
"path": "lib.rs",
"old_string": "let x = 1;", "new_string": "let x = 42;"
}),
)
.await;
assert!(result.is_ok(), "Single WS match should succeed: {result:?}");
let content = tokio::fs::read_to_string(dir.join("lib.rs")).await.unwrap();
assert_eq!(content, "let x = 42;\nlet y = 2;\n");
},
)
.await;
}
#[tokio::test]
async fn ws_ambiguous_not_triggered_for_non_code_files() {
with_temp_workspace(&[("readme.txt", "a b a b")], |dir| async move {
let result = EditTool
.execute(
&test_ws(&dir),
json!({
"path": "readme.txt",
"old_string": "a b",
"new_string": "x"
}),
)
.await;
assert!(
result.is_err(),
"Exact match for .txt should fail (no matches): {result:?}"
);
let err = format!("{}", result.unwrap_err());
assert!(
err.contains(
"not-found: cannot edit readme.txt: old_string not found in file \
(exact match required apart from line endings)"
),
".txt should use exact matching only, got: {err}"
);
})
.await;
}
async fn file_bytes(dir: &Path, name: &str) -> Vec<u8> {
tokio::fs::read(dir.join(name))
.await
.expect("read file bytes")
}
async fn read_view(ws: &Workspace, path: &str) -> String {
let out = crate::tools::ReadTool::general()
.execute(ws, json!({"path": path}))
.await
.expect("read should succeed");
out.lines()
.skip(1) .map(|line| line.split_once(": ").expect("numbered line").1)
.collect::<Vec<_>>()
.join("\n")
}
#[tokio::test]
async fn read_then_edit_round_trip_for_every_file_type_and_ending() {
for name in [
"notes.md",
"notes.txt",
"script.py",
"config.json",
"lib.rs",
] {
for ending in ["\n", "\r\n"] {
let dir = TempDir::new().unwrap();
let initial = format!("alpha{ending}beta{ending}gamma{ending}");
tokio::fs::write(dir.path().join(name), &initial)
.await
.unwrap();
let ws = test_ws(dir.path());
assert_eq!(
read_view(&ws, name).await,
"alpha\nbeta\ngamma",
"{name} ({ending:?}) is read with LF"
);
let result = EditTool
.execute(
&ws,
json!({
"path": name,
"old_string": "beta\ngamma", "new_string": "beta\nGAMMA\nadded",
}),
)
.await;
assert!(
result.is_ok(),
"{name} ({ending:?}): edit built from a read must land: {result:?}"
);
assert_eq!(
file_bytes(dir.path(), name).await,
format!("alpha{ending}beta{ending}GAMMA{ending}added{ending}").as_bytes(),
"{name} ({ending:?}): the change landed, the untouched line kept \
its bytes and the file kept its endings"
);
}
}
}
#[tokio::test]
async fn crlf_multiple_mode_replaces_every_match() {
let dir = TempDir::new().unwrap();
tokio::fs::write(
dir.path().join("data.txt"),
b"item = 1\r\nkeep = 0\r\nitem = 1\r\nkeep = 0\r\n",
)
.await
.unwrap();
let ws = test_ws(dir.path());
assert_eq!(
read_view(&ws, "data.txt").await,
"item = 1\nkeep = 0\nitem = 1\nkeep = 0"
);
let result = EditTool
.execute(
&ws,
json!({
"path": "data.txt",
"old_string": "item = 1\nkeep = 0", "new_string": "item = 2\nkeep = 1",
"multiple": true,
}),
)
.await;
assert!(
result.is_ok(),
"multiple mode must work on a CRLF file: {result:?}"
);
assert!(result.unwrap().contains("replaced 2 occurrences"));
assert_eq!(
file_bytes(dir.path(), "data.txt").await,
b"item = 2\r\nkeep = 1\r\nitem = 2\r\nkeep = 1\r\n".as_slice()
);
}
#[tokio::test]
async fn old_string_spelled_with_carriage_returns_still_matches() {
let dir = TempDir::new().unwrap();
tokio::fs::write(dir.path().join("zoom.txt"), b"one\r\ntwo\r\nthree\r\n")
.await
.unwrap();
let result = EditTool
.execute(
&test_ws(dir.path()),
json!({
"path": "zoom.txt",
"old_string": "one\r\ntwo", "new_string": "one\ntwo\nhalf", }),
)
.await;
assert!(
result.is_ok(),
"CR-spelled old_string must land: {result:?}"
);
assert_eq!(
file_bytes(dir.path(), "zoom.txt").await,
b"one\r\ntwo\r\nhalf\r\nthree\r\n".as_slice(),
"the written line took the file's CRLF, not the LF it was spelled with"
);
}
#[tokio::test]
async fn unix_endings_are_preserved_and_no_foreign_ending_is_introduced() {
let dir = TempDir::new().unwrap();
tokio::fs::write(dir.path().join("unix.txt"), b"a\nb\nc\n")
.await
.unwrap();
let ws = test_ws(dir.path());
let result = EditTool
.execute(
&ws,
json!({"path": "unix.txt", "old_string": "b\nc", "new_string": "b\nC"}),
)
.await;
assert!(result.is_ok(), "multi-line edit: {result:?}");
assert_eq!(
file_bytes(dir.path(), "unix.txt").await,
b"a\nb\nC\n".as_slice()
);
let result = EditTool
.execute(
&ws,
json!({"path": "unix.txt", "old_string": "a\nb", "new_string": "a\r\nB"}),
)
.await;
assert!(result.is_ok(), "CRLF-spelled replacement: {result:?}");
assert_eq!(
file_bytes(dir.path(), "unix.txt").await,
b"a\nB\nC\n".as_slice(),
"an LF file stays LF"
);
}
#[tokio::test]
async fn mixed_endings_survive_outside_the_edit() {
let dir = TempDir::new().unwrap();
tokio::fs::write(dir.path().join("mixed.txt"), b"head\nmid\r\ntail\n")
.await
.unwrap();
let result = EditTool
.execute(
&test_ws(dir.path()),
json!({"path": "mixed.txt", "old_string": "mid", "new_string": "mid\nmiddle"}),
)
.await;
assert!(result.is_ok(), "edit in a mixed file: {result:?}");
assert_eq!(
file_bytes(dir.path(), "mixed.txt").await,
b"head\nmid\r\nmiddle\r\ntail\n".as_slice(),
"the untouched LF line and CRLF pair kept their bytes; the written line \
took the CRLF of the line it was written into"
);
}
#[tokio::test]
async fn lone_carriage_return_is_content() {
let dir = TempDir::new().unwrap();
tokio::fs::write(dir.path().join("classic.txt"), b"a\rb\r\nc\r\n")
.await
.unwrap();
let ws = test_ws(dir.path());
let result = EditTool
.execute(
&ws,
json!({"path": "classic.txt", "old_string": "a\rb", "new_string": "a\rB"}),
)
.await;
assert!(result.is_ok(), "lone CR in old_string: {result:?}");
assert_eq!(
file_bytes(dir.path(), "classic.txt").await,
b"a\rB\r\nc\r\n".as_slice()
);
let result = EditTool
.execute(
&ws,
json!({"path": "classic.txt", "old_string": "c", "new_string": "c\rd\ne"}),
)
.await;
assert!(result.is_ok(), "lone CR in new_string: {result:?}");
assert_eq!(
file_bytes(dir.path(), "classic.txt").await,
b"a\rB\r\nc\rd\r\ne\r\n".as_slice()
);
let result = EditTool
.execute(
&ws,
json!({"path": "classic.txt", "old_string": "a\nb", "new_string": "x"}),
)
.await;
assert!(
result.is_err(),
"a lone CR is content, so it cannot be matched as an ending: {result:?}"
);
}
#[tokio::test]
async fn edit_that_only_respells_endings_is_a_no_op() {
for (name, old, new) in [
("lf_spelling.txt", "a\r\nb", "a\nb"),
("crlf_spelling.txt", "a\nb", "a\r\nb"),
] {
let dir = TempDir::new().unwrap();
let before = b"a\r\nb\r\n".as_slice();
tokio::fs::write(dir.path().join(name), before)
.await
.unwrap();
let result = EditTool
.execute(
&test_ws(dir.path()),
json!({"path": name, "old_string": old, "new_string": new}),
)
.await;
let err = format!(
"{}",
result.expect_err("respelling the file's endings is a no-op")
);
assert!(err.contains("no change needed"), "{name}: {err}");
assert_eq!(file_bytes(dir.path(), name).await, before, "{name}");
}
}
#[tokio::test]
async fn line_ending_match_reports_ambiguity() {
for name in ["dup.txt", "dup.rs"] {
let dir = TempDir::new().unwrap();
tokio::fs::write(dir.path().join(name), b"x = 1\r\nkeep\r\nx = 1\r\nkeep\r\n")
.await
.unwrap();
let result = EditTool
.execute(
&test_ws(dir.path()),
json!({
"path": name,
"old_string": "x = 1\nkeep", "new_string": "x = 2\nkeep",
}),
)
.await;
let err = format!("{}", result.expect_err("two matches are ambiguous"));
assert!(err.contains("matches 2 times"), "{name}: {err}");
assert!(err.contains("must match exactly once"), "{name}: {err}");
assert_eq!(
file_bytes(dir.path(), name).await,
b"x = 1\r\nkeep\r\nx = 1\r\nkeep\r\n".as_slice()
);
}
}
#[tokio::test]
async fn cr_spelled_old_string_matches_content_without_carriage_returns() {
let dir = TempDir::new().unwrap();
tokio::fs::write(dir.path().join("unix.txt"), b"a\nb\n")
.await
.unwrap();
let result = EditTool
.execute(
&test_ws(dir.path()),
json!({"path": "unix.txt", "old_string": "a\r\nb", "new_string": "a\nB"}),
)
.await;
assert!(result.is_ok(), "CR-spelled old on an LF file: {result:?}");
assert_eq!(
file_bytes(dir.path(), "unix.txt").await,
b"a\nB\n".as_slice()
);
}
#[tokio::test]
async fn written_text_takes_the_files_ending() {
let dir = TempDir::new().unwrap();
tokio::fs::write(dir.path().join("mid.txt"), b"one two\r\nthree\r\n")
.await
.unwrap();
let ws = test_ws(dir.path());
let result = EditTool
.execute(
&ws,
json!({"path": "mid.txt", "old_string": "ne", "new_string": "NE\nline"}),
)
.await;
assert!(result.is_ok(), "mid-word edit: {result:?}");
assert_eq!(
file_bytes(dir.path(), "mid.txt").await,
b"oNE\r\nline two\r\nthree\r\n".as_slice()
);
let dir = TempDir::new().unwrap();
tokio::fs::write(dir.path().join("tail.txt"), b"a\r\nb")
.await
.unwrap();
let result = EditTool
.execute(
&test_ws(dir.path()),
json!({"path": "tail.txt", "old_string": "b", "new_string": "b\nx"}),
)
.await;
assert!(result.is_ok(), "unterminated last line: {result:?}");
assert_eq!(
file_bytes(dir.path(), "tail.txt").await,
b"a\r\nb\r\nx".as_slice()
);
}
#[tokio::test]
async fn a_file_with_no_ending_gets_the_text_as_written() {
let dir = TempDir::new().unwrap();
tokio::fs::write(dir.path().join("oneline"), b"abc")
.await
.unwrap();
let result = EditTool
.execute(
&test_ws(dir.path()),
json!({"path": "oneline", "old_string": "b", "new_string": "x\ny\r\nz"}),
)
.await;
assert!(result.is_ok(), "single-line file: {result:?}");
assert_eq!(
file_bytes(dir.path(), "oneline").await,
b"ax\ny\r\nzc".as_slice()
);
}
#[tokio::test]
async fn pattern_starting_on_the_lf_of_a_crlf_pair_stays_on_crlf() {
let dir = TempDir::new().unwrap();
tokio::fs::write(dir.path().join("pair.txt"), b"alpha\r\nbeta\r\ngamma\r\n")
.await
.unwrap();
let result = EditTool
.execute(
&test_ws(dir.path()),
json!({
"path": "pair.txt",
"old_string": "\nbeta",
"new_string": "\nBETA\ninserted",
}),
)
.await;
assert!(result.is_ok(), "pattern starting on a bare LF: {result:?}");
assert_eq!(
file_bytes(dir.path(), "pair.txt").await,
b"alpha\r\nBETA\r\ninserted\r\ngamma\r\n".as_slice()
);
}
#[tokio::test]
async fn deleting_a_line_break_removes_the_whole_ending() {
for (name, ending) in [("unix.txt", "\n"), ("windows.txt", "\r\n")] {
let dir = TempDir::new().unwrap();
tokio::fs::write(dir.path().join(name), format!("a{ending}b"))
.await
.unwrap();
let result = EditTool
.execute(
&test_ws(dir.path()),
json!({"path": name, "old_string": "\n", "new_string": " "}),
)
.await;
assert!(result.is_ok(), "{name}: deleting a line break: {result:?}");
assert_eq!(
file_bytes(dir.path(), name).await,
b"a b".as_slice(),
"{name}: the ending went with the line break it spelled"
);
}
}
#[tokio::test]
async fn whitespace_driven_no_op_still_succeeds() {
let dir = TempDir::new().unwrap();
tokio::fs::write(dir.path().join("lib.rs"), b"a b\n")
.await
.unwrap();
let result = EditTool
.execute(
&test_ws(dir.path()),
json!({"path": "lib.rs", "old_string": "a b", "new_string": "a b"}),
)
.await;
assert!(
result.is_ok(),
"the whitespace fallback may replace a span with its own text: {result:?}"
);
assert_eq!(file_bytes(dir.path(), "lib.rs").await, b"a b\n".as_slice());
}
#[tokio::test]
async fn line_ending_tolerance_is_not_whitespace_tolerance() {
let dir = TempDir::new().unwrap();
tokio::fs::write(dir.path().join("readme.txt"), b"a b\r\nc\r\n")
.await
.unwrap();
let result = EditTool
.execute(
&test_ws(dir.path()),
json!({"path": "readme.txt", "old_string": "a b", "new_string": "a+b"}),
)
.await;
assert!(
result.is_err(),
"a .txt file still gets no whitespace tolerance: {result:?}"
);
}
#[tokio::test]
async fn whitespace_fallback_in_a_crlf_code_file_keeps_the_ending() {
let dir = TempDir::new().unwrap();
tokio::fs::write(
dir.path().join("lib.rs"),
b"let x = 1;\r\nlet y = 2;\r\n",
)
.await
.unwrap();
let result = EditTool
.execute(
&test_ws(dir.path()),
json!({
"path": "lib.rs",
"old_string": "let x = 1;\nlet y = 2;", "new_string": "let x = 42;\nlet y = 2;",
}),
)
.await;
assert!(result.is_ok(), "whitespace fallback: {result:?}");
assert_eq!(
file_bytes(dir.path(), "lib.rs").await,
b"let x = 42;\r\nlet y = 2;\r\n".as_slice()
);
}
}