syncable_cli/agent/tools/
file_ops.rs

1//! File operation tools using Rig's Tool trait
2
3use rig::completion::ToolDefinition;
4use rig::tool::Tool;
5use serde::{Deserialize, Serialize};
6use serde_json::json;
7use std::fs;
8use std::path::PathBuf;
9
10// ============================================================================
11// Read File Tool
12// ============================================================================
13
14#[derive(Debug, Deserialize)]
15pub struct ReadFileArgs {
16    pub path: String,
17    pub start_line: Option<usize>,
18    pub end_line: Option<usize>,
19}
20
21#[derive(Debug, thiserror::Error)]
22#[error("File read error: {0}")]
23pub struct ReadFileError(String);
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ReadFileTool {
27    project_path: PathBuf,
28}
29
30impl ReadFileTool {
31    pub fn new(project_path: PathBuf) -> Self {
32        Self { project_path }
33    }
34
35    fn validate_path(&self, requested: &str) -> Result<PathBuf, ReadFileError> {
36        let canonical_project = self.project_path
37            .canonicalize()
38            .map_err(|e| ReadFileError(format!("Invalid project path: {}", e)))?;
39
40        let target = self.project_path.join(requested);
41        let canonical_target = target
42            .canonicalize()
43            .map_err(|e| ReadFileError(format!("File not found: {}", e)))?;
44
45        if !canonical_target.starts_with(&canonical_project) {
46            return Err(ReadFileError("Access denied: path is outside project".to_string()));
47        }
48
49        Ok(canonical_target)
50    }
51}
52
53impl Tool for ReadFileTool {
54    const NAME: &'static str = "read_file";
55
56    type Error = ReadFileError;
57    type Args = ReadFileArgs;
58    type Output = String;
59
60    async fn definition(&self, _prompt: String) -> ToolDefinition {
61        ToolDefinition {
62            name: Self::NAME.to_string(),
63            description: "Read the contents of a file in the project.".to_string(),
64            parameters: json!({
65                "type": "object",
66                "properties": {
67                    "path": {
68                        "type": "string",
69                        "description": "Path to the file (relative to project root)"
70                    },
71                    "start_line": {
72                        "type": "integer",
73                        "description": "Optional starting line number (1-based)"
74                    },
75                    "end_line": {
76                        "type": "integer",
77                        "description": "Optional ending line number (inclusive)"
78                    }
79                },
80                "required": ["path"]
81            }),
82        }
83    }
84
85    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
86        let file_path = self.validate_path(&args.path)?;
87
88        let metadata = fs::metadata(&file_path)
89            .map_err(|e| ReadFileError(format!("Cannot read file: {}", e)))?;
90
91        const MAX_SIZE: u64 = 1024 * 1024; // 1MB
92        if metadata.len() > MAX_SIZE {
93            return Err(ReadFileError(format!(
94                "File too large ({} bytes). Max: {} bytes.",
95                metadata.len(),
96                MAX_SIZE
97            )));
98        }
99
100        let content = fs::read_to_string(&file_path)
101            .map_err(|e| ReadFileError(format!("Failed to read: {}", e)))?;
102
103        let output = if let Some(start) = args.start_line {
104            let lines: Vec<&str> = content.lines().collect();
105            let start_idx = start.saturating_sub(1);
106            let end_idx = args.end_line.map(|e| e.min(lines.len())).unwrap_or(lines.len());
107
108            if start_idx >= lines.len() {
109                return Err(ReadFileError(format!(
110                    "Start line {} exceeds file length ({})",
111                    start,
112                    lines.len()
113                )));
114            }
115
116            let selected: Vec<String> = lines[start_idx..end_idx]
117                .iter()
118                .enumerate()
119                .map(|(i, line)| format!("{:>4} | {}", start_idx + i + 1, line))
120                .collect();
121
122            json!({
123                "file": args.path,
124                "lines": format!("{}-{}", start, end_idx),
125                "total_lines": lines.len(),
126                "content": selected.join("\n")
127            })
128        } else {
129            json!({
130                "file": args.path,
131                "total_lines": content.lines().count(),
132                "content": content
133            })
134        };
135
136        serde_json::to_string_pretty(&output)
137            .map_err(|e| ReadFileError(format!("Serialization error: {}", e)))
138    }
139}
140
141// ============================================================================
142// List Directory Tool
143// ============================================================================
144
145#[derive(Debug, Deserialize)]
146pub struct ListDirectoryArgs {
147    pub path: Option<String>,
148    pub recursive: Option<bool>,
149}
150
151#[derive(Debug, thiserror::Error)]
152#[error("Directory list error: {0}")]
153pub struct ListDirectoryError(String);
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct ListDirectoryTool {
157    project_path: PathBuf,
158}
159
160impl ListDirectoryTool {
161    pub fn new(project_path: PathBuf) -> Self {
162        Self { project_path }
163    }
164
165    fn validate_path(&self, requested: &str) -> Result<PathBuf, ListDirectoryError> {
166        let canonical_project = self.project_path
167            .canonicalize()
168            .map_err(|e| ListDirectoryError(format!("Invalid project path: {}", e)))?;
169
170        let target = if requested.is_empty() || requested == "." {
171            self.project_path.clone()
172        } else {
173            self.project_path.join(requested)
174        };
175
176        let canonical_target = target
177            .canonicalize()
178            .map_err(|e| ListDirectoryError(format!("Directory not found: {}", e)))?;
179
180        if !canonical_target.starts_with(&canonical_project) {
181            return Err(ListDirectoryError("Access denied: path is outside project".to_string()));
182        }
183
184        Ok(canonical_target)
185    }
186
187    fn list_entries(
188        &self,
189        base_path: &PathBuf,
190        current_path: &PathBuf,
191        recursive: bool,
192        depth: usize,
193        max_depth: usize,
194        entries: &mut Vec<serde_json::Value>,
195    ) -> Result<(), ListDirectoryError> {
196        let skip_dirs = ["node_modules", ".git", "target", "__pycache__", ".venv", "dist", "build"];
197
198        let dir_name = current_path
199            .file_name()
200            .and_then(|n| n.to_str())
201            .unwrap_or("");
202
203        if depth > 0 && skip_dirs.contains(&dir_name) {
204            return Ok(());
205        }
206
207        let read_dir = fs::read_dir(current_path)
208            .map_err(|e| ListDirectoryError(format!("Cannot read directory: {}", e)))?;
209
210        for entry in read_dir {
211            let entry = entry.map_err(|e| ListDirectoryError(format!("Error reading entry: {}", e)))?;
212            let path = entry.path();
213            let metadata = entry.metadata().ok();
214
215            let relative_path = path
216                .strip_prefix(base_path)
217                .unwrap_or(&path)
218                .to_string_lossy()
219                .to_string();
220
221            let is_dir = metadata.as_ref().map(|m| m.is_dir()).unwrap_or(false);
222            let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
223
224            entries.push(json!({
225                "name": entry.file_name().to_string_lossy(),
226                "path": relative_path,
227                "type": if is_dir { "directory" } else { "file" },
228                "size": if is_dir { serde_json::Value::Null } else { json!(size) }
229            }));
230
231            if recursive && is_dir && depth < max_depth {
232                self.list_entries(base_path, &path, recursive, depth + 1, max_depth, entries)?;
233            }
234        }
235
236        Ok(())
237    }
238}
239
240impl Tool for ListDirectoryTool {
241    const NAME: &'static str = "list_directory";
242
243    type Error = ListDirectoryError;
244    type Args = ListDirectoryArgs;
245    type Output = String;
246
247    async fn definition(&self, _prompt: String) -> ToolDefinition {
248        ToolDefinition {
249            name: Self::NAME.to_string(),
250            description: "List the contents of a directory in the project.".to_string(),
251            parameters: json!({
252                "type": "object",
253                "properties": {
254                    "path": {
255                        "type": "string",
256                        "description": "Path to directory (relative to project root). Use '.' for project root."
257                    },
258                    "recursive": {
259                        "type": "boolean",
260                        "description": "If true, list contents recursively (max depth 3)"
261                    }
262                }
263            }),
264        }
265    }
266
267    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
268        let path_str = args.path.as_deref().unwrap_or(".");
269        let dir_path = self.validate_path(path_str)?;
270        let recursive = args.recursive.unwrap_or(false);
271
272        let mut entries = Vec::new();
273        self.list_entries(&dir_path, &dir_path, recursive, 0, 3, &mut entries)?;
274
275        let result = json!({
276            "path": path_str,
277            "entries": entries,
278            "total_count": entries.len()
279        });
280
281        serde_json::to_string_pretty(&result)
282            .map_err(|e| ListDirectoryError(format!("Serialization error: {}", e)))
283    }
284}