agent_sdk/primitive_tools/
edit.rs1use crate::{Environment, Tool, ToolContext, ToolResult, ToolTier};
2use anyhow::{Context, Result};
3use async_trait::async_trait;
4use serde::Deserialize;
5use serde_json::{Value, json};
6use std::sync::Arc;
7
8use super::PrimitiveToolContext;
9
10pub struct EditTool<E: Environment> {
12 ctx: PrimitiveToolContext<E>,
13}
14
15impl<E: Environment> EditTool<E> {
16 #[must_use]
17 pub const fn new(environment: Arc<E>, capabilities: crate::AgentCapabilities) -> Self {
18 Self {
19 ctx: PrimitiveToolContext::new(environment, capabilities),
20 }
21 }
22}
23
24#[derive(Debug, Deserialize)]
25struct EditInput {
26 #[serde(alias = "file_path")]
28 path: String,
29 old_string: String,
31 new_string: String,
33 #[serde(default)]
35 replace_all: bool,
36}
37
38#[async_trait]
39impl<E: Environment + 'static> Tool<()> for EditTool<E> {
40 fn name(&self) -> &'static str {
41 "edit"
42 }
43
44 fn description(&self) -> &'static str {
45 "Edit a file by replacing a string. The old_string must match exactly and uniquely (unless replace_all is true)."
46 }
47
48 fn tier(&self) -> ToolTier {
49 ToolTier::Confirm
50 }
51
52 fn input_schema(&self) -> Value {
53 json!({
54 "type": "object",
55 "properties": {
56 "path": {
57 "type": "string",
58 "description": "Path to the file to edit"
59 },
60 "old_string": {
61 "type": "string",
62 "description": "The exact string to find and replace"
63 },
64 "new_string": {
65 "type": "string",
66 "description": "The replacement string"
67 },
68 "replace_all": {
69 "type": "boolean",
70 "description": "Replace all occurrences instead of requiring unique match. Default: false"
71 }
72 },
73 "required": ["path", "old_string", "new_string"]
74 })
75 }
76
77 async fn execute(&self, _ctx: &ToolContext<()>, input: Value) -> Result<ToolResult> {
78 let input: EditInput =
79 serde_json::from_value(input).context("Invalid input for edit tool")?;
80
81 let path = self.ctx.environment.resolve_path(&input.path);
82
83 if !self.ctx.capabilities.can_write(&path) {
85 return Ok(ToolResult::error(format!(
86 "Permission denied: cannot edit '{path}'"
87 )));
88 }
89
90 let exists = self
92 .ctx
93 .environment
94 .exists(&path)
95 .await
96 .context("Failed to check file existence")?;
97
98 if !exists {
99 return Ok(ToolResult::error(format!("File not found: '{path}'")));
100 }
101
102 let is_dir = self
104 .ctx
105 .environment
106 .is_dir(&path)
107 .await
108 .context("Failed to check if path is directory")?;
109
110 if is_dir {
111 return Ok(ToolResult::error(format!(
112 "'{path}' is a directory, cannot edit"
113 )));
114 }
115
116 let content = self
118 .ctx
119 .environment
120 .read_file(&path)
121 .await
122 .context("Failed to read file")?;
123
124 let count = content.matches(&input.old_string).count();
126
127 if count == 0 {
128 return Ok(ToolResult::error(format!(
129 "String not found in '{}': '{}'",
130 path,
131 truncate_string(&input.old_string, 100)
132 )));
133 }
134
135 if count > 1 && !input.replace_all {
136 return Ok(ToolResult::error(format!(
137 "Found {count} occurrences of the string in '{path}'. Use replace_all: true to replace all, or provide a more specific string."
138 )));
139 }
140
141 let new_content = if input.replace_all {
143 content.replace(&input.old_string, &input.new_string)
144 } else {
145 content.replacen(&input.old_string, &input.new_string, 1)
146 };
147
148 self.ctx
150 .environment
151 .write_file(&path, &new_content)
152 .await
153 .context("Failed to write file")?;
154
155 let replacements = if input.replace_all { count } else { 1 };
156 Ok(ToolResult::success(format!(
157 "Successfully replaced {replacements} occurrence(s) in '{path}'"
158 )))
159 }
160}
161
162fn truncate_string(s: &str, max_len: usize) -> String {
163 if s.len() <= max_len {
164 s.to_string()
165 } else {
166 format!("{}...", &s[..max_len])
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 use crate::{AgentCapabilities, InMemoryFileSystem};
174
175 fn create_test_tool(
176 fs: Arc<InMemoryFileSystem>,
177 capabilities: AgentCapabilities,
178 ) -> EditTool<InMemoryFileSystem> {
179 EditTool::new(fs, capabilities)
180 }
181
182 fn tool_ctx() -> ToolContext<()> {
183 ToolContext::new(())
184 }
185
186 #[tokio::test]
191 async fn test_edit_simple_replacement() -> anyhow::Result<()> {
192 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
193 fs.write_file("test.txt", "Hello, World!").await?;
194
195 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
196 let result = tool
197 .execute(
198 &tool_ctx(),
199 json!({
200 "path": "/workspace/test.txt",
201 "old_string": "World",
202 "new_string": "Rust"
203 }),
204 )
205 .await?;
206
207 assert!(result.success);
208 assert!(result.output.contains("1 occurrence"));
209
210 let content = fs.read_file("/workspace/test.txt").await?;
211 assert_eq!(content, "Hello, Rust!");
212 Ok(())
213 }
214
215 #[tokio::test]
216 async fn test_edit_replace_all_true() -> anyhow::Result<()> {
217 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
218 fs.write_file("test.txt", "foo bar foo baz foo").await?;
219
220 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
221 let result = tool
222 .execute(
223 &tool_ctx(),
224 json!({
225 "path": "/workspace/test.txt",
226 "old_string": "foo",
227 "new_string": "qux",
228 "replace_all": true
229 }),
230 )
231 .await?;
232
233 assert!(result.success);
234 assert!(result.output.contains("3 occurrence"));
235
236 let content = fs.read_file("/workspace/test.txt").await?;
237 assert_eq!(content, "qux bar qux baz qux");
238 Ok(())
239 }
240
241 #[tokio::test]
242 async fn test_edit_multiline_replacement() -> anyhow::Result<()> {
243 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
244 fs.write_file("test.rs", "fn main() {\n println!(\"Hello\");\n}")
245 .await?;
246
247 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
248 let result = tool
249 .execute(
250 &tool_ctx(),
251 json!({
252 "path": "/workspace/test.rs",
253 "old_string": "println!(\"Hello\");",
254 "new_string": "println!(\"Hello, World!\");\n println!(\"Goodbye!\");"
255 }),
256 )
257 .await?;
258
259 assert!(result.success);
260
261 let content = fs.read_file("/workspace/test.rs").await?;
262 assert!(content.contains("Hello, World!"));
263 assert!(content.contains("Goodbye!"));
264 Ok(())
265 }
266
267 #[tokio::test]
272 async fn test_edit_permission_denied() -> anyhow::Result<()> {
273 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
274 fs.write_file("test.txt", "content").await?;
275
276 let caps = AgentCapabilities::read_only();
278
279 let tool = create_test_tool(fs, caps);
280 let result = tool
281 .execute(
282 &tool_ctx(),
283 json!({
284 "path": "/workspace/test.txt",
285 "old_string": "content",
286 "new_string": "new content"
287 }),
288 )
289 .await?;
290
291 assert!(!result.success);
292 assert!(result.output.contains("Permission denied"));
293 Ok(())
294 }
295
296 #[tokio::test]
297 async fn test_edit_denied_path() -> anyhow::Result<()> {
298 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
299 fs.write_file("secrets/config.txt", "secret=value").await?;
300
301 let caps = AgentCapabilities::full_access()
302 .with_denied_paths(vec!["/workspace/secrets/**".into()]);
303
304 let tool = create_test_tool(fs, caps);
305 let result = tool
306 .execute(
307 &tool_ctx(),
308 json!({
309 "path": "/workspace/secrets/config.txt",
310 "old_string": "value",
311 "new_string": "newvalue"
312 }),
313 )
314 .await?;
315
316 assert!(!result.success);
317 assert!(result.output.contains("Permission denied"));
318 Ok(())
319 }
320
321 #[tokio::test]
326 async fn test_edit_string_not_found() -> anyhow::Result<()> {
327 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
328 fs.write_file("test.txt", "Hello, World!").await?;
329
330 let tool = create_test_tool(fs, AgentCapabilities::full_access());
331 let result = tool
332 .execute(
333 &tool_ctx(),
334 json!({
335 "path": "/workspace/test.txt",
336 "old_string": "Rust",
337 "new_string": "Go"
338 }),
339 )
340 .await?;
341
342 assert!(!result.success);
343 assert!(result.output.contains("String not found"));
344 Ok(())
345 }
346
347 #[tokio::test]
348 async fn test_edit_multiple_occurrences_without_replace_all() -> anyhow::Result<()> {
349 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
350 fs.write_file("test.txt", "foo bar foo baz").await?;
351
352 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
353 let result = tool
354 .execute(
355 &tool_ctx(),
356 json!({
357 "path": "/workspace/test.txt",
358 "old_string": "foo",
359 "new_string": "qux"
360 }),
361 )
362 .await?;
363
364 assert!(!result.success);
365 assert!(result.output.contains("2 occurrences"));
366 assert!(result.output.contains("replace_all"));
367
368 let content = fs.read_file("/workspace/test.txt").await?;
370 assert_eq!(content, "foo bar foo baz");
371 Ok(())
372 }
373
374 #[tokio::test]
375 async fn test_edit_file_not_found() -> anyhow::Result<()> {
376 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
377
378 let tool = create_test_tool(fs, AgentCapabilities::full_access());
379 let result = tool
380 .execute(
381 &tool_ctx(),
382 json!({
383 "path": "/workspace/nonexistent.txt",
384 "old_string": "foo",
385 "new_string": "bar"
386 }),
387 )
388 .await?;
389
390 assert!(!result.success);
391 assert!(result.output.contains("File not found"));
392 Ok(())
393 }
394
395 #[tokio::test]
396 async fn test_edit_directory_path() -> anyhow::Result<()> {
397 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
398 fs.create_dir("/workspace/subdir").await?;
399
400 let tool = create_test_tool(fs, AgentCapabilities::full_access());
401 let result = tool
402 .execute(
403 &tool_ctx(),
404 json!({
405 "path": "/workspace/subdir",
406 "old_string": "foo",
407 "new_string": "bar"
408 }),
409 )
410 .await?;
411
412 assert!(!result.success);
413 assert!(result.output.contains("is a directory"));
414 Ok(())
415 }
416
417 #[tokio::test]
418 async fn test_edit_empty_new_string_deletes() -> anyhow::Result<()> {
419 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
420 fs.write_file("test.txt", "Hello, World!").await?;
421
422 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
423 let result = tool
424 .execute(
425 &tool_ctx(),
426 json!({
427 "path": "/workspace/test.txt",
428 "old_string": ", World",
429 "new_string": ""
430 }),
431 )
432 .await?;
433
434 assert!(result.success);
435
436 let content = fs.read_file("/workspace/test.txt").await?;
437 assert_eq!(content, "Hello!");
438 Ok(())
439 }
440
441 #[tokio::test]
442 async fn test_edit_special_characters() -> anyhow::Result<()> {
443 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
444 fs.write_file("test.txt", "įđæŪåįŽĶ emoji ð here").await?;
445
446 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
447 let result = tool
448 .execute(
449 &tool_ctx(),
450 json!({
451 "path": "/workspace/test.txt",
452 "old_string": "ð",
453 "new_string": "ð"
454 }),
455 )
456 .await?;
457
458 assert!(result.success);
459
460 let content = fs.read_file("/workspace/test.txt").await?;
461 assert!(content.contains("ð"));
462 assert!(!content.contains("ð"));
463 Ok(())
464 }
465
466 #[tokio::test]
467 async fn test_edit_tool_metadata() {
468 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
469 let tool = create_test_tool(fs, AgentCapabilities::full_access());
470
471 assert_eq!(tool.name(), "edit");
472 assert_eq!(tool.tier(), ToolTier::Confirm);
473 assert!(tool.description().contains("Edit"));
474
475 let schema = tool.input_schema();
476 assert!(schema.get("properties").is_some());
477 assert!(schema["properties"].get("path").is_some());
478 assert!(schema["properties"].get("old_string").is_some());
479 assert!(schema["properties"].get("new_string").is_some());
480 assert!(schema["properties"].get("replace_all").is_some());
481 }
482
483 #[tokio::test]
484 async fn test_edit_invalid_input() -> anyhow::Result<()> {
485 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
486 let tool = create_test_tool(fs, AgentCapabilities::full_access());
487
488 let result = tool
490 .execute(&tool_ctx(), json!({"path": "/workspace/test.txt"}))
491 .await;
492 assert!(result.is_err());
493 Ok(())
494 }
495
496 #[tokio::test]
497 async fn test_truncate_string_function() {
498 assert_eq!(truncate_string("short", 10), "short");
499 assert_eq!(
500 truncate_string("this is a longer string", 10),
501 "this is a ..."
502 );
503 }
504
505 #[tokio::test]
506 async fn test_edit_preserves_surrounding_content() -> anyhow::Result<()> {
507 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
508 let original = "line 1\nline 2 with target\nline 3";
509 fs.write_file("test.txt", original).await?;
510
511 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
512 let result = tool
513 .execute(
514 &tool_ctx(),
515 json!({
516 "path": "/workspace/test.txt",
517 "old_string": "target",
518 "new_string": "replacement"
519 }),
520 )
521 .await?;
522
523 assert!(result.success);
524
525 let content = fs.read_file("/workspace/test.txt").await?;
526 assert_eq!(content, "line 1\nline 2 with replacement\nline 3");
527 Ok(())
528 }
529}