Skip to main content

opi_coding_agent/tool/
edit.rs

1use std::future::Future;
2use std::path::PathBuf;
3use std::pin::Pin;
4
5use opi_agent::tool::{ExecutionMode, Tool, ToolError, ToolResult};
6use opi_ai::message::{OutputContent, ToolDef};
7use schemars::JsonSchema;
8use serde::Deserialize;
9use tokio_util::sync::CancellationToken;
10
11#[derive(Debug, Deserialize, JsonSchema)]
12pub struct EditArgs {
13    /// Relative path within workspace to edit.
14    pub path: String,
15    /// Exact string to find in the file.
16    pub old_string: String,
17    /// Replacement string.
18    pub new_string: String,
19}
20
21pub struct EditTool {
22    workspace_root: PathBuf,
23    schema: serde_json::Value,
24}
25
26impl EditTool {
27    pub fn new(workspace_root: PathBuf) -> Self {
28        let schema = schemars::schema_for!(EditArgs);
29        Self {
30            workspace_root,
31            schema: serde_json::to_value(&schema).unwrap_or_default(),
32        }
33    }
34}
35
36impl Tool for EditTool {
37    fn definition(&self) -> ToolDef {
38        ToolDef {
39            name: "edit".into(),
40            description: "Replace an exact string in a file.".into(),
41            input_schema: self.schema.clone(),
42        }
43    }
44
45    fn execute(
46        &self,
47        _call_id: &str,
48        arguments: serde_json::Value,
49        _signal: CancellationToken,
50        _on_update: Option<opi_agent::tool::UpdateCallback>,
51    ) -> Pin<Box<dyn Future<Output = Result<ToolResult, ToolError>> + Send>> {
52        let args: EditArgs = match serde_json::from_value(arguments) {
53            Ok(a) => a,
54            Err(e) => {
55                return Box::pin(async move {
56                    Ok(ToolResult {
57                        content: vec![OutputContent::Text {
58                            text: format!("invalid arguments: {e}"),
59                        }],
60                        details: None,
61                        is_error: true,
62                        terminate: false,
63                    })
64                });
65            }
66        };
67        let resolved_path = match super::resolve_tool_path(
68            &self.workspace_root,
69            &args.path,
70            super::PathPolicy::WorkspaceOnly,
71        ) {
72            Ok(p) => p,
73            Err(msg) => {
74                return Box::pin(async move {
75                    Ok(ToolResult {
76                        content: vec![OutputContent::Text { text: msg }],
77                        details: None,
78                        is_error: true,
79                        terminate: false,
80                    })
81                });
82            }
83        };
84        let file_path = resolved_path.path;
85        let inside_workspace = resolved_path.inside_workspace;
86        let workspace_root = self.workspace_root.clone();
87        let path_for_display = args.path.clone();
88        Box::pin(async move {
89            let content = match tokio::fs::read_to_string(&file_path).await {
90                Ok(c) => c,
91                Err(e) => {
92                    return Ok(ToolResult {
93                        content: vec![OutputContent::Text {
94                            text: format!("failed to read {}: {e}", file_path.display()),
95                        }],
96                        details: None,
97                        is_error: true,
98                        terminate: false,
99                    });
100                }
101            };
102
103            if !content.contains(&args.old_string) {
104                return Ok(ToolResult {
105                    content: vec![OutputContent::Text {
106                        text: format!("old_string not found in {}", file_path.display()),
107                    }],
108                    details: None,
109                    is_error: true,
110                    terminate: false,
111                });
112            }
113
114            // Capture before state for diff rendering.
115            let before = content.clone();
116
117            // Replace first occurrence only.
118            let new_content = content.replacen(&args.old_string, &args.new_string, 1);
119
120            if let Err(e) = tokio::fs::write(&file_path, &new_content).await {
121                return Ok(ToolResult {
122                    content: vec![OutputContent::Text {
123                        text: format!("failed to write {}: {e}", file_path.display()),
124                    }],
125                    details: None,
126                    is_error: true,
127                    terminate: false,
128                });
129            }
130
131            let details = serde_json::json!({
132                "workspace_root": workspace_root.to_string_lossy(),
133                "path": path_for_display,
134                "resolved_path": file_path.to_string_lossy(),
135                "inside_workspace": inside_workspace,
136                "before": before,
137                "after": new_content,
138            });
139
140            Ok(ToolResult {
141                content: vec![OutputContent::Text {
142                    text: format!("edited {}", path_for_display),
143                }],
144                details: Some(details),
145                is_error: false,
146                terminate: false,
147            })
148        })
149    }
150
151    fn execution_mode(&self) -> ExecutionMode {
152        ExecutionMode::Sequential
153    }
154}