agent_sdk/primitive_tools/
edit.rs

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