syncable_cli/agent/tools/
file_ops.rs1use rig::completion::ToolDefinition;
4use rig::tool::Tool;
5use serde::{Deserialize, Serialize};
6use serde_json::json;
7use std::fs;
8use std::path::PathBuf;
9
10#[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#[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}