opi_coding_agent/tool/
edit.rs1use 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 pub path: String,
15 pub old_string: String,
17 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 let before = content.clone();
116
117 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}