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, Ctx: Send + Sync + 'static> Tool<Ctx> 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<Ctx>, input: Value) -> Result<ToolResult> {
82 let input: EditInput = EditInput::deserialize(&input)
83 .with_context(|| format!("Invalid input for edit tool: {input}"))?;
84
85 if input.old_string.is_empty() {
90 return Ok(ToolResult::error(
91 "old_string must not be empty; use the Write tool to create or overwrite file content",
92 ));
93 }
94
95 if input.old_string == input.new_string {
98 return Ok(ToolResult::error(
99 "old_string and new_string are identical; the edit would not change the file",
100 ));
101 }
102
103 let path = self.ctx.environment.resolve_path(&input.path);
104
105 if let Err(reason) = self.ctx.capabilities.check_write(&path) {
107 return Ok(ToolResult::error(format!(
108 "Permission denied: cannot edit '{path}': {reason}"
109 )));
110 }
111
112 let exists = self
114 .ctx
115 .environment
116 .exists(&path)
117 .await
118 .context("Failed to check file existence")?;
119
120 if !exists {
121 return Ok(ToolResult::error(format!("File not found: '{path}'")));
122 }
123
124 let is_dir = self
126 .ctx
127 .environment
128 .is_dir(&path)
129 .await
130 .context("Failed to check if path is directory")?;
131
132 if is_dir {
133 return Ok(ToolResult::error(format!(
134 "'{path}' is a directory, cannot edit"
135 )));
136 }
137
138 let content = self
140 .ctx
141 .environment
142 .read_file(&path)
143 .await
144 .context("Failed to read file")?;
145
146 let count = content.matches(&input.old_string).count();
148
149 if count == 0 {
150 return Ok(ToolResult::error(format!(
151 "String not found in '{}': '{}'",
152 path,
153 truncate_string(&input.old_string, 100)
154 )));
155 }
156
157 if count > 1 && !input.replace_all {
158 return Ok(ToolResult::error(format!(
159 "Found {count} occurrences of the string in '{path}'. Use replace_all: true to replace all, or provide a more specific string."
160 )));
161 }
162
163 let new_content = if input.replace_all {
165 content.replace(&input.old_string, &input.new_string)
166 } else {
167 content.replacen(&input.old_string, &input.new_string, 1)
168 };
169
170 self.ctx
172 .environment
173 .write_file(&path, &new_content)
174 .await
175 .context("Failed to write file")?;
176
177 let replacements = if input.replace_all { count } else { 1 };
178
179 Ok(ToolResult::success(format!(
180 "Successfully replaced {replacements} occurrence(s) in '{path}'"
181 )))
182 }
183}
184
185fn truncate_string(s: &str, max_len: usize) -> String {
186 if s.len() <= max_len {
187 s.to_string()
188 } else {
189 format!("{}...", super::truncate_str(s, max_len))
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use crate::{AgentCapabilities, InMemoryFileSystem};
197
198 fn create_test_tool(
199 fs: Arc<InMemoryFileSystem>,
200 capabilities: AgentCapabilities,
201 ) -> EditTool<InMemoryFileSystem> {
202 EditTool::new(fs, capabilities)
203 }
204
205 fn tool_ctx() -> ToolContext<()> {
206 ToolContext::new(())
207 }
208
209 #[tokio::test]
214 async fn test_edit_simple_replacement() -> anyhow::Result<()> {
215 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
216 fs.write_file("test.txt", "Hello, World!").await?;
217
218 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
219 let result = tool
220 .execute(
221 &tool_ctx(),
222 json!({
223 "path": "/workspace/test.txt",
224 "old_string": "World",
225 "new_string": "Rust"
226 }),
227 )
228 .await?;
229
230 assert!(result.success);
231 assert!(result.output.contains("1 occurrence"));
232
233 let content = fs.read_file("/workspace/test.txt").await?;
234 assert_eq!(content, "Hello, Rust!");
235 Ok(())
236 }
237
238 #[tokio::test]
239 async fn test_edit_replace_all_true() -> anyhow::Result<()> {
240 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
241 fs.write_file("test.txt", "foo bar foo baz foo").await?;
242
243 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
244 let result = tool
245 .execute(
246 &tool_ctx(),
247 json!({
248 "path": "/workspace/test.txt",
249 "old_string": "foo",
250 "new_string": "qux",
251 "replace_all": true
252 }),
253 )
254 .await?;
255
256 assert!(result.success);
257 assert!(result.output.contains("3 occurrence"));
258
259 let content = fs.read_file("/workspace/test.txt").await?;
260 assert_eq!(content, "qux bar qux baz qux");
261 Ok(())
262 }
263
264 #[tokio::test]
265 async fn test_edit_multiline_replacement() -> anyhow::Result<()> {
266 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
267 fs.write_file("test.rs", "fn main() {\n println!(\"Hello\");\n}")
268 .await?;
269
270 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
271 let result = tool
272 .execute(
273 &tool_ctx(),
274 json!({
275 "path": "/workspace/test.rs",
276 "old_string": "println!(\"Hello\");",
277 "new_string": "println!(\"Hello, World!\");\n println!(\"Goodbye!\");"
278 }),
279 )
280 .await?;
281
282 assert!(result.success);
283
284 let content = fs.read_file("/workspace/test.rs").await?;
285 assert!(content.contains("Hello, World!"));
286 assert!(content.contains("Goodbye!"));
287 Ok(())
288 }
289
290 #[tokio::test]
295 async fn test_edit_permission_denied() -> anyhow::Result<()> {
296 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
297 fs.write_file("test.txt", "content").await?;
298
299 let caps = AgentCapabilities::read_only();
301
302 let tool = create_test_tool(fs, caps);
303 let result = tool
304 .execute(
305 &tool_ctx(),
306 json!({
307 "path": "/workspace/test.txt",
308 "old_string": "content",
309 "new_string": "new content"
310 }),
311 )
312 .await?;
313
314 assert!(!result.success);
315 assert!(result.output.contains("Permission denied"));
316 Ok(())
317 }
318
319 #[tokio::test]
320 async fn test_edit_denied_path() -> anyhow::Result<()> {
321 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
322 fs.write_file("secrets/config.txt", "secret=value").await?;
323
324 let caps = AgentCapabilities::full_access()
325 .with_denied_paths(vec!["/workspace/secrets/**".into()]);
326
327 let tool = create_test_tool(fs, caps);
328 let result = tool
329 .execute(
330 &tool_ctx(),
331 json!({
332 "path": "/workspace/secrets/config.txt",
333 "old_string": "value",
334 "new_string": "newvalue"
335 }),
336 )
337 .await?;
338
339 assert!(!result.success);
340 assert!(result.output.contains("Permission denied"));
341 Ok(())
342 }
343
344 #[tokio::test]
349 async fn test_edit_string_not_found() -> anyhow::Result<()> {
350 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
351 fs.write_file("test.txt", "Hello, World!").await?;
352
353 let tool = create_test_tool(fs, AgentCapabilities::full_access());
354 let result = tool
355 .execute(
356 &tool_ctx(),
357 json!({
358 "path": "/workspace/test.txt",
359 "old_string": "Rust",
360 "new_string": "Go"
361 }),
362 )
363 .await?;
364
365 assert!(!result.success);
366 assert!(result.output.contains("String not found"));
367 Ok(())
368 }
369
370 #[tokio::test]
371 async fn test_edit_multiple_occurrences_without_replace_all() -> anyhow::Result<()> {
372 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
373 fs.write_file("test.txt", "foo bar foo baz").await?;
374
375 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
376 let result = tool
377 .execute(
378 &tool_ctx(),
379 json!({
380 "path": "/workspace/test.txt",
381 "old_string": "foo",
382 "new_string": "qux"
383 }),
384 )
385 .await?;
386
387 assert!(!result.success);
388 assert!(result.output.contains("2 occurrences"));
389 assert!(result.output.contains("replace_all"));
390
391 let content = fs.read_file("/workspace/test.txt").await?;
393 assert_eq!(content, "foo bar foo baz");
394 Ok(())
395 }
396
397 #[tokio::test]
398 async fn test_edit_file_not_found() -> anyhow::Result<()> {
399 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
400
401 let tool = create_test_tool(fs, AgentCapabilities::full_access());
402 let result = tool
403 .execute(
404 &tool_ctx(),
405 json!({
406 "path": "/workspace/nonexistent.txt",
407 "old_string": "foo",
408 "new_string": "bar"
409 }),
410 )
411 .await?;
412
413 assert!(!result.success);
414 assert!(result.output.contains("File not found"));
415 Ok(())
416 }
417
418 #[tokio::test]
419 async fn test_edit_directory_path() -> anyhow::Result<()> {
420 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
421 fs.create_dir("/workspace/subdir").await?;
422
423 let tool = create_test_tool(fs, AgentCapabilities::full_access());
424 let result = tool
425 .execute(
426 &tool_ctx(),
427 json!({
428 "path": "/workspace/subdir",
429 "old_string": "foo",
430 "new_string": "bar"
431 }),
432 )
433 .await?;
434
435 assert!(!result.success);
436 assert!(result.output.contains("is a directory"));
437 Ok(())
438 }
439
440 #[tokio::test]
441 async fn test_edit_empty_new_string_deletes() -> anyhow::Result<()> {
442 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
443 fs.write_file("test.txt", "Hello, World!").await?;
444
445 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
446 let result = tool
447 .execute(
448 &tool_ctx(),
449 json!({
450 "path": "/workspace/test.txt",
451 "old_string": ", World",
452 "new_string": ""
453 }),
454 )
455 .await?;
456
457 assert!(result.success);
458
459 let content = fs.read_file("/workspace/test.txt").await?;
460 assert_eq!(content, "Hello!");
461 Ok(())
462 }
463
464 #[tokio::test]
465 async fn test_edit_special_characters() -> anyhow::Result<()> {
466 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
467 fs.write_file("test.txt", "特殊字符 emoji 🎉 here").await?;
468
469 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
470 let result = tool
471 .execute(
472 &tool_ctx(),
473 json!({
474 "path": "/workspace/test.txt",
475 "old_string": "🎉",
476 "new_string": "🚀"
477 }),
478 )
479 .await?;
480
481 assert!(result.success);
482
483 let content = fs.read_file("/workspace/test.txt").await?;
484 assert!(content.contains("🚀"));
485 assert!(!content.contains("🎉"));
486 Ok(())
487 }
488
489 #[tokio::test]
490 async fn test_edit_tool_metadata() {
491 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
492 let tool = create_test_tool(fs, AgentCapabilities::full_access());
493
494 assert_eq!(Tool::<()>::name(&tool), PrimitiveToolName::Edit);
495 assert_eq!(Tool::<()>::tier(&tool), ToolTier::Confirm);
496 assert!(Tool::<()>::description(&tool).contains("Edit"));
497
498 let schema = Tool::<()>::input_schema(&tool);
499 assert!(schema.get("properties").is_some());
500 assert!(schema["properties"].get("path").is_some());
501 assert!(schema["properties"].get("old_string").is_some());
502 assert!(schema["properties"].get("new_string").is_some());
503 assert!(schema["properties"].get("replace_all").is_some());
504 }
505
506 #[tokio::test]
507 async fn test_edit_invalid_input() -> anyhow::Result<()> {
508 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
509 let tool = create_test_tool(fs, AgentCapabilities::full_access());
510
511 let result = tool
513 .execute(&tool_ctx(), json!({"path": "/workspace/test.txt"}))
514 .await;
515 assert!(result.is_err());
516 Ok(())
517 }
518
519 #[tokio::test]
520 async fn test_truncate_string_function() {
521 assert_eq!(truncate_string("short", 10), "short");
522 assert_eq!(
523 truncate_string("this is a longer string", 10),
524 "this is a ..."
525 );
526 }
527
528 #[tokio::test]
529 async fn test_edit_rejects_empty_old_string() -> anyhow::Result<()> {
530 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
531 fs.write_file("test.txt", "ab").await?;
532
533 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
534 let result = tool
535 .execute(
536 &tool_ctx(),
537 json!({
538 "path": "/workspace/test.txt",
539 "old_string": "",
540 "new_string": "x"
541 }),
542 )
543 .await?;
544
545 assert!(!result.success);
546 assert!(result.output.contains("old_string must not be empty"));
547
548 let content = fs.read_file("/workspace/test.txt").await?;
550 assert_eq!(content, "ab");
551 Ok(())
552 }
553
554 #[tokio::test]
555 async fn test_edit_rejects_empty_old_string_with_replace_all() -> anyhow::Result<()> {
556 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
557 fs.write_file("test.txt", "abc").await?;
558
559 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
560 let result = tool
561 .execute(
562 &tool_ctx(),
563 json!({
564 "path": "/workspace/test.txt",
565 "old_string": "",
566 "new_string": "X",
567 "replace_all": true
568 }),
569 )
570 .await?;
571
572 assert!(!result.success);
573 assert!(result.output.contains("old_string must not be empty"));
574
575 let content = fs.read_file("/workspace/test.txt").await?;
576 assert_eq!(content, "abc");
577 Ok(())
578 }
579
580 #[tokio::test]
581 async fn test_edit_rejects_identical_old_and_new() -> anyhow::Result<()> {
582 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
583 fs.write_file("test.txt", "hello world").await?;
584
585 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
586 let result = tool
587 .execute(
588 &tool_ctx(),
589 json!({
590 "path": "/workspace/test.txt",
591 "old_string": "world",
592 "new_string": "world"
593 }),
594 )
595 .await?;
596
597 assert!(!result.success);
598 assert!(result.output.contains("identical"));
599
600 let content = fs.read_file("/workspace/test.txt").await?;
601 assert_eq!(content, "hello world");
602 Ok(())
603 }
604
605 #[tokio::test]
606 async fn test_edit_preserves_surrounding_content() -> anyhow::Result<()> {
607 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
608 let original = "line 1\nline 2 with target\nline 3";
609 fs.write_file("test.txt", original).await?;
610
611 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
612 let result = tool
613 .execute(
614 &tool_ctx(),
615 json!({
616 "path": "/workspace/test.txt",
617 "old_string": "target",
618 "new_string": "replacement"
619 }),
620 )
621 .await?;
622
623 assert!(result.success);
624
625 let content = fs.read_file("/workspace/test.txt").await?;
626 assert_eq!(content, "line 1\nline 2 with replacement\nline 3");
627 Ok(())
628 }
629}