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::error::{ErrorCategory, format_error_for_llm};
19use super::response::{
20    format_cancelled, format_file_content, format_file_content_range, format_list,
21};
22use super::truncation::{TruncationLimits, truncate_dir_listing, truncate_file_content};
23use crate::agent::ide::IdeClient;
24use crate::agent::ui::confirmation::ConfirmationResult;
25use crate::agent::ui::diff::{confirm_file_write, confirm_file_write_with_ide};
26use rig::completion::ToolDefinition;
27use rig::tool::Tool;
28use serde::{Deserialize, Serialize};
29use serde_json::json;
30use std::collections::HashSet;
31use std::fs;
32use std::path::PathBuf;
33use std::sync::Mutex;
34
35// ============================================================================
36// Read File Tool
37// ============================================================================
38
39#[derive(Debug, Deserialize)]
40pub struct ReadFileArgs {
41    pub path: String,
42    pub start_line: Option<u64>,
43    pub end_line: Option<u64>,
44}
45
46#[derive(Debug, thiserror::Error)]
47#[error("Read file error: {0}")]
48pub struct ReadFileError(String);
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ReadFileTool {
52    project_path: PathBuf,
53}
54
55impl ReadFileTool {
56    pub fn new(project_path: PathBuf) -> Self {
57        Self { project_path }
58    }
59
60    /// Check if file content appears to be binary (contains null bytes in first 1KB)
61    fn is_likely_binary(content: &[u8]) -> bool {
62        let check_len = content.len().min(1024);
63        content[..check_len].contains(&0)
64    }
65
66    /// Check if a symlink target is within the project boundary
67    fn validate_symlink_target(&self, path: &PathBuf) -> Result<PathBuf, String> {
68        let canonical_project = self.project_path.canonicalize().map_err(|e| {
69            format_error_for_llm(
70                "read_file",
71                ErrorCategory::InternalError,
72                &format!("Invalid project path: {}", e),
73                Some(vec!["This is an internal configuration error"]),
74            )
75        })?;
76
77        // Read the symlink target and resolve it
78        let target = fs::read_link(path).map_err(|e| {
79            format_error_for_llm(
80                "read_file",
81                ErrorCategory::FileNotFound,
82                &format!("Cannot read symlink '{}': {}", path.display(), e),
83                Some(vec!["The symlink may be broken or inaccessible"]),
84            )
85        })?;
86
87        // Resolve the target path (make it absolute if relative)
88        let resolved = if target.is_absolute() {
89            target.clone()
90        } else {
91            path.parent().unwrap_or(path).join(&target)
92        };
93
94        // Canonicalize the resolved target
95        let canonical_target = match resolved.canonicalize() {
96            Ok(p) => p,
97            Err(e) => {
98                let hint1 = format!(
99                    "Symlink '{}' points to '{}'",
100                    path.display(),
101                    target.display()
102                );
103                let hint2 = format!("Error: {}", e);
104                return Err(format_error_for_llm(
105                    "read_file",
106                    ErrorCategory::FileNotFound,
107                    &format!("Symlink target does not exist: {}", resolved.display()),
108                    Some(vec![&hint1, &hint2]),
109                ));
110            }
111        };
112
113        // Verify the target is within project boundary
114        if !canonical_target.starts_with(&canonical_project) {
115            let hint_symlink = format!("Symlink: {}", path.display());
116            let hint_target = format!("Target: {}", target.display());
117            let hint_project = format!("Project root: {}", self.project_path.display());
118            return Err(format_error_for_llm(
119                "read_file",
120                ErrorCategory::PathOutsideBoundary,
121                &format!(
122                    "Symlink target '{}' is outside project boundary",
123                    target.display()
124                ),
125                Some(vec![
126                    "The symlink points to a location outside the project directory",
127                    &hint_symlink,
128                    &hint_target,
129                    &hint_project,
130                ]),
131            ));
132        }
133
134        Ok(canonical_target)
135    }
136
137    /// Validates a path is within the project boundary.
138    /// Returns Ok(Some(path)) if valid, Ok(None) with formatted error string if invalid.
139    fn validate_path(&self, requested: &PathBuf) -> Result<PathBuf, String> {
140        let canonical_project = self.project_path.canonicalize().map_err(|e| {
141            format_error_for_llm(
142                "read_file",
143                ErrorCategory::InternalError,
144                &format!("Invalid project path: {}", e),
145                Some(vec!["This is an internal configuration error"]),
146            )
147        })?;
148
149        let target = if requested.is_absolute() {
150            requested.clone()
151        } else {
152            self.project_path.join(requested)
153        };
154
155        let canonical_target = target.canonicalize().map_err(|e| {
156            let kind = e.kind();
157            match kind {
158                std::io::ErrorKind::NotFound => format_error_for_llm(
159                    "read_file",
160                    ErrorCategory::FileNotFound,
161                    &format!("File not found: {}", requested.display()),
162                    Some(vec![
163                        "Check if the file path is spelled correctly",
164                        "Use list_directory to explore available files",
165                        &format!("Project root: {}", self.project_path.display()),
166                    ]),
167                ),
168                std::io::ErrorKind::PermissionDenied => format_error_for_llm(
169                    "read_file",
170                    ErrorCategory::PermissionDenied,
171                    &format!("Permission denied: {}", requested.display()),
172                    Some(vec![
173                        "The file exists but cannot be read due to permissions",
174                    ]),
175                ),
176                _ => format_error_for_llm(
177                    "read_file",
178                    ErrorCategory::FileNotFound,
179                    &format!("Cannot access file '{}': {}", requested.display(), e),
180                    Some(vec!["Verify the path exists and is accessible"]),
181                ),
182            }
183        })?;
184
185        if !canonical_target.starts_with(&canonical_project) {
186            return Err(format_error_for_llm(
187                "read_file",
188                ErrorCategory::PathOutsideBoundary,
189                &format!("Path '{}' is outside project boundary", requested.display()),
190                Some(vec![
191                    "Paths must be within the project directory",
192                    "Use relative paths from project root",
193                    &format!("Project root: {}", self.project_path.display()),
194                ]),
195            ));
196        }
197
198        Ok(canonical_target)
199    }
200}
201
202impl Tool for ReadFileTool {
203    const NAME: &'static str = "read_file";
204
205    type Error = ReadFileError;
206    type Args = ReadFileArgs;
207    type Output = String;
208
209    async fn definition(&self, _prompt: String) -> ToolDefinition {
210        ToolDefinition {
211            name: Self::NAME.to_string(),
212            description: r#"Read the contents of a file in the project.
213
214**Truncation Limits:**
215- Maximum 2000 lines returned by default
216- Lines longer than 2000 characters are truncated
217- Use start_line/end_line to read specific sections of large files
218
219**Path Restrictions:**
220- Paths must be within the project directory (security boundary)
221- Both relative and absolute paths are supported
222- Relative paths are resolved from project root
223
224**Line Range Usage:**
225- start_line: 1-based line number to start reading from
226- end_line: 1-based line number to stop at (inclusive)
227- If only start_line is provided, reads from that line to end of file
228- If start_line exceeds file length, returns an error with file size info"#
229                .to_string(),
230            parameters: json!({
231                "type": "object",
232                "properties": {
233                    "path": {
234                        "type": "string",
235                        "description": "Path to the file to read (relative to project root or absolute within project)"
236                    },
237                    "start_line": {
238                        "type": "integer",
239                        "description": "Starting line number (1-based). Use with end_line to read specific sections of large files."
240                    },
241                    "end_line": {
242                        "type": "integer",
243                        "description": "Ending line number (1-based, inclusive). If omitted with start_line, reads to end of file."
244                    }
245                },
246                "required": ["path"]
247            }),
248        }
249    }
250
251    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
252        let requested_path = PathBuf::from(&args.path);
253        let file_path = match self.validate_path(&requested_path) {
254            Ok(path) => path,
255            Err(error_msg) => return Ok(error_msg), // Return formatted error as success for LLM
256        };
257
258        // Check if file is a symlink and validate target is within project
259        let symlink_metadata = fs::symlink_metadata(&file_path)
260            .map_err(|e| ReadFileError(format!("Cannot access file: {}", e)))?;
261
262        if symlink_metadata.file_type().is_symlink() {
263            // Validate symlink target is within project boundary
264            if let Err(error_msg) = self.validate_symlink_target(&file_path) {
265                return Ok(error_msg);
266            }
267        }
268
269        let metadata = fs::metadata(&file_path)
270            .map_err(|e| ReadFileError(format!("Cannot read file: {}", e)))?;
271
272        // Handle empty files gracefully
273        if metadata.len() == 0 {
274            return Ok(format_file_content(&args.path, "(empty file)", 0, 0, false));
275        }
276
277        const MAX_SIZE: u64 = 1024 * 1024;
278        if metadata.len() > MAX_SIZE {
279            return Ok(format_error_for_llm(
280                "read_file",
281                ErrorCategory::ValidationFailed,
282                &format!(
283                    "File too large ({} bytes). Maximum size is {} bytes.",
284                    metadata.len(),
285                    MAX_SIZE
286                ),
287                Some(vec![
288                    "Use start_line/end_line to read specific sections",
289                    "Consider if you need the entire file",
290                ]),
291            ));
292        }
293
294        // Read as bytes first to check for binary content
295        let raw_content = fs::read(&file_path)
296            .map_err(|e| ReadFileError(format!("Failed to read file: {}", e)))?;
297
298        // Check for binary content
299        if Self::is_likely_binary(&raw_content) {
300            return Ok(format_error_for_llm(
301                "read_file",
302                ErrorCategory::ValidationFailed,
303                &format!(
304                    "File '{}' appears to be binary (contains null bytes)",
305                    args.path
306                ),
307                Some(vec![
308                    "This tool is designed for text files only",
309                    "Binary files cannot be displayed as text",
310                    "Consider using a hex viewer or specialized tool for binary files",
311                ]),
312            ));
313        }
314
315        // Convert to string (now safe since we checked for binary)
316        let content = String::from_utf8_lossy(&raw_content).into_owned();
317
318        // Use response utilities for consistent formatting
319        if let Some(start) = args.start_line {
320            // User requested specific line range - respect it exactly
321            let lines: Vec<&str> = content.lines().collect();
322            let start_idx = (start as usize).saturating_sub(1);
323            let end_idx = args
324                .end_line
325                .map(|e| (e as usize).min(lines.len()))
326                .unwrap_or(lines.len());
327
328            if start_idx >= lines.len() {
329                return Ok(format_error_for_llm(
330                    "read_file",
331                    ErrorCategory::ValidationFailed,
332                    &format!(
333                        "Start line {} exceeds file length ({} lines)",
334                        start,
335                        lines.len()
336                    ),
337                    Some(vec![
338                        &format!("File has {} lines total", lines.len()),
339                        "Use start_line within valid range",
340                    ]),
341                ));
342            }
343
344            // Ensure end_idx >= start_idx to avoid slice panic when end_line < start_line
345            let end_idx = end_idx.max(start_idx);
346
347            let selected: Vec<String> = lines[start_idx..end_idx]
348                .iter()
349                .enumerate()
350                .map(|(i, line)| format!("{:>4} | {}", start_idx + i + 1, line))
351                .collect();
352
353            Ok(format_file_content_range(
354                &args.path,
355                &selected.join("\n"),
356                start as usize,
357                end_idx,
358                lines.len(),
359            ))
360        } else {
361            // Full file read - apply truncation to prevent context overflow
362            let limits = TruncationLimits::default();
363            let truncated = truncate_file_content(&content, &limits);
364
365            Ok(format_file_content(
366                &args.path,
367                &truncated.content,
368                truncated.total_lines,
369                truncated.returned_lines,
370                truncated.was_truncated,
371            ))
372        }
373    }
374}
375
376// ============================================================================
377// List Directory Tool
378// ============================================================================
379
380#[derive(Debug, Deserialize)]
381pub struct ListDirectoryArgs {
382    pub path: Option<String>,
383    pub recursive: Option<bool>,
384}
385
386#[derive(Debug, thiserror::Error)]
387#[error("List directory error: {0}")]
388pub struct ListDirectoryError(String);
389
390#[derive(Debug, Clone, Serialize, Deserialize)]
391pub struct ListDirectoryTool {
392    project_path: PathBuf,
393}
394
395impl ListDirectoryTool {
396    pub fn new(project_path: PathBuf) -> Self {
397        Self { project_path }
398    }
399
400    /// Validates a path is within the project boundary.
401    /// Returns Ok(path) if valid, Err(formatted_error_string) if invalid.
402    fn validate_path(&self, requested: &PathBuf) -> Result<PathBuf, String> {
403        let canonical_project = self.project_path.canonicalize().map_err(|e| {
404            format_error_for_llm(
405                "list_directory",
406                ErrorCategory::InternalError,
407                &format!("Invalid project path: {}", e),
408                Some(vec!["This is an internal configuration error"]),
409            )
410        })?;
411
412        let target = if requested.is_absolute() {
413            requested.clone()
414        } else {
415            self.project_path.join(requested)
416        };
417
418        let canonical_target = target.canonicalize().map_err(|e| {
419            let kind = e.kind();
420            match kind {
421                std::io::ErrorKind::NotFound => format_error_for_llm(
422                    "list_directory",
423                    ErrorCategory::FileNotFound,
424                    &format!("Directory not found: {}", requested.display()),
425                    Some(vec![
426                        "Check if the directory path is spelled correctly",
427                        "Use '.' to list the project root",
428                        &format!("Project root: {}", self.project_path.display()),
429                    ]),
430                ),
431                std::io::ErrorKind::PermissionDenied => format_error_for_llm(
432                    "list_directory",
433                    ErrorCategory::PermissionDenied,
434                    &format!("Permission denied: {}", requested.display()),
435                    Some(vec![
436                        "The directory exists but cannot be read due to permissions",
437                    ]),
438                ),
439                _ => format_error_for_llm(
440                    "list_directory",
441                    ErrorCategory::FileNotFound,
442                    &format!("Cannot access directory '{}': {}", requested.display(), e),
443                    Some(vec!["Verify the path exists and is accessible"]),
444                ),
445            }
446        })?;
447
448        if !canonical_target.starts_with(&canonical_project) {
449            return Err(format_error_for_llm(
450                "list_directory",
451                ErrorCategory::PathOutsideBoundary,
452                &format!("Path '{}' is outside project boundary", requested.display()),
453                Some(vec![
454                    "Paths must be within the project directory",
455                    "Use '.' for project root",
456                    &format!("Project root: {}", self.project_path.display()),
457                ]),
458            ));
459        }
460
461        Ok(canonical_target)
462    }
463
464    fn list_entries(
465        &self,
466        base_path: &PathBuf,
467        current_path: &PathBuf,
468        recursive: bool,
469        depth: usize,
470        max_depth: usize,
471        entries: &mut Vec<serde_json::Value>,
472    ) -> Result<(), ListDirectoryError> {
473        let skip_dirs = [
474            "node_modules",
475            ".git",
476            "target",
477            "__pycache__",
478            ".venv",
479            "venv",
480            "dist",
481            "build",
482        ];
483
484        let dir_name = current_path
485            .file_name()
486            .and_then(|n| n.to_str())
487            .unwrap_or("");
488
489        if depth > 0 && skip_dirs.contains(&dir_name) {
490            return Ok(());
491        }
492
493        let read_dir = fs::read_dir(current_path)
494            .map_err(|e| ListDirectoryError(format!("Cannot read directory: {}", e)))?;
495
496        for entry in read_dir {
497            let entry =
498                entry.map_err(|e| ListDirectoryError(format!("Error reading entry: {}", e)))?;
499            let path = entry.path();
500            let metadata = entry.metadata().ok();
501
502            let relative_path = path
503                .strip_prefix(base_path)
504                .unwrap_or(&path)
505                .to_string_lossy()
506                .to_string();
507            let is_dir = metadata.as_ref().map(|m| m.is_dir()).unwrap_or(false);
508            let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
509
510            entries.push(json!({
511                "name": entry.file_name().to_string_lossy(),
512                "path": relative_path,
513                "type": if is_dir { "directory" } else { "file" },
514                "size": if is_dir { None::<u64> } else { Some(size) }
515            }));
516
517            if recursive && is_dir && depth < max_depth {
518                self.list_entries(base_path, &path, recursive, depth + 1, max_depth, entries)?;
519            }
520        }
521
522        Ok(())
523    }
524}
525
526impl Tool for ListDirectoryTool {
527    const NAME: &'static str = "list_directory";
528
529    type Error = ListDirectoryError;
530    type Args = ListDirectoryArgs;
531    type Output = String;
532
533    async fn definition(&self, _prompt: String) -> ToolDefinition {
534        ToolDefinition {
535            name: Self::NAME.to_string(),
536            description: r#"List the contents of a directory in the project.
537
538**Truncation Limits:**
539- Maximum 500 entries returned
540- Use more specific paths to explore large directories
541
542**Output Format:**
543- Returns entries sorted alphabetically by name
544- Each entry includes: name, path, type (file/directory), size (for files)
545
546**Filtering:**
547- Automatically skips common non-essential directories: node_modules, .git, target, __pycache__, .venv, venv, dist, build
548- Respects .gitignore patterns in recursive mode
549
550**Path Restrictions:**
551- Paths must be within the project directory (security boundary)
552- Use '.' or empty path for project root"#.to_string(),
553            parameters: json!({
554                "type": "object",
555                "properties": {
556                    "path": {
557                        "type": "string",
558                        "description": "Path to the directory (relative to project root). Use '.' or omit for project root."
559                    },
560                    "recursive": {
561                        "type": "boolean",
562                        "description": "If true, list contents recursively (max depth 3, skips node_modules/.git/etc). Default: false."
563                    }
564                }
565            }),
566        }
567    }
568
569    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
570        let path_str = args.path.as_deref().unwrap_or(".");
571
572        let requested_path = if path_str.is_empty() || path_str == "." {
573            self.project_path.clone()
574        } else {
575            PathBuf::from(path_str)
576        };
577
578        let dir_path = match self.validate_path(&requested_path) {
579            Ok(path) => path,
580            Err(error_msg) => return Ok(error_msg), // Return formatted error as success for LLM
581        };
582        let recursive = args.recursive.unwrap_or(false);
583
584        let mut entries = Vec::new();
585        self.list_entries(&dir_path, &dir_path, recursive, 0, 3, &mut entries)?;
586
587        // Apply truncation to prevent context overflow
588        let limits = TruncationLimits::default();
589        let truncated = truncate_dir_listing(entries, limits.max_dir_entries);
590
591        // Use response utilities for consistent formatting
592        Ok(format_list(
593            path_str,
594            &truncated.entries,
595            truncated.total_entries,
596            truncated.was_truncated,
597        ))
598    }
599}
600
601// ============================================================================
602// Write File Tool - For writing Dockerfiles, Terraform files, Helm values, etc.
603// ============================================================================
604
605#[derive(Debug, Deserialize)]
606pub struct WriteFileArgs {
607    /// Path to the file to write (relative to project root)
608    pub path: String,
609    /// Content to write to the file
610    pub content: String,
611    /// If true, create parent directories if they don't exist (default: true)
612    pub create_dirs: Option<bool>,
613}
614
615#[derive(Debug, thiserror::Error)]
616#[error("Write file error: {0}")]
617pub struct WriteFileError(String);
618
619/// Session-level tracking of always-allowed file patterns
620#[derive(Debug)]
621pub struct AllowedFilePatterns {
622    patterns: Mutex<HashSet<String>>,
623}
624
625impl AllowedFilePatterns {
626    pub fn new() -> Self {
627        Self {
628            patterns: Mutex::new(HashSet::new()),
629        }
630    }
631
632    /// Check if a file pattern is already allowed
633    pub fn is_allowed(&self, filename: &str) -> bool {
634        let patterns = self.patterns.lock().unwrap();
635        patterns.contains(filename)
636    }
637
638    /// Add a file pattern to the allowed list
639    pub fn allow(&self, pattern: String) {
640        let mut patterns = self.patterns.lock().unwrap();
641        patterns.insert(pattern);
642    }
643}
644
645impl Default for AllowedFilePatterns {
646    fn default() -> Self {
647        Self::new()
648    }
649}
650
651#[derive(Debug, Clone)]
652pub struct WriteFileTool {
653    project_path: PathBuf,
654    /// Whether to require confirmation before writing
655    require_confirmation: bool,
656    /// Session-level allowed file patterns
657    allowed_patterns: std::sync::Arc<AllowedFilePatterns>,
658    /// Optional IDE client for native diff viewing
659    ide_client: Option<std::sync::Arc<tokio::sync::Mutex<IdeClient>>>,
660}
661
662impl WriteFileTool {
663    pub fn new(project_path: PathBuf) -> Self {
664        Self {
665            project_path,
666            require_confirmation: true,
667            allowed_patterns: std::sync::Arc::new(AllowedFilePatterns::new()),
668            ide_client: None,
669        }
670    }
671
672    /// Create with shared allowed patterns state (for session persistence)
673    pub fn with_allowed_patterns(
674        project_path: PathBuf,
675        allowed_patterns: std::sync::Arc<AllowedFilePatterns>,
676    ) -> Self {
677        Self {
678            project_path,
679            require_confirmation: true,
680            allowed_patterns,
681            ide_client: None,
682        }
683    }
684
685    /// Set IDE client for native diff viewing
686    pub fn with_ide_client(
687        mut self,
688        ide_client: std::sync::Arc<tokio::sync::Mutex<IdeClient>>,
689    ) -> Self {
690        self.ide_client = Some(ide_client);
691        self
692    }
693
694    /// Disable confirmation prompts
695    pub fn without_confirmation(mut self) -> Self {
696        self.require_confirmation = false;
697        self
698    }
699
700    /// Validates a path is within the project boundary for writing.
701    /// Returns Ok(path) if valid, Err(formatted_error_string) if invalid.
702    fn validate_path(&self, requested: &PathBuf) -> Result<PathBuf, String> {
703        let canonical_project = self.project_path.canonicalize().map_err(|e| {
704            format_error_for_llm(
705                "write_file",
706                ErrorCategory::InternalError,
707                &format!("Invalid project path: {}", e),
708                Some(vec!["This is an internal configuration error"]),
709            )
710        })?;
711
712        let target = if requested.is_absolute() {
713            requested.clone()
714        } else {
715            self.project_path.join(requested)
716        };
717
718        // For new files, we can't canonicalize yet, so check the parent
719        let parent = target.parent().ok_or_else(|| {
720            format_error_for_llm(
721                "write_file",
722                ErrorCategory::ValidationFailed,
723                &format!(
724                    "Invalid path '{}': no parent directory",
725                    requested.display()
726                ),
727                Some(vec![
728                    "Provide a valid file path with at least a filename",
729                    "Example: 'tmp/output.txt' or 'results/analysis.md'",
730                ]),
731            )
732        })?;
733
734        // If parent exists, canonicalize it; otherwise check the path prefix
735        let is_within_project = if parent.exists() {
736            let canonical_parent = parent.canonicalize().map_err(|e| {
737                let kind = e.kind();
738                match kind {
739                    std::io::ErrorKind::PermissionDenied => format_error_for_llm(
740                        "write_file",
741                        ErrorCategory::PermissionDenied,
742                        &format!(
743                            "Permission denied accessing parent directory: {}",
744                            parent.display()
745                        ),
746                        Some(vec!["The parent directory exists but cannot be accessed"]),
747                    ),
748                    _ => format_error_for_llm(
749                        "write_file",
750                        ErrorCategory::ValidationFailed,
751                        &format!("Invalid parent path '{}': {}", parent.display(), e),
752                        Some(vec!["Verify the parent directory path is valid"]),
753                    ),
754                }
755            })?;
756            canonical_parent.starts_with(&canonical_project)
757        } else {
758            // For nested new directories, check if the normalized path stays within project
759            let normalized = self.project_path.join(requested);
760            !normalized
761                .components()
762                .any(|c| c == std::path::Component::ParentDir)
763        };
764
765        if !is_within_project {
766            return Err(format_error_for_llm(
767                "write_file",
768                ErrorCategory::PathOutsideBoundary,
769                &format!("Path '{}' is outside project boundary", requested.display()),
770                Some(vec![
771                    "SECURITY: Writes are restricted to the project directory",
772                    "For temporary files, create a 'tmp/' directory in project root",
773                    "Use a project-relative path like 'tmp/output.txt'",
774                    &format!("Project root: {}", self.project_path.display()),
775                ]),
776            ));
777        }
778
779        Ok(target)
780    }
781}
782
783impl Tool for WriteFileTool {
784    const NAME: &'static str = "write_file";
785
786    type Error = WriteFileError;
787    type Args = WriteFileArgs;
788    type Output = String;
789
790    async fn definition(&self, _prompt: String) -> ToolDefinition {
791        ToolDefinition {
792            name: Self::NAME.to_string(),
793            description: r#"Write content to a file in the project. Creates the file if it doesn't exist, or overwrites if it does.
794
795**SECURITY: Path Restriction (Intentional)**
796- Writes are ONLY allowed within the project directory
797- Writing to /tmp, /etc, or any path outside the project is blocked
798- This is a security feature to prevent unintended system modifications
799- For temporary files, create a 'tmp/' directory within your project root
800
801**Confirmation Workflow:**
802- All writes show a diff preview before applying
803- User can approve, reject, or request modifications
804- Use 'Always' option to skip confirmation for repeated file types
805
806**IMPORTANT**: Use this tool IMMEDIATELY when the user asks you to:
807- Create ANY file (Dockerfile, .tf, .yaml, .md, .json, etc.)
808- Generate configuration files
809- Write documentation to a specific location
810- Save analysis results or findings
811
812**DO NOT** just describe what you would write - actually call this tool with the content.
813
814Use cases:
815- Generate Dockerfiles for applications
816- Create Terraform configuration files (.tf)
817- Write Helm chart templates and values
818- Create docker-compose.yml files
819- Generate CI/CD configuration files
820- Write Kubernetes manifests
821- Save analysis findings to markdown files
822
823The tool will create parent directories automatically if they don't exist."#.to_string(),
824            parameters: json!({
825                "type": "object",
826                "properties": {
827                    "path": {
828                        "type": "string",
829                        "description": "Path to the file (relative to project root). Must be within project. Examples: 'Dockerfile', 'terraform/main.tf', 'tmp/scratch.txt'"
830                    },
831                    "content": {
832                        "type": "string",
833                        "description": "The complete content to write to the file"
834                    },
835                    "create_dirs": {
836                        "type": "boolean",
837                        "description": "If true (default), create parent directories if they don't exist"
838                    }
839                },
840                "required": ["path", "content"]
841            }),
842        }
843    }
844
845    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
846        let requested_path = PathBuf::from(&args.path);
847        let file_path = match self.validate_path(&requested_path) {
848            Ok(path) => path,
849            Err(error_msg) => return Ok(error_msg), // Return formatted error as success for LLM
850        };
851
852        // Read existing content for diff (if file exists)
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(&args.path)
861            .file_name()
862            .map(|n| n.to_string_lossy().to_string())
863            .unwrap_or_else(|| args.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            // Get IDE client reference if available
871            let ide_client_guard = if let Some(ref client) = self.ide_client {
872                Some(client.lock().await)
873            } else {
874                None
875            };
876            let ide_client_ref = ide_client_guard.as_deref();
877
878            // Show diff with IDE integration if available
879            let confirmation = confirm_file_write_with_ide(
880                &args.path,
881                old_content.as_deref(),
882                &args.content,
883                ide_client_ref,
884            )
885            .await;
886
887            match confirmation {
888                ConfirmationResult::Proceed => {
889                    // Continue with write
890                }
891                ConfirmationResult::ProceedAlways(pattern) => {
892                    // Remember this file pattern for the session
893                    self.allowed_patterns.allow(pattern);
894                }
895                ConfirmationResult::Modify(feedback) => {
896                    // Return feedback to the agent using response utility
897                    return Ok(format_cancelled(
898                        &args.path,
899                        "User requested changes",
900                        Some(&feedback),
901                    ));
902                }
903                ConfirmationResult::Cancel => {
904                    // User cancelled using response utility
905                    return Ok(format_cancelled(
906                        &args.path,
907                        "User cancelled the operation",
908                        None,
909                    ));
910                }
911            }
912        } else {
913            // Auto-accept mode: show the diff without requiring confirmation
914            use crate::agent::ui::diff::{render_diff, render_new_file};
915            use colored::Colorize;
916
917            if let Some(old) = &old_content {
918                render_diff(old, &args.content, &args.path);
919            } else {
920                render_new_file(&args.content, &args.path);
921            }
922            println!("  {} Auto-accepted", "✓".green());
923        }
924
925        // Create parent directories if needed
926        let create_dirs = args.create_dirs.unwrap_or(true);
927        if create_dirs
928            && let Some(parent) = file_path.parent()
929            && !parent.exists()
930        {
931            fs::create_dir_all(parent)
932                .map_err(|e| WriteFileError(format!("Failed to create directories: {}", e)))?;
933        }
934
935        // Check if file exists (for reporting)
936        let file_existed = file_path.exists();
937
938        // Write the content
939        fs::write(&file_path, &args.content)
940            .map_err(|e| WriteFileError(format!("Failed to write file: {}", e)))?;
941
942        let action = if file_existed { "Updated" } else { "Created" };
943        let lines = args.content.lines().count();
944
945        let result = json!({
946            "success": true,
947            "action": action,
948            "path": args.path,
949            "lines_written": lines,
950            "bytes_written": args.content.len()
951        });
952
953        serde_json::to_string_pretty(&result)
954            .map_err(|e| WriteFileError(format!("Failed to serialize: {}", e)))
955    }
956}
957
958// ============================================================================
959// Write Files Tool - For writing multiple files (Terraform modules, Helm charts)
960// ============================================================================
961
962#[derive(Debug, Deserialize)]
963pub struct FileToWrite {
964    /// Path to the file (relative to project root)
965    pub path: String,
966    /// Content to write
967    pub content: String,
968}
969
970#[derive(Debug, Deserialize)]
971pub struct WriteFilesArgs {
972    /// List of files to write
973    pub files: Vec<FileToWrite>,
974    /// If true, create parent directories if they don't exist (default: true)
975    pub create_dirs: Option<bool>,
976}
977
978#[derive(Debug, thiserror::Error)]
979#[error("Write files error: {0}")]
980pub struct WriteFilesError(String);
981
982#[derive(Debug, Clone)]
983pub struct WriteFilesTool {
984    project_path: PathBuf,
985    /// Whether to require confirmation before writing
986    require_confirmation: bool,
987    /// Session-level allowed file patterns
988    allowed_patterns: std::sync::Arc<AllowedFilePatterns>,
989    /// Optional IDE client for native diff views
990    ide_client: Option<std::sync::Arc<tokio::sync::Mutex<IdeClient>>>,
991}
992
993impl WriteFilesTool {
994    pub fn new(project_path: PathBuf) -> Self {
995        Self {
996            project_path,
997            require_confirmation: true,
998            allowed_patterns: std::sync::Arc::new(AllowedFilePatterns::new()),
999            ide_client: None,
1000        }
1001    }
1002
1003    /// Create with shared allowed patterns state
1004    pub fn with_allowed_patterns(
1005        project_path: PathBuf,
1006        allowed_patterns: std::sync::Arc<AllowedFilePatterns>,
1007    ) -> Self {
1008        Self {
1009            project_path,
1010            require_confirmation: true,
1011            allowed_patterns,
1012            ide_client: None,
1013        }
1014    }
1015
1016    /// Disable confirmation prompts
1017    pub fn without_confirmation(mut self) -> Self {
1018        self.require_confirmation = false;
1019        self
1020    }
1021
1022    /// Set the IDE client for native diff views
1023    pub fn with_ide_client(
1024        mut self,
1025        ide_client: std::sync::Arc<tokio::sync::Mutex<IdeClient>>,
1026    ) -> Self {
1027        self.ide_client = Some(ide_client);
1028        self
1029    }
1030
1031    /// Validates a path is within the project boundary for writing.
1032    /// Returns Ok(path) if valid, Err(formatted_error_string) if invalid.
1033    fn validate_path(&self, requested: &PathBuf) -> Result<PathBuf, String> {
1034        let canonical_project = self.project_path.canonicalize().map_err(|e| {
1035            format_error_for_llm(
1036                "write_files",
1037                ErrorCategory::InternalError,
1038                &format!("Invalid project path: {}", e),
1039                Some(vec!["This is an internal configuration error"]),
1040            )
1041        })?;
1042
1043        let target = if requested.is_absolute() {
1044            requested.clone()
1045        } else {
1046            self.project_path.join(requested)
1047        };
1048
1049        let parent = target.parent().ok_or_else(|| {
1050            format_error_for_llm(
1051                "write_files",
1052                ErrorCategory::ValidationFailed,
1053                &format!(
1054                    "Invalid path '{}': no parent directory",
1055                    requested.display()
1056                ),
1057                Some(vec![
1058                    "Provide a valid file path with at least a filename",
1059                    "Example: 'tmp/output.txt' or 'results/analysis.md'",
1060                ]),
1061            )
1062        })?;
1063
1064        let is_within_project = if parent.exists() {
1065            let canonical_parent = parent.canonicalize().map_err(|e| {
1066                let kind = e.kind();
1067                match kind {
1068                    std::io::ErrorKind::PermissionDenied => format_error_for_llm(
1069                        "write_files",
1070                        ErrorCategory::PermissionDenied,
1071                        &format!(
1072                            "Permission denied accessing parent directory: {}",
1073                            parent.display()
1074                        ),
1075                        Some(vec!["The parent directory exists but cannot be accessed"]),
1076                    ),
1077                    _ => format_error_for_llm(
1078                        "write_files",
1079                        ErrorCategory::ValidationFailed,
1080                        &format!("Invalid parent path '{}': {}", parent.display(), e),
1081                        Some(vec!["Verify the parent directory path is valid"]),
1082                    ),
1083                }
1084            })?;
1085            canonical_parent.starts_with(&canonical_project)
1086        } else {
1087            let normalized = self.project_path.join(requested);
1088            !normalized
1089                .components()
1090                .any(|c| c == std::path::Component::ParentDir)
1091        };
1092
1093        if !is_within_project {
1094            return Err(format_error_for_llm(
1095                "write_files",
1096                ErrorCategory::PathOutsideBoundary,
1097                &format!("Path '{}' is outside project boundary", requested.display()),
1098                Some(vec![
1099                    "SECURITY: Writes are restricted to the project directory",
1100                    "For temporary files, create a 'tmp/' directory in project root",
1101                    "Use project-relative paths like 'tmp/output.txt'",
1102                    &format!("Project root: {}", self.project_path.display()),
1103                ]),
1104            ));
1105        }
1106
1107        Ok(target)
1108    }
1109}
1110
1111impl Tool for WriteFilesTool {
1112    const NAME: &'static str = "write_files";
1113
1114    type Error = WriteFilesError;
1115    type Args = WriteFilesArgs;
1116    type Output = String;
1117
1118    async fn definition(&self, _prompt: String) -> ToolDefinition {
1119        ToolDefinition {
1120            name: Self::NAME.to_string(),
1121            description: r#"Write multiple files at once. Ideal for creating complete infrastructure configurations.
1122
1123**SECURITY: Path Restriction (Intentional)**
1124- ALL paths must be within the project directory
1125- Writing to /tmp, /etc, or any path outside the project is blocked
1126- This is a security feature to prevent unintended system modifications
1127- For temporary files, create a 'tmp/' directory within your project root
1128
1129**Atomicity:**
1130- All paths are validated BEFORE any files are written
1131- If any path is invalid, NO files are written
1132- Confirmation is requested for each file individually
1133
1134**USE THIS TOOL** (not just describe files) when the user asks for:
1135- Complete Terraform modules (main.tf, variables.tf, outputs.tf, providers.tf)
1136- Full Helm charts (Chart.yaml, values.yaml, templates/*.yaml)
1137- Kubernetes manifests (deployment.yaml, service.yaml, configmap.yaml)
1138- Multi-file docker-compose setups
1139- Any set of related files
1140
1141**DO NOT** just describe the files - actually call this tool to create them.
1142
1143Parent directories are created automatically."#.to_string(),
1144            parameters: json!({
1145                "type": "object",
1146                "properties": {
1147                    "files": {
1148                        "type": "array",
1149                        "description": "List of files to write. All paths must be within project directory.",
1150                        "items": {
1151                            "type": "object",
1152                            "properties": {
1153                                "path": {
1154                                    "type": "string",
1155                                    "description": "Path to the file (relative to project root). Must be within project."
1156                                },
1157                                "content": {
1158                                    "type": "string",
1159                                    "description": "Content to write to the file"
1160                                }
1161                            },
1162                            "required": ["path", "content"]
1163                        }
1164                    },
1165                    "create_dirs": {
1166                        "type": "boolean",
1167                        "description": "If true (default), create parent directories if they don't exist"
1168                    }
1169                },
1170                "required": ["files"]
1171            }),
1172        }
1173    }
1174
1175    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
1176        let create_dirs = args.create_dirs.unwrap_or(true);
1177        let mut results = Vec::new();
1178        let mut total_bytes = 0usize;
1179        let mut total_lines = 0usize;
1180
1181        // Pre-validate ALL paths before writing ANY files (atomicity)
1182        let mut validated_paths: Vec<(PathBuf, &FileToWrite)> = Vec::new();
1183        let mut invalid_paths: Vec<String> = Vec::new();
1184
1185        for file in &args.files {
1186            let requested_path = PathBuf::from(&file.path);
1187            match self.validate_path(&requested_path) {
1188                Ok(path) => validated_paths.push((path, file)),
1189                Err(_) => invalid_paths.push(file.path.clone()),
1190            }
1191        }
1192
1193        // If any paths are invalid, return error listing all invalid paths
1194        if !invalid_paths.is_empty() {
1195            let invalid_list = invalid_paths.join(", ");
1196            return Ok(format_error_for_llm(
1197                "write_files",
1198                ErrorCategory::PathOutsideBoundary,
1199                &format!("Invalid paths detected: {}", invalid_list),
1200                Some(vec![
1201                    "SECURITY: All paths must be within the project directory",
1202                    "None of the files were written due to invalid paths",
1203                    "For temporary files, create a 'tmp/' directory in project root",
1204                    &format!("Project root: {}", self.project_path.display()),
1205                ]),
1206            ));
1207        }
1208
1209        // Now process all validated files
1210        for (file_path, file) in validated_paths {
1211            // Read existing content for diff
1212            let old_content = if file_path.exists() {
1213                fs::read_to_string(&file_path).ok()
1214            } else {
1215                None
1216            };
1217
1218            // Get filename for pattern matching
1219            let filename = std::path::Path::new(&file.path)
1220                .file_name()
1221                .map(|n| n.to_string_lossy().to_string())
1222                .unwrap_or_else(|| file.path.clone());
1223
1224            // Check if confirmation is needed
1225            let needs_confirmation =
1226                self.require_confirmation && !self.allowed_patterns.is_allowed(&filename);
1227
1228            if needs_confirmation {
1229                // Use IDE diff if client is connected, otherwise terminal diff
1230                let confirmation = if let Some(ref client) = self.ide_client {
1231                    let guard = client.lock().await;
1232                    if guard.is_connected() {
1233                        confirm_file_write_with_ide(
1234                            &file.path,
1235                            old_content.as_deref(),
1236                            &file.content,
1237                            Some(&*guard),
1238                        )
1239                        .await
1240                    } else {
1241                        drop(guard);
1242                        confirm_file_write(&file.path, old_content.as_deref(), &file.content)
1243                    }
1244                } else {
1245                    confirm_file_write(&file.path, old_content.as_deref(), &file.content)
1246                };
1247
1248                match confirmation {
1249                    ConfirmationResult::Proceed => {
1250                        // Continue with this file
1251                    }
1252                    ConfirmationResult::ProceedAlways(pattern) => {
1253                        self.allowed_patterns.allow(pattern);
1254                    }
1255                    ConfirmationResult::Modify(feedback) => {
1256                        // User provided feedback - stop ALL remaining files immediately
1257                        return Ok(format_cancelled(
1258                            &file.path,
1259                            "User requested changes",
1260                            Some(&feedback),
1261                        ));
1262                    }
1263                    ConfirmationResult::Cancel => {
1264                        // User cancelled - stop ALL remaining files immediately
1265                        return Ok(format_cancelled(
1266                            &file.path,
1267                            "User cancelled the operation",
1268                            None,
1269                        ));
1270                    }
1271                }
1272            } else {
1273                // Auto-accept mode: show the diff without requiring confirmation
1274                use crate::agent::ui::diff::{render_diff, render_new_file};
1275                use colored::Colorize;
1276
1277                if let Some(old) = &old_content {
1278                    render_diff(old, &file.content, &file.path);
1279                } else {
1280                    render_new_file(&file.content, &file.path);
1281                }
1282                println!("  {} Auto-accepted", "✓".green());
1283            }
1284
1285            // Create parent directories if needed
1286            if create_dirs
1287                && let Some(parent) = file_path.parent()
1288                && !parent.exists()
1289            {
1290                fs::create_dir_all(parent).map_err(|e| {
1291                    WriteFilesError(format!(
1292                        "Failed to create directories for {}: {}",
1293                        file.path, e
1294                    ))
1295                })?;
1296            }
1297
1298            let file_existed = file_path.exists();
1299
1300            fs::write(&file_path, &file.content)
1301                .map_err(|e| WriteFilesError(format!("Failed to write {}: {}", file.path, e)))?;
1302
1303            let lines = file.content.lines().count();
1304            total_bytes += file.content.len();
1305            total_lines += lines;
1306
1307            results.push(json!({
1308                "path": file.path,
1309                "action": if file_existed { "updated" } else { "created" },
1310                "lines": lines,
1311                "bytes": file.content.len()
1312            }));
1313        }
1314
1315        // If we get here, all files were written successfully
1316        // (cancellations return early with immediate stop message)
1317        let result = json!({
1318            "success": true,
1319            "files_written": results.len(),
1320            "total_lines": total_lines,
1321            "total_bytes": total_bytes,
1322            "files": results
1323        });
1324
1325        serde_json::to_string_pretty(&result)
1326            .map_err(|e| WriteFilesError(format!("Failed to serialize: {}", e)))
1327    }
1328}
1329
1330#[cfg(test)]
1331mod tests {
1332    use super::*;
1333    use tempfile::tempdir;
1334
1335    // =========================================================================
1336    // ReadFileTool tests
1337    // =========================================================================
1338
1339    #[test]
1340    fn test_is_likely_binary_text() {
1341        // Pure ASCII text should not be detected as binary
1342        let text = b"fn main() {\n    println!(\"Hello, world!\");\n}\n";
1343        assert!(!ReadFileTool::is_likely_binary(text));
1344    }
1345
1346    #[test]
1347    fn test_is_likely_binary_with_null() {
1348        // Content with null byte should be detected as binary
1349        let binary = b"some text\x00more text";
1350        assert!(ReadFileTool::is_likely_binary(binary));
1351    }
1352
1353    #[test]
1354    fn test_is_likely_binary_empty() {
1355        // Empty content should not be detected as binary
1356        let empty: &[u8] = b"";
1357        assert!(!ReadFileTool::is_likely_binary(empty));
1358    }
1359
1360    #[test]
1361    fn test_is_likely_binary_utf8() {
1362        // UTF-8 content should not be detected as binary
1363        let utf8 = "日本語テキスト".as_bytes();
1364        assert!(!ReadFileTool::is_likely_binary(utf8));
1365    }
1366
1367    #[tokio::test]
1368    async fn test_read_file_within_project() {
1369        let dir = tempdir().unwrap();
1370        let file_path = dir.path().join("test.txt");
1371        fs::write(&file_path, "Hello, world!").unwrap();
1372
1373        let tool = ReadFileTool::new(dir.path().to_path_buf());
1374        let args = ReadFileArgs {
1375            path: "test.txt".to_string(),
1376            start_line: None,
1377            end_line: None,
1378        };
1379
1380        let result = tool.call(args).await.unwrap();
1381        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1382
1383        assert_eq!(parsed["file"], "test.txt");
1384        assert!(
1385            parsed["content"]
1386                .as_str()
1387                .unwrap()
1388                .contains("Hello, world!")
1389        );
1390    }
1391
1392    #[tokio::test]
1393    async fn test_read_file_not_found() {
1394        let dir = tempdir().unwrap();
1395        let tool = ReadFileTool::new(dir.path().to_path_buf());
1396        let args = ReadFileArgs {
1397            path: "nonexistent.txt".to_string(),
1398            start_line: None,
1399            end_line: None,
1400        };
1401
1402        let result = tool.call(args).await.unwrap();
1403        // Should return error formatted for LLM
1404        assert!(
1405            result.contains("error")
1406                || result.contains("not found")
1407                || result.contains("does not exist")
1408        );
1409    }
1410
1411    // =========================================================================
1412    // ListDirectoryTool tests
1413    // =========================================================================
1414
1415    #[tokio::test]
1416    async fn test_list_directory_basic() {
1417        let dir = tempdir().unwrap();
1418        fs::write(dir.path().join("file1.txt"), "content").unwrap();
1419        fs::write(dir.path().join("file2.txt"), "content").unwrap();
1420        fs::create_dir(dir.path().join("subdir")).unwrap();
1421
1422        let tool = ListDirectoryTool::new(dir.path().to_path_buf());
1423        let args = ListDirectoryArgs {
1424            path: Some(".".to_string()),
1425            recursive: None,
1426        };
1427
1428        let result = tool.call(args).await.unwrap();
1429        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1430
1431        assert!(parsed["entries"].as_array().unwrap().len() >= 2);
1432    }
1433}