Skip to main content

agent_sdk/primitive_tools/
edit.rs

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