toast_api/tools/
editor.rs1use super::{Tool, ToolInfo};
4use anyhow::{anyhow, Result};
5use async_trait::async_trait;
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8use tokio::fs;
9
10#[derive(Debug, Clone)]
11pub struct EditorTool;
12
13#[derive(Debug, Deserialize, Serialize)]
14struct EditorParams {
15 command: String,
16 path: String,
17 #[serde(skip_serializing_if = "Option::is_none")]
18 file_text: Option<String>,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 view_range: Option<[i32; 2]>,
21 #[serde(skip_serializing_if = "Option::is_none")]
22 old_str: Option<String>,
23 #[serde(skip_serializing_if = "Option::is_none")]
24 new_str: Option<String>,
25}
26
27impl Default for EditorTool {
28 fn default() -> Self {
29 Self::new()
30 }
31}
32
33impl EditorTool {
34 pub fn new() -> Self {
35 Self
36 }
37
38 async fn view_file(&self, path: &Path, range: Option<[i32; 2]>) -> Result<String> {
39 let content = fs::read_to_string(path).await
40 .map_err(|e| anyhow!("Failed to read file: {}", e))?;
41
42 let lines: Vec<&str> = content.lines().collect();
43 let total_lines = lines.len();
44
45 let (start, end) = if let Some([start, end]) = range {
46 let start = if start < 1 { 1 } else { start as usize };
47 let end = if end == -1 || end as usize > total_lines {
48 total_lines
49 } else {
50 end as usize
51 };
52 (start, end)
53 } else {
54 (1, total_lines)
55 };
56
57 let mut result = format!("File: {} (lines {}-{} of {})\n", path.display(), start, end, total_lines);
58 result.push_str(&"─".repeat(60));
59 result.push('\n');
60
61 for (idx, line) in lines.iter().enumerate() {
62 let line_num = idx + 1;
63 if line_num >= start && line_num <= end {
64 result.push_str(&format!("{line_num:6} │ {line}\n"));
65 }
66 }
67
68 Ok(result)
69 }
70
71 async fn create_file(&self, path: &Path, content: &str) -> Result<String> {
72 if path.exists() {
73 return Err(anyhow!("File already exists: {}", path.display()));
74 }
75
76 if let Some(parent) = path.parent() {
78 fs::create_dir_all(parent).await
79 .map_err(|e| anyhow!("Failed to create parent directories: {}", e))?;
80 }
81
82 fs::write(path, content).await
83 .map_err(|e| anyhow!("Failed to write file: {}", e))?;
84
85 Ok(format!("File created successfully at: {}", path.display()))
86 }
87
88 async fn str_replace(&self, path: &Path, old_str: &str, new_str: &str) -> Result<String> {
89 let content = fs::read_to_string(path).await
90 .map_err(|e| anyhow!("Failed to read file: {}", e))?;
91
92 let occurrences = content.matches(old_str).count();
93
94 if occurrences == 0 {
95 return Err(anyhow!("Could not find the exact text to replace in {}", path.display()));
96 } else if occurrences > 1 {
97 return Err(anyhow!(
98 "Found multiple ({}) occurrences of the text in {}. Must be unique.",
99 occurrences,
100 path.display()
101 ));
102 }
103
104 let new_content = content.replace(old_str, new_str);
105 fs::write(path, new_content).await
106 .map_err(|e| anyhow!("Failed to write file: {}", e))?;
107
108 Ok(format!("Successfully replaced text in {}", path.display()))
109 }
110}
111
112#[async_trait]
113impl Tool for EditorTool {
114 fn info(&self) -> ToolInfo {
115 ToolInfo {
116 name: "editor".to_string(),
117 description: r#"File viewing and editing tool
118* Use 'view' to display file contents with line numbers
119* Use 'create' to create new files (fails if file exists)
120* Use 'str_replace' to replace unique text occurrences
121* view_range: [start, end] where lines are 1-based, use -1 for end to read until EOF"#.to_string(),
122 input_schema: serde_json::json!({
123 "type": "object",
124 "properties": {
125 "command": {
126 "type": "string",
127 "enum": ["view", "create", "str_replace"],
128 "description": "The command to run"
129 },
130 "path": {
131 "type": "string",
132 "description": "Path to the file"
133 },
134 "file_text": {
135 "type": "string",
136 "description": "Content for create command"
137 },
138 "view_range": {
139 "type": "array",
140 "items": {"type": "integer"},
141 "minItems": 2,
142 "maxItems": 2,
143 "description": "Line range [start, end] for view command"
144 },
145 "old_str": {
146 "type": "string",
147 "description": "Text to find for str_replace"
148 },
149 "new_str": {
150 "type": "string",
151 "description": "Replacement text for str_replace"
152 }
153 },
154 "required": ["command", "path"]
155 }),
156 }
157 }
158
159 async fn execute(&self, params: serde_json::Value) -> Result<String> {
160 let editor_params: EditorParams = serde_json::from_value(params)
161 .map_err(|e| anyhow!("Invalid parameters: {}", e))?;
162
163 let path = PathBuf::from(&editor_params.path);
164
165 match editor_params.command.as_str() {
166 "view" => self.view_file(&path, editor_params.view_range).await,
167 "create" => {
168 let content = editor_params.file_text
169 .ok_or_else(|| anyhow!("Missing file_text for create command"))?;
170 self.create_file(&path, &content).await
171 }
172 "str_replace" => {
173 let old_str = editor_params.old_str
174 .ok_or_else(|| anyhow!("Missing old_str for str_replace"))?;
175 let new_str = editor_params.new_str
176 .ok_or_else(|| anyhow!("Missing new_str for str_replace"))?;
177 self.str_replace(&path, &old_str, &new_str).await
178 }
179 _ => Err(anyhow!("Unknown command: {}", editor_params.command)),
180 }
181 }
182}