syncable_cli/agent/tools/
file_ops.rs

1//! File operation tools for reading and exploring the project 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<u64>,
18    pub end_line: Option<u64>,
19}
20
21#[derive(Debug, thiserror::Error)]
22#[error("Read file 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: &PathBuf) -> Result<PathBuf, ReadFileError> {
36        let canonical_project = self.project_path.canonicalize()
37            .map_err(|e| ReadFileError(format!("Invalid project path: {}", e)))?;
38        
39        let target = if requested.is_absolute() {
40            requested.clone()
41        } else {
42            self.project_path.join(requested)
43        };
44
45        let canonical_target = target.canonicalize()
46            .map_err(|e| ReadFileError(format!("File not found: {}", e)))?;
47
48        if !canonical_target.starts_with(&canonical_project) {
49            return Err(ReadFileError("Access denied: path is outside project directory".to_string()));
50        }
51
52        Ok(canonical_target)
53    }
54}
55
56impl Tool for ReadFileTool {
57    const NAME: &'static str = "read_file";
58
59    type Error = ReadFileError;
60    type Args = ReadFileArgs;
61    type Output = String;
62
63    async fn definition(&self, _prompt: String) -> ToolDefinition {
64        ToolDefinition {
65            name: Self::NAME.to_string(),
66            description: "Read the contents of a file in the project. Use this to examine source code, configuration files, or any text file.".to_string(),
67            parameters: json!({
68                "type": "object",
69                "properties": {
70                    "path": {
71                        "type": "string",
72                        "description": "Path to the file to read (relative to project root)"
73                    },
74                    "start_line": {
75                        "type": "integer",
76                        "description": "Optional starting line number (1-based)"
77                    },
78                    "end_line": {
79                        "type": "integer",
80                        "description": "Optional ending line number (1-based, inclusive)"
81                    }
82                },
83                "required": ["path"]
84            }),
85        }
86    }
87
88    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
89        let requested_path = PathBuf::from(&args.path);
90        let file_path = self.validate_path(&requested_path)?;
91
92        let metadata = fs::metadata(&file_path)
93            .map_err(|e| ReadFileError(format!("Cannot read file: {}", e)))?;
94        
95        const MAX_SIZE: u64 = 1024 * 1024;
96        if metadata.len() > MAX_SIZE {
97            return Ok(json!({
98                "error": format!("File too large ({} bytes). Maximum size is {} bytes.", metadata.len(), MAX_SIZE)
99            }).to_string());
100        }
101
102        let content = fs::read_to_string(&file_path)
103            .map_err(|e| ReadFileError(format!("Failed to read file: {}", e)))?;
104
105        let output = if let Some(start) = args.start_line {
106            let lines: Vec<&str> = content.lines().collect();
107            let start_idx = (start as usize).saturating_sub(1);
108            let end_idx = args.end_line.map(|e| (e as usize).min(lines.len())).unwrap_or(lines.len());
109            
110            if start_idx >= lines.len() {
111                return Ok(json!({
112                    "error": format!("Start line {} exceeds file length ({})", start, lines.len())
113                }).to_string());
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!("Failed to serialize: {}", 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("List directory 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: &PathBuf) -> Result<PathBuf, ListDirectoryError> {
166        let canonical_project = self.project_path.canonicalize()
167            .map_err(|e| ListDirectoryError(format!("Invalid project path: {}", e)))?;
168        
169        let target = if requested.is_absolute() {
170            requested.clone()
171        } else {
172            self.project_path.join(requested)
173        };
174
175        let canonical_target = target.canonicalize()
176            .map_err(|e| ListDirectoryError(format!("Directory not found: {}", e)))?;
177
178        if !canonical_target.starts_with(&canonical_project) {
179            return Err(ListDirectoryError("Access denied: path is outside project directory".to_string()));
180        }
181
182        Ok(canonical_target)
183    }
184
185    fn list_entries(
186        &self,
187        base_path: &PathBuf,
188        current_path: &PathBuf,
189        recursive: bool,
190        depth: usize,
191        max_depth: usize,
192        entries: &mut Vec<serde_json::Value>,
193    ) -> Result<(), ListDirectoryError> {
194        let skip_dirs = ["node_modules", ".git", "target", "__pycache__", ".venv", "venv", "dist", "build"];
195        
196        let dir_name = current_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
197        
198        if depth > 0 && skip_dirs.contains(&dir_name) {
199            return Ok(());
200        }
201
202        let read_dir = fs::read_dir(current_path)
203            .map_err(|e| ListDirectoryError(format!("Cannot read directory: {}", e)))?;
204
205        for entry in read_dir {
206            let entry = entry.map_err(|e| ListDirectoryError(format!("Error reading entry: {}", e)))?;
207            let path = entry.path();
208            let metadata = entry.metadata().ok();
209            
210            let relative_path = path.strip_prefix(base_path).unwrap_or(&path).to_string_lossy().to_string();
211            let is_dir = metadata.as_ref().map(|m| m.is_dir()).unwrap_or(false);
212            let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
213
214            entries.push(json!({
215                "name": entry.file_name().to_string_lossy(),
216                "path": relative_path,
217                "type": if is_dir { "directory" } else { "file" },
218                "size": if is_dir { None::<u64> } else { Some(size) }
219            }));
220
221            if recursive && is_dir && depth < max_depth {
222                self.list_entries(base_path, &path, recursive, depth + 1, max_depth, entries)?;
223            }
224        }
225
226        Ok(())
227    }
228}
229
230impl Tool for ListDirectoryTool {
231    const NAME: &'static str = "list_directory";
232
233    type Error = ListDirectoryError;
234    type Args = ListDirectoryArgs;
235    type Output = String;
236
237    async fn definition(&self, _prompt: String) -> ToolDefinition {
238        ToolDefinition {
239            name: Self::NAME.to_string(),
240            description: "List the contents of a directory in the project. Returns file and subdirectory names with their types and sizes.".to_string(),
241            parameters: json!({
242                "type": "object",
243                "properties": {
244                    "path": {
245                        "type": "string",
246                        "description": "Path to the directory to list (relative to project root). Use '.' for root."
247                    },
248                    "recursive": {
249                        "type": "boolean",
250                        "description": "If true, list contents recursively (max depth 3). Default is false."
251                    }
252                }
253            }),
254        }
255    }
256
257    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
258        let path_str = args.path.as_deref().unwrap_or(".");
259        
260        let requested_path = if path_str.is_empty() || path_str == "." {
261            self.project_path.clone()
262        } else {
263            PathBuf::from(path_str)
264        };
265
266        let dir_path = self.validate_path(&requested_path)?;
267        let recursive = args.recursive.unwrap_or(false);
268
269        let mut entries = Vec::new();
270        self.list_entries(&dir_path, &dir_path, recursive, 0, 3, &mut entries)?;
271
272        let result = json!({
273            "path": path_str,
274            "entries": entries,
275            "total_count": entries.len()
276        });
277
278        serde_json::to_string_pretty(&result)
279            .map_err(|e| ListDirectoryError(format!("Failed to serialize: {}", e)))
280    }
281}