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