toast-api 0.1.9

An unofficial CLI client and API server for Claude/Deepseek
Documentation
//! Editor tool implementation for file viewing and editing

use super::{Tool, ToolInfo};
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tokio::fs;

#[derive(Debug, Clone)]
pub struct EditorTool;

#[derive(Debug, Deserialize, Serialize)]
struct EditorParams {
    command: String,
    path: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    file_text: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    view_range: Option<[i32; 2]>,
    #[serde(skip_serializing_if = "Option::is_none")]
    old_str: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    new_str: Option<String>,
}

impl Default for EditorTool {
    fn default() -> Self {
        Self::new()
    }
}

impl EditorTool {
    pub fn new() -> Self {
        Self
    }

    async fn view_file(&self, path: &Path, range: Option<[i32; 2]>) -> Result<String> {
        let content = fs::read_to_string(path).await
            .map_err(|e| anyhow!("Failed to read file: {}", e))?;

        let lines: Vec<&str> = content.lines().collect();
        let total_lines = lines.len();

        let (start, end) = if let Some([start, end]) = range {
            let start = if start < 1 { 1 } else { start as usize };
            let end = if end == -1 || end as usize > total_lines {
                total_lines
            } else {
                end as usize
            };
            (start, end)
        } else {
            (1, total_lines)
        };

        let mut result = format!("File: {} (lines {}-{} of {})\n", path.display(), start, end, total_lines);
        result.push_str(&"".repeat(60));
        result.push('\n');

        for (idx, line) in lines.iter().enumerate() {
            let line_num = idx + 1;
            if line_num >= start && line_num <= end {
                result.push_str(&format!("{line_num:6}{line}\n"));
            }
        }

        Ok(result)
    }

    async fn create_file(&self, path: &Path, content: &str) -> Result<String> {
        if path.exists() {
            return Err(anyhow!("File already exists: {}", path.display()));
        }

        // Create parent directories if needed
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).await
                .map_err(|e| anyhow!("Failed to create parent directories: {}", e))?;
        }

        fs::write(path, content).await
            .map_err(|e| anyhow!("Failed to write file: {}", e))?;

        Ok(format!("File created successfully at: {}", path.display()))
    }

    async fn str_replace(&self, path: &Path, old_str: &str, new_str: &str) -> Result<String> {
        let content = fs::read_to_string(path).await
            .map_err(|e| anyhow!("Failed to read file: {}", e))?;

        let occurrences = content.matches(old_str).count();

        if occurrences == 0 {
            return Err(anyhow!("Could not find the exact text to replace in {}", path.display()));
        } else if occurrences > 1 {
            return Err(anyhow!(
                "Found multiple ({}) occurrences of the text in {}. Must be unique.",
                occurrences,
                path.display()
            ));
        }

        let new_content = content.replace(old_str, new_str);
        fs::write(path, new_content).await
            .map_err(|e| anyhow!("Failed to write file: {}", e))?;

        Ok(format!("Successfully replaced text in {}", path.display()))
    }
}

#[async_trait]
impl Tool for EditorTool {
    fn info(&self) -> ToolInfo {
        ToolInfo {
            name: "editor".to_string(),
            description: r#"File viewing and editing tool
* Use 'view' to display file contents with line numbers
* Use 'create' to create new files (fails if file exists)
* Use 'str_replace' to replace unique text occurrences
* view_range: [start, end] where lines are 1-based, use -1 for end to read until EOF"#.to_string(),
            input_schema: serde_json::json!({
                "type": "object",
                "properties": {
                    "command": {
                        "type": "string",
                        "enum": ["view", "create", "str_replace"],
                        "description": "The command to run"
                    },
                    "path": {
                        "type": "string",
                        "description": "Path to the file"
                    },
                    "file_text": {
                        "type": "string",
                        "description": "Content for create command"
                    },
                    "view_range": {
                        "type": "array",
                        "items": {"type": "integer"},
                        "minItems": 2,
                        "maxItems": 2,
                        "description": "Line range [start, end] for view command"
                    },
                    "old_str": {
                        "type": "string",
                        "description": "Text to find for str_replace"
                    },
                    "new_str": {
                        "type": "string",
                        "description": "Replacement text for str_replace"
                    }
                },
                "required": ["command", "path"]
            }),
        }
    }

    async fn execute(&self, params: serde_json::Value) -> Result<String> {
        let editor_params: EditorParams = serde_json::from_value(params)
            .map_err(|e| anyhow!("Invalid parameters: {}", e))?;

        let path = PathBuf::from(&editor_params.path);

        match editor_params.command.as_str() {
            "view" => self.view_file(&path, editor_params.view_range).await,
            "create" => {
                let content = editor_params.file_text
                    .ok_or_else(|| anyhow!("Missing file_text for create command"))?;
                self.create_file(&path, &content).await
            }
            "str_replace" => {
                let old_str = editor_params.old_str
                    .ok_or_else(|| anyhow!("Missing old_str for str_replace"))?;
                let new_str = editor_params.new_str
                    .ok_or_else(|| anyhow!("Missing new_str for str_replace"))?;
                self.str_replace(&path, &old_str, &new_str).await
            }
            _ => Err(anyhow!("Unknown command: {}", editor_params.command)),
        }
    }
}