syncable_cli/agent/tools/
file_ops.rs

1//! File operation tools for reading, writing, and exploring the project using Rig's Tool trait
2//!
3//! Provides tools for:
4//! - Reading files (ReadFileTool)
5//! - Writing single files (WriteFileTool) - for Dockerfiles, terraform files, etc.
6//! - Writing multiple files (WriteFilesTool) - for Terraform modules, Helm charts
7//! - Listing directories (ListDirectoryTool)
8
9use rig::completion::ToolDefinition;
10use rig::tool::Tool;
11use serde::{Deserialize, Serialize};
12use serde_json::json;
13use std::fs;
14use std::path::PathBuf;
15
16// ============================================================================
17// Read File Tool
18// ============================================================================
19
20#[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            // Ensure end_idx >= start_idx to avoid slice panic when end_line < start_line
123            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// ============================================================================
151// List Directory Tool
152// ============================================================================
153
154#[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// ============================================================================
293// Write File Tool - For writing Dockerfiles, Terraform files, Helm values, etc.
294// ============================================================================
295
296#[derive(Debug, Deserialize)]
297pub struct WriteFileArgs {
298    /// Path to the file to write (relative to project root)
299    pub path: String,
300    /// Content to write to the file
301    pub content: String,
302    /// If true, create parent directories if they don't exist (default: true)
303    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        // For new files, we can't canonicalize yet, so check the parent
331        let parent = target.parent()
332            .ok_or_else(|| WriteFileError("Invalid path: no parent directory".to_string()))?;
333
334        // If parent exists, canonicalize it; otherwise check the path prefix
335        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            // For nested new directories, check if the normalized path stays within project
341            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        // Create parent directories if needed
400        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        // Check if file exists (for reporting)
411        let file_existed = file_path.exists();
412
413        // Write the content
414        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// ============================================================================
434// Write Files Tool - For writing multiple files (Terraform modules, Helm charts)
435// ============================================================================
436
437#[derive(Debug, Deserialize)]
438pub struct FileToWrite {
439    /// Path to the file (relative to project root)
440    pub path: String,
441    /// Content to write
442    pub content: String,
443}
444
445#[derive(Debug, Deserialize)]
446pub struct WriteFilesArgs {
447    /// List of files to write
448    pub files: Vec<FileToWrite>,
449    /// If true, create parent directories if they don't exist (default: true)
450    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            // Create parent directories if needed
558            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}