toast_api/tools/
editor.rs

1//! Editor tool implementation for file viewing and editing
2
3use 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        // Create parent directories if needed
77        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}