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//!
9//! File write operations include interactive diff confirmation before applying changes.
10//!
11//! ## Truncation Limits
12//!
13//! Tool outputs are truncated to prevent context overflow:
14//! - File reads: Max 2000 lines (use start_line/end_line for specific sections)
15//! - Directory listings: Max 500 entries
16//! - Long lines: Truncated at 2000 characters
17
18use crate::agent::ide::IdeClient;
19use crate::agent::ui::confirmation::ConfirmationResult;
20use crate::agent::ui::diff::{confirm_file_write, confirm_file_write_with_ide};
21use super::truncation::{truncate_file_content, truncate_dir_listing, TruncationLimits};
22use rig::completion::ToolDefinition;
23use rig::tool::Tool;
24use serde::{Deserialize, Serialize};
25use serde_json::json;
26use std::collections::HashSet;
27use std::fs;
28use std::path::PathBuf;
29use std::sync::Mutex;
30
31// ============================================================================
32// Read File Tool
33// ============================================================================
34
35#[derive(Debug, Deserialize)]
36pub struct ReadFileArgs {
37    pub path: String,
38    pub start_line: Option<u64>,
39    pub end_line: Option<u64>,
40}
41
42#[derive(Debug, thiserror::Error)]
43#[error("Read file error: {0}")]
44pub struct ReadFileError(String);
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct ReadFileTool {
48    project_path: PathBuf,
49}
50
51impl ReadFileTool {
52    pub fn new(project_path: PathBuf) -> Self {
53        Self { project_path }
54    }
55
56    fn validate_path(&self, requested: &PathBuf) -> Result<PathBuf, ReadFileError> {
57        let canonical_project = self.project_path.canonicalize()
58            .map_err(|e| ReadFileError(format!("Invalid project path: {}", e)))?;
59        
60        let target = if requested.is_absolute() {
61            requested.clone()
62        } else {
63            self.project_path.join(requested)
64        };
65
66        let canonical_target = target.canonicalize()
67            .map_err(|e| ReadFileError(format!("File not found: {}", e)))?;
68
69        if !canonical_target.starts_with(&canonical_project) {
70            return Err(ReadFileError("Access denied: path is outside project directory".to_string()));
71        }
72
73        Ok(canonical_target)
74    }
75}
76
77impl Tool for ReadFileTool {
78    const NAME: &'static str = "read_file";
79
80    type Error = ReadFileError;
81    type Args = ReadFileArgs;
82    type Output = String;
83
84    async fn definition(&self, _prompt: String) -> ToolDefinition {
85        ToolDefinition {
86            name: Self::NAME.to_string(),
87            description: "Read the contents of a file in the project. Use this to examine source code, configuration files, or any text file.".to_string(),
88            parameters: json!({
89                "type": "object",
90                "properties": {
91                    "path": {
92                        "type": "string",
93                        "description": "Path to the file to read (relative to project root)"
94                    },
95                    "start_line": {
96                        "type": "integer",
97                        "description": "Optional starting line number (1-based)"
98                    },
99                    "end_line": {
100                        "type": "integer",
101                        "description": "Optional ending line number (1-based, inclusive)"
102                    }
103                },
104                "required": ["path"]
105            }),
106        }
107    }
108
109    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
110        let requested_path = PathBuf::from(&args.path);
111        let file_path = self.validate_path(&requested_path)?;
112
113        let metadata = fs::metadata(&file_path)
114            .map_err(|e| ReadFileError(format!("Cannot read file: {}", e)))?;
115
116        const MAX_SIZE: u64 = 1024 * 1024;
117        if metadata.len() > MAX_SIZE {
118            return Ok(json!({
119                "error": format!("File too large ({} bytes). Maximum size is {} bytes.", metadata.len(), MAX_SIZE)
120            }).to_string());
121        }
122
123        let content = fs::read_to_string(&file_path)
124            .map_err(|e| ReadFileError(format!("Failed to read file: {}", e)))?;
125
126        let output = if let Some(start) = args.start_line {
127            // User requested specific line range - respect it exactly
128            let lines: Vec<&str> = content.lines().collect();
129            let start_idx = (start as usize).saturating_sub(1);
130            let end_idx = args.end_line.map(|e| (e as usize).min(lines.len())).unwrap_or(lines.len());
131
132            if start_idx >= lines.len() {
133                return Ok(json!({
134                    "error": format!("Start line {} exceeds file length ({})", start, lines.len())
135                }).to_string());
136            }
137
138            // Ensure end_idx >= start_idx to avoid slice panic when end_line < start_line
139            let end_idx = end_idx.max(start_idx);
140
141            let selected: Vec<String> = lines[start_idx..end_idx]
142                .iter()
143                .enumerate()
144                .map(|(i, line)| format!("{:>4} | {}", start_idx + i + 1, line))
145                .collect();
146
147            json!({
148                "file": args.path,
149                "lines": format!("{}-{}", start, end_idx),
150                "total_lines": lines.len(),
151                "content": selected.join("\n")
152            })
153        } else {
154            // Full file read - apply truncation to prevent context overflow
155            let limits = TruncationLimits::default();
156            let truncated = truncate_file_content(&content, &limits);
157
158            json!({
159                "file": args.path,
160                "total_lines": truncated.total_lines,
161                "lines_returned": truncated.returned_lines,
162                "truncated": truncated.was_truncated,
163                "content": truncated.content
164            })
165        };
166
167        serde_json::to_string_pretty(&output)
168            .map_err(|e| ReadFileError(format!("Failed to serialize: {}", e)))
169    }
170}
171
172// ============================================================================
173// List Directory Tool
174// ============================================================================
175
176#[derive(Debug, Deserialize)]
177pub struct ListDirectoryArgs {
178    pub path: Option<String>,
179    pub recursive: Option<bool>,
180}
181
182#[derive(Debug, thiserror::Error)]
183#[error("List directory error: {0}")]
184pub struct ListDirectoryError(String);
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct ListDirectoryTool {
188    project_path: PathBuf,
189}
190
191impl ListDirectoryTool {
192    pub fn new(project_path: PathBuf) -> Self {
193        Self { project_path }
194    }
195
196    fn validate_path(&self, requested: &PathBuf) -> Result<PathBuf, ListDirectoryError> {
197        let canonical_project = self.project_path.canonicalize()
198            .map_err(|e| ListDirectoryError(format!("Invalid project path: {}", e)))?;
199        
200        let target = if requested.is_absolute() {
201            requested.clone()
202        } else {
203            self.project_path.join(requested)
204        };
205
206        let canonical_target = target.canonicalize()
207            .map_err(|e| ListDirectoryError(format!("Directory not found: {}", e)))?;
208
209        if !canonical_target.starts_with(&canonical_project) {
210            return Err(ListDirectoryError("Access denied: path is outside project directory".to_string()));
211        }
212
213        Ok(canonical_target)
214    }
215
216    fn list_entries(
217        &self,
218        base_path: &PathBuf,
219        current_path: &PathBuf,
220        recursive: bool,
221        depth: usize,
222        max_depth: usize,
223        entries: &mut Vec<serde_json::Value>,
224    ) -> Result<(), ListDirectoryError> {
225        let skip_dirs = ["node_modules", ".git", "target", "__pycache__", ".venv", "venv", "dist", "build"];
226        
227        let dir_name = current_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
228        
229        if depth > 0 && skip_dirs.contains(&dir_name) {
230            return Ok(());
231        }
232
233        let read_dir = fs::read_dir(current_path)
234            .map_err(|e| ListDirectoryError(format!("Cannot read directory: {}", e)))?;
235
236        for entry in read_dir {
237            let entry = entry.map_err(|e| ListDirectoryError(format!("Error reading entry: {}", e)))?;
238            let path = entry.path();
239            let metadata = entry.metadata().ok();
240            
241            let relative_path = path.strip_prefix(base_path).unwrap_or(&path).to_string_lossy().to_string();
242            let is_dir = metadata.as_ref().map(|m| m.is_dir()).unwrap_or(false);
243            let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
244
245            entries.push(json!({
246                "name": entry.file_name().to_string_lossy(),
247                "path": relative_path,
248                "type": if is_dir { "directory" } else { "file" },
249                "size": if is_dir { None::<u64> } else { Some(size) }
250            }));
251
252            if recursive && is_dir && depth < max_depth {
253                self.list_entries(base_path, &path, recursive, depth + 1, max_depth, entries)?;
254            }
255        }
256
257        Ok(())
258    }
259}
260
261impl Tool for ListDirectoryTool {
262    const NAME: &'static str = "list_directory";
263
264    type Error = ListDirectoryError;
265    type Args = ListDirectoryArgs;
266    type Output = String;
267
268    async fn definition(&self, _prompt: String) -> ToolDefinition {
269        ToolDefinition {
270            name: Self::NAME.to_string(),
271            description: "List the contents of a directory in the project. Returns file and subdirectory names with their types and sizes.".to_string(),
272            parameters: json!({
273                "type": "object",
274                "properties": {
275                    "path": {
276                        "type": "string",
277                        "description": "Path to the directory to list (relative to project root). Use '.' for root."
278                    },
279                    "recursive": {
280                        "type": "boolean",
281                        "description": "If true, list contents recursively (max depth 3). Default is false."
282                    }
283                }
284            }),
285        }
286    }
287
288    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
289        let path_str = args.path.as_deref().unwrap_or(".");
290
291        let requested_path = if path_str.is_empty() || path_str == "." {
292            self.project_path.clone()
293        } else {
294            PathBuf::from(path_str)
295        };
296
297        let dir_path = self.validate_path(&requested_path)?;
298        let recursive = args.recursive.unwrap_or(false);
299
300        let mut entries = Vec::new();
301        self.list_entries(&dir_path, &dir_path, recursive, 0, 3, &mut entries)?;
302
303        // Apply truncation to prevent context overflow
304        let limits = TruncationLimits::default();
305        let truncated = truncate_dir_listing(entries, limits.max_dir_entries);
306
307        let result = if truncated.was_truncated {
308            json!({
309                "path": path_str,
310                "entries": truncated.entries,
311                "entries_returned": truncated.entries.len(),
312                "total_count": truncated.total_entries,
313                "truncated": true,
314                "note": format!("Showing first {} of {} entries. Use a more specific path to see others.", truncated.entries.len(), truncated.total_entries)
315            })
316        } else {
317            json!({
318                "path": path_str,
319                "entries": truncated.entries,
320                "total_count": truncated.total_entries
321            })
322        };
323
324        serde_json::to_string_pretty(&result)
325            .map_err(|e| ListDirectoryError(format!("Failed to serialize: {}", e)))
326    }
327}
328
329// ============================================================================
330// Write File Tool - For writing Dockerfiles, Terraform files, Helm values, etc.
331// ============================================================================
332
333#[derive(Debug, Deserialize)]
334pub struct WriteFileArgs {
335    /// Path to the file to write (relative to project root)
336    pub path: String,
337    /// Content to write to the file
338    pub content: String,
339    /// If true, create parent directories if they don't exist (default: true)
340    pub create_dirs: Option<bool>,
341}
342
343#[derive(Debug, thiserror::Error)]
344#[error("Write file error: {0}")]
345pub struct WriteFileError(String);
346
347/// Session-level tracking of always-allowed file patterns
348#[derive(Debug)]
349pub struct AllowedFilePatterns {
350    patterns: Mutex<HashSet<String>>,
351}
352
353impl AllowedFilePatterns {
354    pub fn new() -> Self {
355        Self {
356            patterns: Mutex::new(HashSet::new()),
357        }
358    }
359
360    /// Check if a file pattern is already allowed
361    pub fn is_allowed(&self, filename: &str) -> bool {
362        let patterns = self.patterns.lock().unwrap();
363        patterns.contains(filename)
364    }
365
366    /// Add a file pattern to the allowed list
367    pub fn allow(&self, pattern: String) {
368        let mut patterns = self.patterns.lock().unwrap();
369        patterns.insert(pattern);
370    }
371}
372
373impl Default for AllowedFilePatterns {
374    fn default() -> Self {
375        Self::new()
376    }
377}
378
379#[derive(Debug, Clone)]
380pub struct WriteFileTool {
381    project_path: PathBuf,
382    /// Whether to require confirmation before writing
383    require_confirmation: bool,
384    /// Session-level allowed file patterns
385    allowed_patterns: std::sync::Arc<AllowedFilePatterns>,
386    /// Optional IDE client for native diff viewing
387    ide_client: Option<std::sync::Arc<tokio::sync::Mutex<IdeClient>>>,
388}
389
390impl WriteFileTool {
391    pub fn new(project_path: PathBuf) -> Self {
392        Self {
393            project_path,
394            require_confirmation: true,
395            allowed_patterns: std::sync::Arc::new(AllowedFilePatterns::new()),
396            ide_client: None,
397        }
398    }
399
400    /// Create with shared allowed patterns state (for session persistence)
401    pub fn with_allowed_patterns(
402        project_path: PathBuf,
403        allowed_patterns: std::sync::Arc<AllowedFilePatterns>,
404    ) -> Self {
405        Self {
406            project_path,
407            require_confirmation: true,
408            allowed_patterns,
409            ide_client: None,
410        }
411    }
412
413    /// Set IDE client for native diff viewing
414    pub fn with_ide_client(
415        mut self,
416        ide_client: std::sync::Arc<tokio::sync::Mutex<IdeClient>>,
417    ) -> Self {
418        self.ide_client = Some(ide_client);
419        self
420    }
421
422    /// Disable confirmation prompts
423    pub fn without_confirmation(mut self) -> Self {
424        self.require_confirmation = false;
425        self
426    }
427
428    fn validate_path(&self, requested: &PathBuf) -> Result<PathBuf, WriteFileError> {
429        let canonical_project = self.project_path.canonicalize()
430            .map_err(|e| WriteFileError(format!("Invalid project path: {}", e)))?;
431
432        let target = if requested.is_absolute() {
433            requested.clone()
434        } else {
435            self.project_path.join(requested)
436        };
437
438        // For new files, we can't canonicalize yet, so check the parent
439        let parent = target.parent()
440            .ok_or_else(|| WriteFileError("Invalid path: no parent directory".to_string()))?;
441
442        // If parent exists, canonicalize it; otherwise check the path prefix
443        let is_within_project = if parent.exists() {
444            let canonical_parent = parent.canonicalize()
445                .map_err(|e| WriteFileError(format!("Invalid parent path: {}", e)))?;
446            canonical_parent.starts_with(&canonical_project)
447        } else {
448            // For nested new directories, check if the normalized path stays within project
449            let normalized = self.project_path.join(requested);
450            !normalized.components().any(|c| c == std::path::Component::ParentDir)
451        };
452
453        if !is_within_project {
454            return Err(WriteFileError("Access denied: path is outside project directory".to_string()));
455        }
456
457        Ok(target)
458    }
459}
460
461impl Tool for WriteFileTool {
462    const NAME: &'static str = "write_file";
463
464    type Error = WriteFileError;
465    type Args = WriteFileArgs;
466    type Output = String;
467
468    async fn definition(&self, _prompt: String) -> ToolDefinition {
469        ToolDefinition {
470            name: Self::NAME.to_string(),
471            description: r#"Write content to a file in the project. Creates the file if it doesn't exist, or overwrites if it does.
472
473**IMPORTANT**: Use this tool IMMEDIATELY when the user asks you to:
474- Create ANY file (Dockerfile, .tf, .yaml, .md, .json, etc.)
475- Generate configuration files
476- Write documentation to a specific location
477- "Put content in" or "under" a directory
478- Save analysis results or findings
479- Document anything in a file
480
481**DO NOT** just describe what you would write - actually call this tool with the content.
482
483Use cases:
484- Generate Dockerfiles for applications
485- Create Terraform configuration files (.tf)
486- Write Helm chart templates and values
487- Create docker-compose.yml files
488- Generate CI/CD configuration files (.github/workflows, .gitlab-ci.yml)
489- Write Kubernetes manifests
490- Save analysis findings to markdown files
491- Create any text file the user requests
492
493The tool will create parent directories automatically if they don't exist."#.to_string(),
494            parameters: json!({
495                "type": "object",
496                "properties": {
497                    "path": {
498                        "type": "string",
499                        "description": "Path to the file to write (relative to project root). Example: 'Dockerfile', 'terraform/main.tf', 'helm/values.yaml'"
500                    },
501                    "content": {
502                        "type": "string",
503                        "description": "The complete content to write to the file"
504                    },
505                    "create_dirs": {
506                        "type": "boolean",
507                        "description": "If true (default), create parent directories if they don't exist"
508                    }
509                },
510                "required": ["path", "content"]
511            }),
512        }
513    }
514
515    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
516        let requested_path = PathBuf::from(&args.path);
517        let file_path = self.validate_path(&requested_path)?;
518
519        // Read existing content for diff (if file exists)
520        let old_content = if file_path.exists() {
521            fs::read_to_string(&file_path).ok()
522        } else {
523            None
524        };
525
526        // Get filename for pattern matching
527        let filename = std::path::Path::new(&args.path)
528            .file_name()
529            .map(|n| n.to_string_lossy().to_string())
530            .unwrap_or_else(|| args.path.clone());
531
532        // Check if confirmation is needed
533        let needs_confirmation = self.require_confirmation
534            && !self.allowed_patterns.is_allowed(&filename);
535
536        if needs_confirmation {
537            // Get IDE client reference if available
538            let ide_client_guard = if let Some(ref client) = self.ide_client {
539                Some(client.lock().await)
540            } else {
541                None
542            };
543            let ide_client_ref = ide_client_guard.as_deref();
544
545            // Show diff with IDE integration if available
546            let confirmation = confirm_file_write_with_ide(
547                &args.path,
548                old_content.as_deref(),
549                &args.content,
550                ide_client_ref,
551            )
552            .await;
553
554            match confirmation {
555                ConfirmationResult::Proceed => {
556                    // Continue with write
557                }
558                ConfirmationResult::ProceedAlways(pattern) => {
559                    // Remember this file pattern for the session
560                    self.allowed_patterns.allow(pattern);
561                }
562                ConfirmationResult::Modify(feedback) => {
563                    // Return feedback to the agent - make it VERY clear to stop
564                    let result = json!({
565                        "cancelled": true,
566                        "STOP": "Do NOT create this file or any similar files. Wait for user instruction.",
567                        "reason": "User requested changes",
568                        "user_feedback": feedback,
569                        "original_path": args.path,
570                        "action_required": "Read the user_feedback and respond accordingly. Do NOT try to create alternative files."
571                    });
572                    return serde_json::to_string_pretty(&result)
573                        .map_err(|e| WriteFileError(format!("Failed to serialize: {}", e)));
574                }
575                ConfirmationResult::Cancel => {
576                    // User cancelled - make it absolutely clear to stop
577                    let result = json!({
578                        "cancelled": true,
579                        "STOP": "User has rejected this operation. Do NOT create this file or any alternative files.",
580                        "reason": "User cancelled the operation",
581                        "original_path": args.path,
582                        "action_required": "Stop creating files. Ask the user what they want instead."
583                    });
584                    return serde_json::to_string_pretty(&result)
585                        .map_err(|e| WriteFileError(format!("Failed to serialize: {}", e)));
586                }
587            }
588        } else {
589            // Auto-accept mode: show the diff without requiring confirmation
590            use crate::agent::ui::diff::{render_diff, render_new_file};
591            use colored::Colorize;
592
593            if let Some(old) = &old_content {
594                render_diff(old, &args.content, &args.path);
595            } else {
596                render_new_file(&args.content, &args.path);
597            }
598            println!("  {} Auto-accepted", "✓".green());
599        }
600
601        // Create parent directories if needed
602        let create_dirs = args.create_dirs.unwrap_or(true);
603        if create_dirs {
604            if let Some(parent) = file_path.parent() {
605                if !parent.exists() {
606                    fs::create_dir_all(parent)
607                        .map_err(|e| WriteFileError(format!("Failed to create directories: {}", e)))?;
608                }
609            }
610        }
611
612        // Check if file exists (for reporting)
613        let file_existed = file_path.exists();
614
615        // Write the content
616        fs::write(&file_path, &args.content)
617            .map_err(|e| WriteFileError(format!("Failed to write file: {}", e)))?;
618
619        let action = if file_existed { "Updated" } else { "Created" };
620        let lines = args.content.lines().count();
621
622        let result = json!({
623            "success": true,
624            "action": action,
625            "path": args.path,
626            "lines_written": lines,
627            "bytes_written": args.content.len()
628        });
629
630        serde_json::to_string_pretty(&result)
631            .map_err(|e| WriteFileError(format!("Failed to serialize: {}", e)))
632    }
633}
634
635// ============================================================================
636// Write Files Tool - For writing multiple files (Terraform modules, Helm charts)
637// ============================================================================
638
639#[derive(Debug, Deserialize)]
640pub struct FileToWrite {
641    /// Path to the file (relative to project root)
642    pub path: String,
643    /// Content to write
644    pub content: String,
645}
646
647#[derive(Debug, Deserialize)]
648pub struct WriteFilesArgs {
649    /// List of files to write
650    pub files: Vec<FileToWrite>,
651    /// If true, create parent directories if they don't exist (default: true)
652    pub create_dirs: Option<bool>,
653}
654
655#[derive(Debug, thiserror::Error)]
656#[error("Write files error: {0}")]
657pub struct WriteFilesError(String);
658
659#[derive(Debug, Clone)]
660pub struct WriteFilesTool {
661    project_path: PathBuf,
662    /// Whether to require confirmation before writing
663    require_confirmation: bool,
664    /// Session-level allowed file patterns
665    allowed_patterns: std::sync::Arc<AllowedFilePatterns>,
666    /// Optional IDE client for native diff views
667    ide_client: Option<std::sync::Arc<tokio::sync::Mutex<IdeClient>>>,
668}
669
670impl WriteFilesTool {
671    pub fn new(project_path: PathBuf) -> Self {
672        Self {
673            project_path,
674            require_confirmation: true,
675            allowed_patterns: std::sync::Arc::new(AllowedFilePatterns::new()),
676            ide_client: None,
677        }
678    }
679
680    /// Create with shared allowed patterns state
681    pub fn with_allowed_patterns(
682        project_path: PathBuf,
683        allowed_patterns: std::sync::Arc<AllowedFilePatterns>,
684    ) -> Self {
685        Self {
686            project_path,
687            require_confirmation: true,
688            allowed_patterns,
689            ide_client: None,
690        }
691    }
692
693    /// Disable confirmation prompts
694    pub fn without_confirmation(mut self) -> Self {
695        self.require_confirmation = false;
696        self
697    }
698
699    /// Set the IDE client for native diff views
700    pub fn with_ide_client(mut self, ide_client: std::sync::Arc<tokio::sync::Mutex<IdeClient>>) -> Self {
701        self.ide_client = Some(ide_client);
702        self
703    }
704
705    fn validate_path(&self, requested: &PathBuf) -> Result<PathBuf, WriteFilesError> {
706        let canonical_project = self.project_path.canonicalize()
707            .map_err(|e| WriteFilesError(format!("Invalid project path: {}", e)))?;
708
709        let target = if requested.is_absolute() {
710            requested.clone()
711        } else {
712            self.project_path.join(requested)
713        };
714
715        let parent = target.parent()
716            .ok_or_else(|| WriteFilesError("Invalid path: no parent directory".to_string()))?;
717
718        let is_within_project = if parent.exists() {
719            let canonical_parent = parent.canonicalize()
720                .map_err(|e| WriteFilesError(format!("Invalid parent path: {}", e)))?;
721            canonical_parent.starts_with(&canonical_project)
722        } else {
723            let normalized = self.project_path.join(requested);
724            !normalized.components().any(|c| c == std::path::Component::ParentDir)
725        };
726
727        if !is_within_project {
728            return Err(WriteFilesError("Access denied: path is outside project directory".to_string()));
729        }
730
731        Ok(target)
732    }
733}
734
735impl Tool for WriteFilesTool {
736    const NAME: &'static str = "write_files";
737
738    type Error = WriteFilesError;
739    type Args = WriteFilesArgs;
740    type Output = String;
741
742    async fn definition(&self, _prompt: String) -> ToolDefinition {
743        ToolDefinition {
744            name: Self::NAME.to_string(),
745            description: r#"Write multiple files at once. Ideal for creating complete infrastructure configurations.
746
747**IMPORTANT**: Use this tool when you need to create multiple related files together.
748
749**USE THIS TOOL** (not just describe files) when the user asks for:
750- Complete Terraform modules (main.tf, variables.tf, outputs.tf, providers.tf)
751- Full Helm charts (Chart.yaml, values.yaml, templates/*.yaml)
752- Kubernetes manifests (deployment.yaml, service.yaml, configmap.yaml)
753- Multi-file docker-compose setups
754- Multiple documentation files in a directory
755- Any set of related files
756
757**DO NOT** just describe the files - actually call this tool to create them.
758
759All files are written atomically. Parent directories are created automatically."#.to_string(),
760            parameters: json!({
761                "type": "object",
762                "properties": {
763                    "files": {
764                        "type": "array",
765                        "description": "List of files to write",
766                        "items": {
767                            "type": "object",
768                            "properties": {
769                                "path": {
770                                    "type": "string",
771                                    "description": "Path to the file (relative to project root)"
772                                },
773                                "content": {
774                                    "type": "string",
775                                    "description": "Content to write to the file"
776                                }
777                            },
778                            "required": ["path", "content"]
779                        }
780                    },
781                    "create_dirs": {
782                        "type": "boolean",
783                        "description": "If true (default), create parent directories if they don't exist"
784                    }
785                },
786                "required": ["files"]
787            }),
788        }
789    }
790
791    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
792        let create_dirs = args.create_dirs.unwrap_or(true);
793        let mut results = Vec::new();
794        let mut total_bytes = 0usize;
795        let mut total_lines = 0usize;
796
797        for file in &args.files {
798            let requested_path = PathBuf::from(&file.path);
799            let file_path = self.validate_path(&requested_path)?;
800
801            // Read existing content for diff
802            let old_content = if file_path.exists() {
803                fs::read_to_string(&file_path).ok()
804            } else {
805                None
806            };
807
808            // Get filename for pattern matching
809            let filename = std::path::Path::new(&file.path)
810                .file_name()
811                .map(|n| n.to_string_lossy().to_string())
812                .unwrap_or_else(|| file.path.clone());
813
814            // Check if confirmation is needed
815            let needs_confirmation = self.require_confirmation
816                && !self.allowed_patterns.is_allowed(&filename);
817
818            if needs_confirmation {
819                // Use IDE diff if client is connected, otherwise terminal diff
820                let confirmation = if let Some(ref client) = self.ide_client {
821                    let guard = client.lock().await;
822                    if guard.is_connected() {
823                        confirm_file_write_with_ide(
824                            &file.path,
825                            old_content.as_deref(),
826                            &file.content,
827                            Some(&*guard),
828                        ).await
829                    } else {
830                        drop(guard);
831                        confirm_file_write(
832                            &file.path,
833                            old_content.as_deref(),
834                            &file.content,
835                        )
836                    }
837                } else {
838                    confirm_file_write(
839                        &file.path,
840                        old_content.as_deref(),
841                        &file.content,
842                    )
843                };
844
845                match confirmation {
846                    ConfirmationResult::Proceed => {
847                        // Continue with this file
848                    }
849                    ConfirmationResult::ProceedAlways(pattern) => {
850                        self.allowed_patterns.allow(pattern);
851                    }
852                    ConfirmationResult::Modify(feedback) => {
853                        // User provided feedback - stop ALL remaining files immediately
854                        let result = json!({
855                            "cancelled": true,
856                            "STOP": "User provided feedback. Stop creating all remaining files in this batch.",
857                            "reason": "User requested changes",
858                            "user_feedback": feedback,
859                            "skipped_file": file.path,
860                            "files_written_before_cancel": results.len(),
861                            "action_required": "Read the user_feedback. Do NOT continue with remaining files."
862                        });
863                        return serde_json::to_string_pretty(&result)
864                            .map_err(|e| WriteFilesError(format!("Failed to serialize: {}", e)));
865                    }
866                    ConfirmationResult::Cancel => {
867                        // User cancelled - stop ALL remaining files immediately
868                        let result = json!({
869                            "cancelled": true,
870                            "STOP": "User cancelled. Stop creating all files immediately.",
871                            "reason": "User cancelled the operation",
872                            "skipped_file": file.path,
873                            "files_written_before_cancel": results.len(),
874                            "action_required": "Stop all file creation. Ask the user what they want instead."
875                        });
876                        return serde_json::to_string_pretty(&result)
877                            .map_err(|e| WriteFilesError(format!("Failed to serialize: {}", e)));
878                    }
879                }
880            } else {
881                // Auto-accept mode: show the diff without requiring confirmation
882                use crate::agent::ui::diff::{render_diff, render_new_file};
883                use colored::Colorize;
884
885                if let Some(old) = &old_content {
886                    render_diff(old, &file.content, &file.path);
887                } else {
888                    render_new_file(&file.content, &file.path);
889                }
890                println!("  {} Auto-accepted", "✓".green());
891            }
892
893            // Create parent directories if needed
894            if create_dirs {
895                if let Some(parent) = file_path.parent() {
896                    if !parent.exists() {
897                        fs::create_dir_all(parent)
898                            .map_err(|e| WriteFilesError(format!("Failed to create directories for {}: {}", file.path, e)))?;
899                    }
900                }
901            }
902
903            let file_existed = file_path.exists();
904
905            fs::write(&file_path, &file.content)
906                .map_err(|e| WriteFilesError(format!("Failed to write {}: {}", file.path, e)))?;
907
908            let lines = file.content.lines().count();
909            total_bytes += file.content.len();
910            total_lines += lines;
911
912            results.push(json!({
913                "path": file.path,
914                "action": if file_existed { "updated" } else { "created" },
915                "lines": lines,
916                "bytes": file.content.len()
917            }));
918        }
919
920        // If we get here, all files were written successfully
921        // (cancellations return early with immediate stop message)
922        let result = json!({
923            "success": true,
924            "files_written": results.len(),
925            "total_lines": total_lines,
926            "total_bytes": total_bytes,
927            "files": results
928        });
929
930        serde_json::to_string_pretty(&result)
931            .map_err(|e| WriteFilesError(format!("Failed to serialize: {}", e)))
932    }
933}