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<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; 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#[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}