Skip to main content

apollo/tools/
edit.rs

1//! Edit tool — surgical text replacement in files (like OpenClaw's Edit).
2//! Finds exact text and replaces it, preserving the rest of the file.
3
4use async_trait::async_trait;
5use serde::Deserialize;
6use std::path::PathBuf;
7
8use super::sandbox::resolve_workspace_existing_path;
9use super::traits::*;
10
11pub struct EditTool {
12    workspace: PathBuf,
13}
14
15impl EditTool {
16    pub fn new(workspace: PathBuf) -> Self {
17        Self { workspace }
18    }
19}
20
21#[derive(Deserialize)]
22struct EditArgs {
23    /// File path (relative to workspace or absolute)
24    #[serde(alias = "file_path")]
25    path: String,
26    /// Exact text to find (must match exactly including whitespace)
27    #[serde(alias = "oldText")]
28    old_string: String,
29    /// New text to replace with
30    #[serde(alias = "newText")]
31    new_string: String,
32}
33
34#[async_trait]
35impl Tool for EditTool {
36    fn name(&self) -> &str {
37        "Edit"
38    }
39
40    fn spec(&self) -> ToolSpec {
41        ToolSpec {
42            name: "Edit".to_string(),
43            description: "Edit a file by replacing exact text. The old_string must match exactly (including whitespace). Use this for precise, surgical edits.".to_string(),
44            parameters: serde_json::json!({
45                "type": "object",
46                "properties": {
47                    "path": {
48                        "type": "string",
49                        "description": "Path to the file to edit (relative to workspace or absolute)"
50                    },
51                    "old_string": {
52                        "type": "string",
53                        "description": "Exact text to find and replace (must match exactly)"
54                    },
55                    "new_string": {
56                        "type": "string",
57                        "description": "New text to replace the old text with"
58                    }
59                },
60                "required": ["path", "old_string", "new_string"]
61            }),
62        }
63    }
64
65    async fn execute(&self, arguments: &str) -> anyhow::Result<ToolResult> {
66        let args: EditArgs = serde_json::from_str(arguments)?;
67
68        let full_path = match resolve_workspace_existing_path(&self.workspace, &args.path) {
69            Ok(path) => path,
70            Err(err) => return Ok(ToolResult::error(err.to_string())),
71        };
72
73        let content = match tokio::fs::read_to_string(&full_path).await {
74            Ok(c) => c,
75            Err(e) => {
76                return Ok(ToolResult::error(format!(
77                    "Cannot read file '{}': {}",
78                    args.path, e
79                )))
80            }
81        };
82
83        // Find and replace
84        if !content.contains(&args.old_string) {
85            return Ok(ToolResult::error(format!(
86                "Could not find the exact text in {}. The old_string must match exactly including all whitespace and newlines.",
87                args.path
88            )));
89        }
90
91        let new_content = content.replacen(&args.old_string, &args.new_string, 1);
92
93        // Write back
94        match tokio::fs::write(&full_path, &new_content).await {
95            Ok(_) => Ok(ToolResult::success(format!(
96                "Successfully replaced text in {}",
97                args.path
98            ))),
99            Err(e) => Ok(ToolResult::error(format!("Failed to write: {}", e))),
100        }
101    }
102}