use crate::{Tool, ToolOutputPhase, 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;
#[allow(clippy::too_many_lines)]
#[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. 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 exact text is replaced by `new_string` (semi-insensitive to whitespace in code files, 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). 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) -> anyhow::Result<String> {
let path = super::require_path_arg(&args)?;
let old_string = super::get_opt_str(&args, "old_string");
let new_string = super::get_str(&args, "new_string")?;
match old_string {
None | Some("") => {
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!("Failed to write file: {e}"))?;
update_search_index_after_write(ws, &resolved_target);
Ok(format!("Written {} bytes to {path}", new_string.len()))
}
Some(old_string) => {
let multiple = super::get_bool(&args, "multiple", false);
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_file_size(&meta)?;
}
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!("Failed to read file: {e}");
}
};
let new_content;
let replaced_count;
let exact_count = content.matches(old_string).count();
if multiple {
if exact_count == 0 {
if use_ws_matching && find_ws_insensitive(&content, old_string).is_some() {
anyhow::bail!(
"old_string not found exactly (whitespace differs); try without multiple=true"
);
}
anyhow::bail!("old_string not found in file (multiple=true mode)");
}
new_content = content.replace(old_string, new_string);
replaced_count = exact_count;
} else {
match exact_count {
1 => {
new_content = content.replacen(old_string, new_string, 1);
replaced_count = 1;
}
0 if use_ws_matching => {
if ws_match_is_ambiguous(&content, old_string) {
anyhow::bail!(
"old_string matches multiple times after whitespace normalization; provide more surrounding context to disambiguate"
);
}
match find_ws_insensitive(&content, old_string) {
Some(ws_match) => {
new_content = format!(
"{}{}{}",
&content[..ws_match.start],
new_string,
&content[ws_match.end..]
);
replaced_count = 1;
}
None => {
anyhow::bail!(
"old_string not found in file (whitespace-insensitive matching tried)"
);
}
}
}
0 => {
anyhow::bail!("old_string not found in file (exact match required)");
}
_ => {
anyhow::bail!(
"old_string matches {exact_count} times; must match exactly once (or pass multiple=true to replace all)"
);
}
}
}
tokio::fs::write(&resolved_target, &new_content)
.await
.map_err(|e| anyhow::anyhow!("Failed to write file: {e}"))?;
update_search_index_after_write(ws, &resolved_target);
Ok(format!(
"Edited {path}: replaced {replaced_count} occurrence{} ({} bytes)",
if replaced_count == 1 { "" } else { "s" },
new_content.len()
))
}
}
}
fn debug_output(
&self,
phase: ToolOutputPhase,
args: &serde_json::Value,
outcome: Option<&crate::tools::ToolExecutionOutcome>,
) -> Option<String> {
match phase {
ToolOutputPhase::Before => None,
ToolOutputPhase::After => {
let outcome = outcome?;
let old_string = super::get_opt_str(args, "old_string").filter(|s| !s.is_empty());
let new_string = super::get_opt_str(args, "new_string").unwrap_or("?");
if let Some(old) = old_string {
let combined = format!("{old}\n-----------\n{new_string}");
Some(format_file_tool_result("Edit", &combined, args, outcome))
} else {
Some(format_file_tool_result("Write", new_string, args, outcome))
}
}
}
}
}
#[must_use]
fn format_file_tool_result(
action: &str,
content: &str,
args: &serde_json::Value,
outcome: &super::ToolExecutionOutcome,
) -> String {
let path = super::find_path_arg(args).unwrap_or("?");
if !outcome.success {
return format!("❌ {action} attempted on {path}");
}
let block = crate::util::truncate_sandwich(content, 2000, "debug");
format!("✏️ {path}\n{block}")
}
fn update_search_index_after_write(ws: &Workspace, file_path: &std::path::Path) {
let Some(entry) = crate::search_engine::get_engine_if_exists(ws) else {
return;
};
match entry.picker.write() {
Ok(mut guard) => {
if 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"
);
}
}
Err(e) => {
tracing::warn!(
workspace = ws.name,
path = %file_path.display(),
error = %e,
"Failed to acquire search index write lock after file write"
);
}
}
}
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)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct WsMatch {
start: usize,
end: usize,
}
fn segment_at(pos: usize, segments: &[Segment]) -> Result<&Segment> {
segments
.iter()
.find(|seg| pos < seg.norm_range.end)
.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<(usize, 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) -> Option<WsMatch> {
if old_string.is_empty() || content.is_empty() {
return None;
}
let (norm_content, segments) = normalize_ws(content);
let (norm_old, _) = normalize_ws(old_string);
let norm_pos = norm_content.find(&norm_old)?;
let norm_end = norm_pos.saturating_add(norm_old.len());
let (start, end) = map_norm_span(norm_pos, norm_end, &segments).ok()?;
Some(WsMatch { start, end })
}
#[must_use]
fn ws_match_is_ambiguous(content: &str, old_string: &str) -> bool {
let (norm_content, _) = normalize_ws(content);
let (norm_old, _) = normalize_ws(old_string);
if norm_old.is_empty() {
return false;
}
let Some(first_pos) = norm_content.find(&norm_old) else {
return false;
};
let first_char_len = norm_old.chars().next().map_or(1, char::len_utf8);
let search_start = first_pos + first_char_len;
norm_content[search_start..].find(&norm_old).is_some()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::workspace::test_ws;
async fn with_temp_workspace<F, Fut>(test_name: &str, files: &[(&str, &str)], test: F)
where
F: FnOnce(PathBuf) -> Fut,
Fut: std::future::Future<Output = ()>,
{
let dir = std::env::temp_dir().join(test_name);
let _ = tokio::fs::remove_dir_all(&dir).await;
if files.is_empty() {
tokio::fs::create_dir_all(&dir).await.unwrap();
}
for (filename, content) in files {
let path = dir.join(filename);
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await.unwrap();
}
tokio::fs::write(&path, content).await.unwrap();
}
test(dir.clone()).await;
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[test]
fn ws_insensitive_included_extensions() {
for path in [
"main.rs",
"src/lib.rs",
"app.js",
"component.jsx",
"app.ts",
"component.tsx",
"main.c",
"main.h",
"main.cpp",
"main.hpp",
"main.cc",
"main.cxx",
"Main.java",
"Main.kt",
"Main.kts",
"main.go",
"main.swift",
"main.dart",
"Program.cs",
"main.zig",
"Main.scala",
"Main.rs",
"Main.RS",
"App.JS",
"Main.Rs",
] {
assert!(
is_ws_insensitive_extension(path),
"Expected match for {path}"
);
}
}
#[test]
fn ws_insensitive_excluded_extensions() {
for path in [
"config.toml",
"config.json",
"config.yaml",
"config.yml",
"readme.md",
"Dockerfile",
"Makefile",
"main.py",
"main.rb",
"main.php",
"style.css",
"script.sh",
"docker-compose",
] {
assert!(
!is_ws_insensitive_extension(path),
"Expected no match for {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() {}",
),
("a b a b a b", "a b", "a b"),
(
"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_or_else(|| panic!("Expected match: content={content:?} old={old:?}"));
assert_eq!(
&content[m.start..m.end],
*expected,
"content={content:?} old={old:?}"
);
}
}
#[test]
fn ws_insensitive_should_not_match() {
for (content, old) in NOMATCH_CASES {
assert!(
find_ws_insensitive(content, old).is_none(),
"Expected no match: content={content:?} old={old:?}"
);
}
}
#[test]
fn ws_match_is_ambiguous_true_cases() {
let ambiguous_cases: &[(&str, &str)] = &[
("a b a b a b", "a b"),
("let x = 1;\nlet x = 1;", "let x = 1;"),
("a a a", "a a"),
("ñ b ñ b", "ñ b"),
("字 符 字 符", "字 符"),
("🚀 b 🚀 b", "🚀 b"),
];
for (content, old) in ambiguous_cases {
assert!(
ws_match_is_ambiguous(content, old),
"Expected ambiguous: content={content:?} old={old:?}"
);
}
}
#[test]
fn ws_match_is_ambiguous_false_cases() {
let unambiguous_cases: &[(&str, &str)] = &[
("fn foo() {}", "fn foo() {}"),
("fn foo() {}\nfn bar() {}", "fn bar() {}"),
("let x = 5;\nlet y = 10;\n x + y", "let x = 5;"),
("anything", ""),
("fn foo() {}", "fn bar() {}"),
("fn ñ foo() {}", "fn ñ foo()"),
("let 字 = 1;", "let 字 = 1;"),
("let 🚀 = 1;", "let 🚀 = 1;"),
];
for (content, old) in unambiguous_cases {
assert!(
!ws_match_is_ambiguous(content, old),
"Expected unambiguous: content={content:?} old={old:?}"
);
}
}
#[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(3, &segments).is_ok());
assert!(segment_at(7, &segments).is_ok());
}
#[tokio::test]
async fn file_edit_multiple_replacements() {
let dir = std::env::temp_dir().join("mahbot_test_file_edit_multiple");
let _ = tokio::fs::remove_dir_all(&dir).await;
tokio::fs::create_dir_all(&dir).await.unwrap();
tokio::fs::write(dir.join("test.txt"), "a b a c a d")
.await
.unwrap();
let result = EditTool
.execute(
&test_ws(&dir),
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.join("test.txt"))
.await
.unwrap(),
"a x a c a d"
);
let result = EditTool
.execute(
&test_ws(&dir),
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.join("test.txt"))
.await
.unwrap(),
"y x y c y d"
);
let result = EditTool
.execute(
&test_ws(&dir),
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"));
tokio::fs::write(dir.join("test.txt"), "only one")
.await
.unwrap();
let result = EditTool
.execute(&Workspace::from_path(&dir), 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.join("test.txt"))
.await
.unwrap(),
"only two"
);
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn file_edit_match_operations() {
let dir = std::env::temp_dir().join("mahbot_test_file_edit_match");
let _ = tokio::fs::remove_dir_all(&dir).await;
tokio::fs::create_dir_all(&dir).await.unwrap();
tokio::fs::write(dir.join("test.txt"), "hello world")
.await
.unwrap();
let result = EditTool
.execute(
&test_ws(&dir),
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.join("test.txt"))
.await
.unwrap(),
"goodbye world"
);
let result = EditTool.execute(&Workspace::from_path(&dir), 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"));
tokio::fs::write(dir.join("test.txt"), "aaa bbb aaa")
.await
.unwrap();
let result = EditTool
.execute(
&test_ws(&dir),
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.join("test.txt"))
.await
.unwrap(),
"aaa bbb aaa"
);
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn file_edit_delete_via_empty_new_string() {
with_temp_workspace(
"mahbot_test_file_edit_delete",
&[("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_creates_file() {
let dir = std::env::temp_dir().join("mahbot_test_edit_write_mode");
let _ = tokio::fs::remove_dir_all(&dir).await;
tokio::fs::create_dir_all(&dir).await.unwrap();
let result = EditTool
.execute(
&Workspace::from_path(&dir),
json!({"path": "out.txt", "new_string": "written!"}),
)
.await;
assert!(result.is_ok(), "write mode should succeed: {result:?}");
let result = result.unwrap();
assert!(result.contains("8 bytes"));
let content = tokio::fs::read_to_string(dir.join("out.txt"))
.await
.unwrap();
assert_eq!(content, "written!");
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn edit_write_mode_with_empty_old_string() {
let dir = std::env::temp_dir().join("mahbot_test_edit_write_mode_empty");
let _ = tokio::fs::remove_dir_all(&dir).await;
tokio::fs::create_dir_all(&dir).await.unwrap();
let result = EditTool
.execute(
&test_ws(&dir),
json!({"path": "out.txt", "old_string": "", "new_string": "content"}),
)
.await;
assert!(
result.is_ok(),
"write mode with empty old_string: {result:?}"
);
let content = tokio::fs::read_to_string(dir.join("out.txt"))
.await
.unwrap();
assert_eq!(content, "content");
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn edit_write_mode_creates_parent_dirs() {
let dir = std::env::temp_dir().join("mahbot_test_edit_write_mode_nested");
let _ = tokio::fs::remove_dir_all(&dir).await;
tokio::fs::create_dir_all(&dir).await.unwrap();
let result = EditTool
.execute(
&test_ws(&dir),
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.join("a/b/c/deep.txt"))
.await
.unwrap();
assert_eq!(content, "deep");
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn file_edit_blocks_dangerous_paths() {
with_temp_workspace("mahbot_test_file_edit_traversal", &[], |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("not allowed"));
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("not allowed"));
})
.await;
}
#[tokio::test]
async fn file_edit_normalizes_relative_path() {
with_temp_workspace(
"mahbot_test_file_edit_relative",
&[("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 = std::env::temp_dir().join("mahbot_test_file_edit_symlink_target");
let workspace = root.join("workspace");
let outside = root.join("outside");
let _ = tokio::fs::remove_dir_all(&root).await;
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");
let _ = tokio::fs::remove_dir_all(&root).await;
}
#[tokio::test]
async fn file_edit_nonexistent_file() {
with_temp_workspace("mahbot_test_file_edit_nofile", &[], |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(
"mahbot_test_file_edit_abs_path",
&[("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(
"mahbot_test_ws_ambiguous",
&[("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(
"mahbot_test_ws_unambiguous",
&[("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(
"mahbot_test_ws_nocode",
&[("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"),
".txt should use exact matching only, got: {err}"
);
},
)
.await;
}
}