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