1use rig::completion::ToolDefinition;
10use rig::tool::Tool;
11use serde::{Deserialize, Serialize};
12use serde_json::json;
13use std::fs;
14use std::path::PathBuf;
15
16#[derive(Debug, Deserialize)]
21pub struct ReadFileArgs {
22 pub path: String,
23 pub start_line: Option<u64>,
24 pub end_line: Option<u64>,
25}
26
27#[derive(Debug, thiserror::Error)]
28#[error("Read file error: {0}")]
29pub struct ReadFileError(String);
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ReadFileTool {
33 project_path: PathBuf,
34}
35
36impl ReadFileTool {
37 pub fn new(project_path: PathBuf) -> Self {
38 Self { project_path }
39 }
40
41 fn validate_path(&self, requested: &PathBuf) -> Result<PathBuf, ReadFileError> {
42 let canonical_project = self.project_path.canonicalize()
43 .map_err(|e| ReadFileError(format!("Invalid project path: {}", e)))?;
44
45 let target = if requested.is_absolute() {
46 requested.clone()
47 } else {
48 self.project_path.join(requested)
49 };
50
51 let canonical_target = target.canonicalize()
52 .map_err(|e| ReadFileError(format!("File not found: {}", e)))?;
53
54 if !canonical_target.starts_with(&canonical_project) {
55 return Err(ReadFileError("Access denied: path is outside project directory".to_string()));
56 }
57
58 Ok(canonical_target)
59 }
60}
61
62impl Tool for ReadFileTool {
63 const NAME: &'static str = "read_file";
64
65 type Error = ReadFileError;
66 type Args = ReadFileArgs;
67 type Output = String;
68
69 async fn definition(&self, _prompt: String) -> ToolDefinition {
70 ToolDefinition {
71 name: Self::NAME.to_string(),
72 description: "Read the contents of a file in the project. Use this to examine source code, configuration files, or any text file.".to_string(),
73 parameters: json!({
74 "type": "object",
75 "properties": {
76 "path": {
77 "type": "string",
78 "description": "Path to the file to read (relative to project root)"
79 },
80 "start_line": {
81 "type": "integer",
82 "description": "Optional starting line number (1-based)"
83 },
84 "end_line": {
85 "type": "integer",
86 "description": "Optional ending line number (1-based, inclusive)"
87 }
88 },
89 "required": ["path"]
90 }),
91 }
92 }
93
94 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
95 let requested_path = PathBuf::from(&args.path);
96 let file_path = self.validate_path(&requested_path)?;
97
98 let metadata = fs::metadata(&file_path)
99 .map_err(|e| ReadFileError(format!("Cannot read file: {}", e)))?;
100
101 const MAX_SIZE: u64 = 1024 * 1024;
102 if metadata.len() > MAX_SIZE {
103 return Ok(json!({
104 "error": format!("File too large ({} bytes). Maximum size is {} bytes.", metadata.len(), MAX_SIZE)
105 }).to_string());
106 }
107
108 let content = fs::read_to_string(&file_path)
109 .map_err(|e| ReadFileError(format!("Failed to read file: {}", e)))?;
110
111 let output = if let Some(start) = args.start_line {
112 let lines: Vec<&str> = content.lines().collect();
113 let start_idx = (start as usize).saturating_sub(1);
114 let end_idx = args.end_line.map(|e| (e as usize).min(lines.len())).unwrap_or(lines.len());
115
116 if start_idx >= lines.len() {
117 return Ok(json!({
118 "error": format!("Start line {} exceeds file length ({})", start, lines.len())
119 }).to_string());
120 }
121
122 let end_idx = end_idx.max(start_idx);
124
125 let selected: Vec<String> = lines[start_idx..end_idx]
126 .iter()
127 .enumerate()
128 .map(|(i, line)| format!("{:>4} | {}", start_idx + i + 1, line))
129 .collect();
130
131 json!({
132 "file": args.path,
133 "lines": format!("{}-{}", start, end_idx),
134 "total_lines": lines.len(),
135 "content": selected.join("\n")
136 })
137 } else {
138 json!({
139 "file": args.path,
140 "total_lines": content.lines().count(),
141 "content": content
142 })
143 };
144
145 serde_json::to_string_pretty(&output)
146 .map_err(|e| ReadFileError(format!("Failed to serialize: {}", e)))
147 }
148}
149
150#[derive(Debug, Deserialize)]
155pub struct ListDirectoryArgs {
156 pub path: Option<String>,
157 pub recursive: Option<bool>,
158}
159
160#[derive(Debug, thiserror::Error)]
161#[error("List directory error: {0}")]
162pub struct ListDirectoryError(String);
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct ListDirectoryTool {
166 project_path: PathBuf,
167}
168
169impl ListDirectoryTool {
170 pub fn new(project_path: PathBuf) -> Self {
171 Self { project_path }
172 }
173
174 fn validate_path(&self, requested: &PathBuf) -> Result<PathBuf, ListDirectoryError> {
175 let canonical_project = self.project_path.canonicalize()
176 .map_err(|e| ListDirectoryError(format!("Invalid project path: {}", e)))?;
177
178 let target = if requested.is_absolute() {
179 requested.clone()
180 } else {
181 self.project_path.join(requested)
182 };
183
184 let canonical_target = target.canonicalize()
185 .map_err(|e| ListDirectoryError(format!("Directory not found: {}", e)))?;
186
187 if !canonical_target.starts_with(&canonical_project) {
188 return Err(ListDirectoryError("Access denied: path is outside project directory".to_string()));
189 }
190
191 Ok(canonical_target)
192 }
193
194 fn list_entries(
195 &self,
196 base_path: &PathBuf,
197 current_path: &PathBuf,
198 recursive: bool,
199 depth: usize,
200 max_depth: usize,
201 entries: &mut Vec<serde_json::Value>,
202 ) -> Result<(), ListDirectoryError> {
203 let skip_dirs = ["node_modules", ".git", "target", "__pycache__", ".venv", "venv", "dist", "build"];
204
205 let dir_name = current_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
206
207 if depth > 0 && skip_dirs.contains(&dir_name) {
208 return Ok(());
209 }
210
211 let read_dir = fs::read_dir(current_path)
212 .map_err(|e| ListDirectoryError(format!("Cannot read directory: {}", e)))?;
213
214 for entry in read_dir {
215 let entry = entry.map_err(|e| ListDirectoryError(format!("Error reading entry: {}", e)))?;
216 let path = entry.path();
217 let metadata = entry.metadata().ok();
218
219 let relative_path = path.strip_prefix(base_path).unwrap_or(&path).to_string_lossy().to_string();
220 let is_dir = metadata.as_ref().map(|m| m.is_dir()).unwrap_or(false);
221 let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
222
223 entries.push(json!({
224 "name": entry.file_name().to_string_lossy(),
225 "path": relative_path,
226 "type": if is_dir { "directory" } else { "file" },
227 "size": if is_dir { None::<u64> } else { Some(size) }
228 }));
229
230 if recursive && is_dir && depth < max_depth {
231 self.list_entries(base_path, &path, recursive, depth + 1, max_depth, entries)?;
232 }
233 }
234
235 Ok(())
236 }
237}
238
239impl Tool for ListDirectoryTool {
240 const NAME: &'static str = "list_directory";
241
242 type Error = ListDirectoryError;
243 type Args = ListDirectoryArgs;
244 type Output = String;
245
246 async fn definition(&self, _prompt: String) -> ToolDefinition {
247 ToolDefinition {
248 name: Self::NAME.to_string(),
249 description: "List the contents of a directory in the project. Returns file and subdirectory names with their types and sizes.".to_string(),
250 parameters: json!({
251 "type": "object",
252 "properties": {
253 "path": {
254 "type": "string",
255 "description": "Path to the directory to list (relative to project root). Use '.' for root."
256 },
257 "recursive": {
258 "type": "boolean",
259 "description": "If true, list contents recursively (max depth 3). Default is false."
260 }
261 }
262 }),
263 }
264 }
265
266 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
267 let path_str = args.path.as_deref().unwrap_or(".");
268
269 let requested_path = if path_str.is_empty() || path_str == "." {
270 self.project_path.clone()
271 } else {
272 PathBuf::from(path_str)
273 };
274
275 let dir_path = self.validate_path(&requested_path)?;
276 let recursive = args.recursive.unwrap_or(false);
277
278 let mut entries = Vec::new();
279 self.list_entries(&dir_path, &dir_path, recursive, 0, 3, &mut entries)?;
280
281 let result = json!({
282 "path": path_str,
283 "entries": entries,
284 "total_count": entries.len()
285 });
286
287 serde_json::to_string_pretty(&result)
288 .map_err(|e| ListDirectoryError(format!("Failed to serialize: {}", e)))
289 }
290}
291
292#[derive(Debug, Deserialize)]
297pub struct WriteFileArgs {
298 pub path: String,
300 pub content: String,
302 pub create_dirs: Option<bool>,
304}
305
306#[derive(Debug, thiserror::Error)]
307#[error("Write file error: {0}")]
308pub struct WriteFileError(String);
309
310#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct WriteFileTool {
312 project_path: PathBuf,
313}
314
315impl WriteFileTool {
316 pub fn new(project_path: PathBuf) -> Self {
317 Self { project_path }
318 }
319
320 fn validate_path(&self, requested: &PathBuf) -> Result<PathBuf, WriteFileError> {
321 let canonical_project = self.project_path.canonicalize()
322 .map_err(|e| WriteFileError(format!("Invalid project path: {}", e)))?;
323
324 let target = if requested.is_absolute() {
325 requested.clone()
326 } else {
327 self.project_path.join(requested)
328 };
329
330 let parent = target.parent()
332 .ok_or_else(|| WriteFileError("Invalid path: no parent directory".to_string()))?;
333
334 let is_within_project = if parent.exists() {
336 let canonical_parent = parent.canonicalize()
337 .map_err(|e| WriteFileError(format!("Invalid parent path: {}", e)))?;
338 canonical_parent.starts_with(&canonical_project)
339 } else {
340 let normalized = self.project_path.join(requested);
342 !normalized.components().any(|c| c == std::path::Component::ParentDir)
343 };
344
345 if !is_within_project {
346 return Err(WriteFileError("Access denied: path is outside project directory".to_string()));
347 }
348
349 Ok(target)
350 }
351}
352
353impl Tool for WriteFileTool {
354 const NAME: &'static str = "write_file";
355
356 type Error = WriteFileError;
357 type Args = WriteFileArgs;
358 type Output = String;
359
360 async fn definition(&self, _prompt: String) -> ToolDefinition {
361 ToolDefinition {
362 name: Self::NAME.to_string(),
363 description: r#"Write content to a file in the project. Creates the file if it doesn't exist, or overwrites if it does.
364
365Use this tool to:
366- Generate Dockerfiles for applications
367- Create Terraform configuration files (.tf)
368- Write Helm chart templates and values
369- Create docker-compose.yml files
370- Generate CI/CD configuration files (.github/workflows, .gitlab-ci.yml)
371- Write Kubernetes manifests
372
373The tool will create parent directories automatically if they don't exist."#.to_string(),
374 parameters: json!({
375 "type": "object",
376 "properties": {
377 "path": {
378 "type": "string",
379 "description": "Path to the file to write (relative to project root). Example: 'Dockerfile', 'terraform/main.tf', 'helm/values.yaml'"
380 },
381 "content": {
382 "type": "string",
383 "description": "The complete content to write to the file"
384 },
385 "create_dirs": {
386 "type": "boolean",
387 "description": "If true (default), create parent directories if they don't exist"
388 }
389 },
390 "required": ["path", "content"]
391 }),
392 }
393 }
394
395 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
396 let requested_path = PathBuf::from(&args.path);
397 let file_path = self.validate_path(&requested_path)?;
398
399 let create_dirs = args.create_dirs.unwrap_or(true);
401 if create_dirs {
402 if let Some(parent) = file_path.parent() {
403 if !parent.exists() {
404 fs::create_dir_all(parent)
405 .map_err(|e| WriteFileError(format!("Failed to create directories: {}", e)))?;
406 }
407 }
408 }
409
410 let file_existed = file_path.exists();
412
413 fs::write(&file_path, &args.content)
415 .map_err(|e| WriteFileError(format!("Failed to write file: {}", e)))?;
416
417 let action = if file_existed { "Updated" } else { "Created" };
418 let lines = args.content.lines().count();
419
420 let result = json!({
421 "success": true,
422 "action": action,
423 "path": args.path,
424 "lines_written": lines,
425 "bytes_written": args.content.len()
426 });
427
428 serde_json::to_string_pretty(&result)
429 .map_err(|e| WriteFileError(format!("Failed to serialize: {}", e)))
430 }
431}
432
433#[derive(Debug, Deserialize)]
438pub struct FileToWrite {
439 pub path: String,
441 pub content: String,
443}
444
445#[derive(Debug, Deserialize)]
446pub struct WriteFilesArgs {
447 pub files: Vec<FileToWrite>,
449 pub create_dirs: Option<bool>,
451}
452
453#[derive(Debug, thiserror::Error)]
454#[error("Write files error: {0}")]
455pub struct WriteFilesError(String);
456
457#[derive(Debug, Clone, Serialize, Deserialize)]
458pub struct WriteFilesTool {
459 project_path: PathBuf,
460}
461
462impl WriteFilesTool {
463 pub fn new(project_path: PathBuf) -> Self {
464 Self { project_path }
465 }
466
467 fn validate_path(&self, requested: &PathBuf) -> Result<PathBuf, WriteFilesError> {
468 let canonical_project = self.project_path.canonicalize()
469 .map_err(|e| WriteFilesError(format!("Invalid project path: {}", e)))?;
470
471 let target = if requested.is_absolute() {
472 requested.clone()
473 } else {
474 self.project_path.join(requested)
475 };
476
477 let parent = target.parent()
478 .ok_or_else(|| WriteFilesError("Invalid path: no parent directory".to_string()))?;
479
480 let is_within_project = if parent.exists() {
481 let canonical_parent = parent.canonicalize()
482 .map_err(|e| WriteFilesError(format!("Invalid parent path: {}", e)))?;
483 canonical_parent.starts_with(&canonical_project)
484 } else {
485 let normalized = self.project_path.join(requested);
486 !normalized.components().any(|c| c == std::path::Component::ParentDir)
487 };
488
489 if !is_within_project {
490 return Err(WriteFilesError("Access denied: path is outside project directory".to_string()));
491 }
492
493 Ok(target)
494 }
495}
496
497impl Tool for WriteFilesTool {
498 const NAME: &'static str = "write_files";
499
500 type Error = WriteFilesError;
501 type Args = WriteFilesArgs;
502 type Output = String;
503
504 async fn definition(&self, _prompt: String) -> ToolDefinition {
505 ToolDefinition {
506 name: Self::NAME.to_string(),
507 description: r#"Write multiple files at once. Ideal for creating complete infrastructure configurations.
508
509Use this tool when you need to create multiple related files together:
510- Complete Terraform modules (main.tf, variables.tf, outputs.tf, providers.tf)
511- Full Helm charts (Chart.yaml, values.yaml, templates/*.yaml)
512- Kubernetes manifests (deployment.yaml, service.yaml, configmap.yaml)
513- Multi-file docker-compose setups
514
515All files are written atomically - if any file fails, previously written files in the batch remain."#.to_string(),
516 parameters: json!({
517 "type": "object",
518 "properties": {
519 "files": {
520 "type": "array",
521 "description": "List of files to write",
522 "items": {
523 "type": "object",
524 "properties": {
525 "path": {
526 "type": "string",
527 "description": "Path to the file (relative to project root)"
528 },
529 "content": {
530 "type": "string",
531 "description": "Content to write to the file"
532 }
533 },
534 "required": ["path", "content"]
535 }
536 },
537 "create_dirs": {
538 "type": "boolean",
539 "description": "If true (default), create parent directories if they don't exist"
540 }
541 },
542 "required": ["files"]
543 }),
544 }
545 }
546
547 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
548 let create_dirs = args.create_dirs.unwrap_or(true);
549 let mut results = Vec::new();
550 let mut total_bytes = 0usize;
551 let mut total_lines = 0usize;
552
553 for file in &args.files {
554 let requested_path = PathBuf::from(&file.path);
555 let file_path = self.validate_path(&requested_path)?;
556
557 if create_dirs {
559 if let Some(parent) = file_path.parent() {
560 if !parent.exists() {
561 fs::create_dir_all(parent)
562 .map_err(|e| WriteFilesError(format!("Failed to create directories for {}: {}", file.path, e)))?;
563 }
564 }
565 }
566
567 let file_existed = file_path.exists();
568
569 fs::write(&file_path, &file.content)
570 .map_err(|e| WriteFilesError(format!("Failed to write {}: {}", file.path, e)))?;
571
572 let lines = file.content.lines().count();
573 total_bytes += file.content.len();
574 total_lines += lines;
575
576 results.push(json!({
577 "path": file.path,
578 "action": if file_existed { "updated" } else { "created" },
579 "lines": lines,
580 "bytes": file.content.len()
581 }));
582 }
583
584 let result = json!({
585 "success": true,
586 "files_written": results.len(),
587 "total_lines": total_lines,
588 "total_bytes": total_bytes,
589 "files": results
590 });
591
592 serde_json::to_string_pretty(&result)
593 .map_err(|e| WriteFilesError(format!("Failed to serialize: {}", e)))
594 }
595}