Skip to main content

agent_sdk/primitive_tools/
edit.rs

1use 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
9/// Tool for editing files via string replacement
10pub 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    /// Path to the file to edit (also accepts `file_path` for compatibility)
26    #[serde(alias = "file_path")]
27    path: String,
28    /// String to find and replace
29    old_string: String,
30    /// Replacement string
31    new_string: String,
32    /// Replace all occurrences (default: false)
33    #[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        // An empty old_string would match between every character, so
86        // `content.replace("", ..)` interleaves new_string across the whole
87        // file — silent corruption reported as success. Reject it and steer
88        // the model toward the Write tool instead.
89        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        // A no-op edit (old_string == new_string) reports success while
96        // changing nothing, which can loop the model. Reject it explicitly.
97        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        // Check capabilities
106        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        // Check if file exists
113        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        // Check if it's a directory
125        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        // Read current content
139        let content = self
140            .ctx
141            .environment
142            .read_file(&path)
143            .await
144            .context("Failed to read file")?;
145
146        // Count occurrences
147        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        // Perform replacement
164        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        // Write back
171        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    // ===================
210    // Unit Tests
211    // ===================
212
213    #[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    // ===================
291    // Integration Tests
292    // ===================
293
294    #[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        // Read-only capabilities
300        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    // ===================
345    // Edge Cases
346    // ===================
347
348    #[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        // File should not have changed
392        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        // Missing required fields
512        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        // The file must be untouched (no interleaving corruption).
549        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}