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