1use std::path::PathBuf;
2
3use serde_json::Value;
4
5use crate::tools::{acquire_write_lock, require_str, ToolResult, ToolRuntime};
6
7const SANDBOX_EDIT_SCRIPT: &str = r#"
8from pathlib import Path
9import json
10import sys
11
12orig = sys.argv[1]
13path = Path(sys.argv[2])
14payload = json.load(sys.stdin)
15old_text = payload["old_text"]
16new_text = payload["new_text"]
17
18if not path.exists():
19 print(f"File not found: {orig}")
20 sys.exit(2)
21
22try:
23 content = path.read_text(encoding="utf-8")
24except Exception as exc:
25 print(f"Error reading {orig}: {exc}")
26 sys.exit(2)
27
28count = content.count(old_text)
29if count == 0:
30 print(f"old_text not found in {orig}")
31 sys.exit(2)
32if count > 1:
33 print(f"old_text appears {count} times — provide more context to make it unique")
34 sys.exit(2)
35
36new_content = content.replace(old_text, new_text, 1)
37try:
38 path.write_text(new_content, encoding="utf-8")
39 print("ok")
40except Exception as exc:
41 print(f"Error writing {orig}: {exc}")
42 sys.exit(2)
43"#;
44
45pub async fn execute(args: Value, runtime: &ToolRuntime) -> ToolResult {
46 let path = match require_str(&args, "path") {
47 Ok(s) => s,
48 Err(e) => return e,
49 };
50 let old_text = match require_str(&args, "old_text") {
51 Ok(s) => s,
52 Err(e) => return e,
53 };
54 let new_text = match require_str(&args, "new_text") {
55 Ok(s) => s,
56 Err(e) => return e,
57 };
58
59 if let Some(sandbox) = &runtime.sandbox {
60 let guest_path = match sandbox.resolve_path(&path) {
61 Ok(path) => path,
62 Err(error) => {
63 return ToolResult {
64 content: error.to_string(),
65 is_error: true,
66 }
67 }
68 };
69 let payload = serde_json::json!({
70 "old_text": old_text,
71 "new_text": new_text,
72 });
73 let args = vec![
74 "-c".to_string(),
75 SANDBOX_EDIT_SCRIPT.to_string(),
76 path.clone(),
77 guest_path.display().to_string(),
78 ];
79 return match sandbox
80 .exec("python3", &args, Some(payload.to_string().into_bytes()))
81 .await
82 {
83 Ok(output) if output.status.success() => ToolResult {
84 content: "ok".to_string(),
85 is_error: false,
86 },
87 Ok(output) => ToolResult {
88 content: String::from_utf8_lossy(&output.stdout).trim().to_string(),
89 is_error: true,
90 },
91 Err(error) => ToolResult {
92 content: format!("Error editing {} in sandbox: {}", path, error),
93 is_error: true,
94 },
95 };
96 }
97
98 let path = PathBuf::from(path);
99 if !path.exists() {
100 return ToolResult {
101 content: format!("File not found: {}", path.display()),
102 is_error: true,
103 };
104 }
105
106 let content = match tokio::fs::read_to_string(&path).await {
107 Ok(c) => c,
108 Err(e) => {
109 return ToolResult {
110 content: format!("Error reading {}: {}", path.display(), e),
111 is_error: true,
112 }
113 }
114 };
115
116 let count = content.matches(&old_text as &str).count();
117 if count == 0 {
118 return ToolResult {
119 content: format!("old_text not found in {}", path.display()),
120 is_error: true,
121 };
122 }
123 if count > 1 {
124 return ToolResult {
125 content: format!(
126 "old_text appears {} times — provide more context to make it unique",
127 count
128 ),
129 is_error: true,
130 };
131 }
132
133 let new_content = content.replacen(&old_text, &new_text, 1);
134 let _guard = acquire_write_lock().await;
135 match tokio::fs::write(&path, new_content.as_bytes()).await {
136 Ok(_) => ToolResult {
137 content: "ok".to_string(),
138 is_error: false,
139 },
140 Err(e) => ToolResult {
141 content: format!("Error writing {}: {}", path.display(), e),
142 is_error: true,
143 },
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use serde_json::json;
151 use std::collections::HashSet;
152 use std::sync::Arc;
153 use tokio::sync::Mutex;
154
155 use crate::events::EventSink;
156
157 async fn write_temp(content: &str) -> PathBuf {
158 use std::sync::atomic::{AtomicU64, Ordering};
159 static COUNTER: AtomicU64 = AtomicU64::new(0);
160 let id = COUNTER.fetch_add(1, Ordering::Relaxed);
161 let path = std::env::temp_dir().join(format!("agent_edit_test_{}.txt", id));
162 tokio::fs::write(&path, content).await.unwrap();
163 path
164 }
165
166 fn local_runtime() -> ToolRuntime {
167 ToolRuntime {
168 store_path: PathBuf::new(),
169 session_id: None,
170 worker_executable: None,
171 active_threads: Arc::new(Mutex::new(HashSet::new())),
172 event_sink: EventSink::none(),
173 sandbox: None,
174 mcp: None,
175 skills: None,
176 activated_skills: Arc::new(Mutex::new(HashSet::new())),
177 terminal_manager: crate::terminal::TerminalManager::new(),
178 thread_timeout_secs: crate::tools::thread::DEFAULT_THREAD_TIMEOUT_SECS,
179 }
180 }
181
182 #[tokio::test]
183 async fn test_exact_match() {
184 let path = write_temp("hello world\ngoodbye\n").await;
185 let result = execute(
186 json!({
187 "path": path.to_string_lossy(),
188 "old_text": "hello world",
189 "new_text": "hi earth"
190 }),
191 &local_runtime(),
192 )
193 .await;
194 assert!(!result.is_error, "Got error: {}", result.content);
195 let content = tokio::fs::read_to_string(&path).await.unwrap();
196 assert!(content.contains("hi earth"));
197 let _ = tokio::fs::remove_file(&path).await;
198 }
199
200 #[tokio::test]
201 async fn test_no_match() {
202 let path = write_temp("fn foo() {}\n").await;
203 let result = execute(
204 json!({
205 "path": path.to_string_lossy(),
206 "old_text": "nonexistent text xyz",
207 "new_text": "replacement"
208 }),
209 &local_runtime(),
210 )
211 .await;
212 assert!(result.is_error);
213 assert!(
214 result.content.contains("not found"),
215 "Got: {}",
216 result.content
217 );
218 let _ = tokio::fs::remove_file(&path).await;
219 }
220
221 #[tokio::test]
222 async fn test_multiple_matches() {
223 let path = write_temp("foo\nfoo\nfoo\n").await;
224 let result = execute(
225 json!({
226 "path": path.to_string_lossy(),
227 "old_text": "foo",
228 "new_text": "bar"
229 }),
230 &local_runtime(),
231 )
232 .await;
233 assert!(result.is_error);
234 assert!(
235 result.content.contains("3 times"),
236 "Got: {}",
237 result.content
238 );
239 let _ = tokio::fs::remove_file(&path).await;
240 }
241}