Skip to main content

rucora_tools/file/
edit.rs

1//! 文件编辑工具
2//!
3//! 通过精确字符串替换编辑文件内容
4
5use async_trait::async_trait;
6use rucora_core::{
7    error::ToolError,
8    tool::{Tool, ToolCategory},
9};
10use serde_json::{Value, json};
11use std::path::PathBuf;
12
13use super::FileToolConfig;
14
15/// 文件编辑工具:通过精确替换编辑文件。
16///
17/// 使用 old_string → new_string 的精确替换方式来编辑文件内容。
18/// old_string 必须在文件中精确匹配出现一次(0 次=未找到,多次=歧义)。
19/// new_string 可以为空以删除匹配的文本。
20///
21/// 安全限制:
22/// - 仅允许编辑白名单扩展名的文件
23/// - 禁止访问系统敏感路径
24/// - 支持配置允许的工作目录
25///
26/// 输入格式:
27/// ```json
28/// {
29///   "path": "文件路径",
30///   "old_string": "要替换的原文",
31///   "new_string": "新文本"
32/// }
33/// ```
34pub struct FileEditTool {
35    config: FileToolConfig,
36}
37
38impl FileEditTool {
39    /// 创建一个新的 FileEditTool 实例。
40    pub fn new() -> Self {
41        Self {
42            config: FileToolConfig::new(),
43        }
44    }
45
46    /// 设置允许的工作目录
47    pub fn with_allowed_dirs(self, dirs: Vec<PathBuf>) -> Self {
48        Self {
49            config: self.config.with_allowed_dirs(dirs),
50        }
51    }
52
53    /// 设置最大文件大小
54    pub fn with_max_file_size(self, size: u64) -> Self {
55        Self {
56            config: self.config.with_max_file_size(size),
57        }
58    }
59}
60
61impl Default for FileEditTool {
62    fn default() -> Self {
63        Self::new()
64    }
65}
66
67#[async_trait]
68impl Tool for FileEditTool {
69    /// 返回工具名称。
70    fn name(&self) -> &str {
71        "file_edit"
72    }
73
74    /// 返回工具描述。
75    fn description(&self) -> Option<&str> {
76        Some("通过精确字符串替换编辑文件内容(有安全限制)")
77    }
78
79    /// 返回工具分类。
80    fn categories(&self) -> &'static [ToolCategory] {
81        &[ToolCategory::File]
82    }
83
84    /// 返回输入参数的 JSON Schema。
85    fn input_schema(&self) -> Value {
86        json!({
87            "type": "object",
88            "properties": {
89                "path": {
90                    "type": "string",
91                    "description": "文件路径"
92                },
93                "old_string": {
94                    "type": "string",
95                    "description": "要查找并替换的精确文本(必须在文件中精确出现一次)"
96                },
97                "new_string": {
98                    "type": "string",
99                    "description": "替换后的文本(空字符串表示删除)"
100                }
101            },
102            "required": ["path", "old_string", "new_string"]
103        })
104    }
105
106    /// 执行文件编辑。
107    async fn call(&self, input: Value) -> Result<Value, ToolError> {
108        let path_str = input
109            .get("path")
110            .and_then(|v| v.as_str())
111            .ok_or_else(|| ToolError::Message("缺少必需的 'path' 字段".to_string()))?;
112
113        let old_string = input
114            .get("old_string")
115            .and_then(|v| v.as_str())
116            .ok_or_else(|| ToolError::Message("缺少必需的 'old_string' 字段".to_string()))?;
117
118        let new_string = input
119            .get("new_string")
120            .and_then(|v| v.as_str())
121            .ok_or_else(|| ToolError::Message("缺少必需的 'new_string' 字段".to_string()))?;
122
123        if old_string.is_empty() {
124            return Err(ToolError::Message("old_string 不能为空".to_string()));
125        }
126
127        let path = self.config.validate_path_for_read(path_str)?;
128
129        // 读取文件内容
130        let content = tokio::fs::read_to_string(&path)
131            .await
132            .map_err(|e| ToolError::Message(format!("读取文件失败:{e}")))?;
133
134        // 检查匹配次数
135        let matches = content.matches(old_string).count();
136        if matches == 0 {
137            return Err(ToolError::Message(format!("未找到匹配文本:{old_string}")));
138        }
139        if matches > 1 {
140            return Err(ToolError::Message(format!(
141                "找到 {matches} 处匹配,匹配歧义。请提供更精确的唯一匹配文本"
142            )));
143        }
144
145        // 执行替换
146        let new_content = content.replacen(old_string, new_string, 1);
147
148        // 检查新内容大小
149        self.config
150            .check_file_size(new_content.len() as u64, "编辑后文件")?;
151
152        // 写回文件
153        tokio::fs::write(&path, new_content)
154            .await
155            .map_err(|e| ToolError::Message(format!("写入文件失败:{e}")))?;
156
157        Ok(json!({
158            "success": true,
159            "path": path_str,
160            "replacements": 1
161        }))
162    }
163}