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            if let Some(parent) = file_path.parent() {
644                if !parent.exists() {
645                    fs::create_dir_all(parent).map_err(|e| {
646                        WriteFileError(format!("Failed to create directories: {}", e))
647                    })?;
648                }
649            }
650        }
651
652        // Check if file exists (for reporting)
653        let file_existed = file_path.exists();
654
655        // Write the content
656        fs::write(&file_path, &args.content)
657            .map_err(|e| WriteFileError(format!("Failed to write file: {}", e)))?;
658
659        let action = if file_existed { "Updated" } else { "Created" };
660        let lines = args.content.lines().count();
661
662        let result = json!({
663            "success": true,
664            "action": action,
665            "path": args.path,
666            "lines_written": lines,
667            "bytes_written": args.content.len()
668        });
669
670        serde_json::to_string_pretty(&result)
671            .map_err(|e| WriteFileError(format!("Failed to serialize: {}", e)))
672    }
673}
674
675// ============================================================================
676// Write Files Tool - For writing multiple files (Terraform modules, Helm charts)
677// ============================================================================
678
679#[derive(Debug, Deserialize)]
680pub struct FileToWrite {
681    /// Path to the file (relative to project root)
682    pub path: String,
683    /// Content to write
684    pub content: String,
685}
686
687#[derive(Debug, Deserialize)]
688pub struct WriteFilesArgs {
689    /// List of files to write
690    pub files: Vec<FileToWrite>,
691    /// If true, create parent directories if they don't exist (default: true)
692    pub create_dirs: Option<bool>,
693}
694
695#[derive(Debug, thiserror::Error)]
696#[error("Write files error: {0}")]
697pub struct WriteFilesError(String);
698
699#[derive(Debug, Clone)]
700pub struct WriteFilesTool {
701    project_path: PathBuf,
702    /// Whether to require confirmation before writing
703    require_confirmation: bool,
704    /// Session-level allowed file patterns
705    allowed_patterns: std::sync::Arc<AllowedFilePatterns>,
706    /// Optional IDE client for native diff views
707    ide_client: Option<std::sync::Arc<tokio::sync::Mutex<IdeClient>>>,
708}
709
710impl WriteFilesTool {
711    pub fn new(project_path: PathBuf) -> Self {
712        Self {
713            project_path,
714            require_confirmation: true,
715            allowed_patterns: std::sync::Arc::new(AllowedFilePatterns::new()),
716            ide_client: None,
717        }
718    }
719
720    /// Create with shared allowed patterns state
721    pub fn with_allowed_patterns(
722        project_path: PathBuf,
723        allowed_patterns: std::sync::Arc<AllowedFilePatterns>,
724    ) -> Self {
725        Self {
726            project_path,
727            require_confirmation: true,
728            allowed_patterns,
729            ide_client: None,
730        }
731    }
732
733    /// Disable confirmation prompts
734    pub fn without_confirmation(mut self) -> Self {
735        self.require_confirmation = false;
736        self
737    }
738
739    /// Set the IDE client for native diff views
740    pub fn with_ide_client(
741        mut self,
742        ide_client: std::sync::Arc<tokio::sync::Mutex<IdeClient>>,
743    ) -> Self {
744        self.ide_client = Some(ide_client);
745        self
746    }
747
748    fn validate_path(&self, requested: &PathBuf) -> Result<PathBuf, WriteFilesError> {
749        let canonical_project = self
750            .project_path
751            .canonicalize()
752            .map_err(|e| WriteFilesError(format!("Invalid project path: {}", e)))?;
753
754        let target = if requested.is_absolute() {
755            requested.clone()
756        } else {
757            self.project_path.join(requested)
758        };
759
760        let parent = target
761            .parent()
762            .ok_or_else(|| WriteFilesError("Invalid path: no parent directory".to_string()))?;
763
764        let is_within_project = if parent.exists() {
765            let canonical_parent = parent
766                .canonicalize()
767                .map_err(|e| WriteFilesError(format!("Invalid parent path: {}", e)))?;
768            canonical_parent.starts_with(&canonical_project)
769        } else {
770            let normalized = self.project_path.join(requested);
771            !normalized
772                .components()
773                .any(|c| c == std::path::Component::ParentDir)
774        };
775
776        if !is_within_project {
777            return Err(WriteFilesError(
778                "Access denied: path is outside project directory".to_string(),
779            ));
780        }
781
782        Ok(target)
783    }
784}
785
786impl Tool for WriteFilesTool {
787    const NAME: &'static str = "write_files";
788
789    type Error = WriteFilesError;
790    type Args = WriteFilesArgs;
791    type Output = String;
792
793    async fn definition(&self, _prompt: String) -> ToolDefinition {
794        ToolDefinition {
795            name: Self::NAME.to_string(),
796            description: r#"Write multiple files at once. Ideal for creating complete infrastructure configurations.
797
798**IMPORTANT**: Use this tool when you need to create multiple related files together.
799
800**USE THIS TOOL** (not just describe files) when the user asks for:
801- Complete Terraform modules (main.tf, variables.tf, outputs.tf, providers.tf)
802- Full Helm charts (Chart.yaml, values.yaml, templates/*.yaml)
803- Kubernetes manifests (deployment.yaml, service.yaml, configmap.yaml)
804- Multi-file docker-compose setups
805- Multiple documentation files in a directory
806- Any set of related files
807
808**DO NOT** just describe the files - actually call this tool to create them.
809
810All files are written atomically. Parent directories are created automatically."#.to_string(),
811            parameters: json!({
812                "type": "object",
813                "properties": {
814                    "files": {
815                        "type": "array",
816                        "description": "List of files to write",
817                        "items": {
818                            "type": "object",
819                            "properties": {
820                                "path": {
821                                    "type": "string",
822                                    "description": "Path to the file (relative to project root)"
823                                },
824                                "content": {
825                                    "type": "string",
826                                    "description": "Content to write to the file"
827                                }
828                            },
829                            "required": ["path", "content"]
830                        }
831                    },
832                    "create_dirs": {
833                        "type": "boolean",
834                        "description": "If true (default), create parent directories if they don't exist"
835                    }
836                },
837                "required": ["files"]
838            }),
839        }
840    }
841
842    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
843        let create_dirs = args.create_dirs.unwrap_or(true);
844        let mut results = Vec::new();
845        let mut total_bytes = 0usize;
846        let mut total_lines = 0usize;
847
848        for file in &args.files {
849            let requested_path = PathBuf::from(&file.path);
850            let file_path = self.validate_path(&requested_path)?;
851
852            // Read existing content for diff
853            let old_content = if file_path.exists() {
854                fs::read_to_string(&file_path).ok()
855            } else {
856                None
857            };
858
859            // Get filename for pattern matching
860            let filename = std::path::Path::new(&file.path)
861                .file_name()
862                .map(|n| n.to_string_lossy().to_string())
863                .unwrap_or_else(|| file.path.clone());
864
865            // Check if confirmation is needed
866            let needs_confirmation =
867                self.require_confirmation && !self.allowed_patterns.is_allowed(&filename);
868
869            if needs_confirmation {
870                // Use IDE diff if client is connected, otherwise terminal diff
871                let confirmation = if let Some(ref client) = self.ide_client {
872                    let guard = client.lock().await;
873                    if guard.is_connected() {
874                        confirm_file_write_with_ide(
875                            &file.path,
876                            old_content.as_deref(),
877                            &file.content,
878                            Some(&*guard),
879                        )
880                        .await
881                    } else {
882                        drop(guard);
883                        confirm_file_write(&file.path, old_content.as_deref(), &file.content)
884                    }
885                } else {
886                    confirm_file_write(&file.path, old_content.as_deref(), &file.content)
887                };
888
889                match confirmation {
890                    ConfirmationResult::Proceed => {
891                        // Continue with this file
892                    }
893                    ConfirmationResult::ProceedAlways(pattern) => {
894                        self.allowed_patterns.allow(pattern);
895                    }
896                    ConfirmationResult::Modify(feedback) => {
897                        // User provided feedback - stop ALL remaining files immediately
898                        let result = json!({
899                            "cancelled": true,
900                            "STOP": "User provided feedback. Stop creating all remaining files in this batch.",
901                            "reason": "User requested changes",
902                            "user_feedback": feedback,
903                            "skipped_file": file.path,
904                            "files_written_before_cancel": results.len(),
905                            "action_required": "Read the user_feedback. Do NOT continue with remaining files."
906                        });
907                        return serde_json::to_string_pretty(&result)
908                            .map_err(|e| WriteFilesError(format!("Failed to serialize: {}", e)));
909                    }
910                    ConfirmationResult::Cancel => {
911                        // User cancelled - stop ALL remaining files immediately
912                        let result = json!({
913                            "cancelled": true,
914                            "STOP": "User cancelled. Stop creating all files immediately.",
915                            "reason": "User cancelled the operation",
916                            "skipped_file": file.path,
917                            "files_written_before_cancel": results.len(),
918                            "action_required": "Stop all file creation. Ask the user what they want instead."
919                        });
920                        return serde_json::to_string_pretty(&result)
921                            .map_err(|e| WriteFilesError(format!("Failed to serialize: {}", e)));
922                    }
923                }
924            } else {
925                // Auto-accept mode: show the diff without requiring confirmation
926                use crate::agent::ui::diff::{render_diff, render_new_file};
927                use colored::Colorize;
928
929                if let Some(old) = &old_content {
930                    render_diff(old, &file.content, &file.path);
931                } else {
932                    render_new_file(&file.content, &file.path);
933                }
934                println!("  {} Auto-accepted", "✓".green());
935            }
936
937            // Create parent directories if needed
938            if create_dirs {
939                if let Some(parent) = file_path.parent() {
940                    if !parent.exists() {
941                        fs::create_dir_all(parent).map_err(|e| {
942                            WriteFilesError(format!(
943                                "Failed to create directories for {}: {}",
944                                file.path, e
945                            ))
946                        })?;
947                    }
948                }
949            }
950
951            let file_existed = file_path.exists();
952
953            fs::write(&file_path, &file.content)
954                .map_err(|e| WriteFilesError(format!("Failed to write {}: {}", file.path, e)))?;
955
956            let lines = file.content.lines().count();
957            total_bytes += file.content.len();
958            total_lines += lines;
959
960            results.push(json!({
961                "path": file.path,
962                "action": if file_existed { "updated" } else { "created" },
963                "lines": lines,
964                "bytes": file.content.len()
965            }));
966        }
967
968        // If we get here, all files were written successfully
969        // (cancellations return early with immediate stop message)
970        let result = json!({
971            "success": true,
972            "files_written": results.len(),
973            "total_lines": total_lines,
974            "total_bytes": total_bytes,
975            "files": results
976        });
977
978        serde_json::to_string_pretty(&result)
979            .map_err(|e| WriteFilesError(format!("Failed to serialize: {}", e)))
980    }
981}