agent_sdk/primitive_tools/
edit.rs

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