gestura_core_tools/
file.rs

1//! File operations tool
2//!
3//! Provides file system operations with structured output.
4//! All functions return data structures rather than formatted strings.
5
6use crate::error::{AppError, Result};
7use serde::{Deserialize, Serialize};
8use std::collections::HashSet;
9use std::fs;
10use std::path::{Path, PathBuf};
11use std::sync::RwLock;
12
13/// Result of reading a file
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct FileReadResult {
16    pub path: PathBuf,
17    pub content: String,
18    pub line_count: usize,
19    pub start_line: usize,
20    pub end_line: usize,
21}
22
23/// Result of writing a file
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct FileWriteResult {
26    pub path: PathBuf,
27    pub bytes_written: usize,
28    pub created: bool,
29    pub changed: bool,
30}
31
32/// Result of editing a file
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct FileEditResult {
35    pub path: PathBuf,
36    pub replacements: usize,
37    pub old_content: String,
38    pub new_content: String,
39    pub changed: bool,
40}
41
42/// A file entry in directory listing
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct FileEntry {
45    pub path: PathBuf,
46    pub name: String,
47    pub is_dir: bool,
48    pub size: Option<u64>,
49    pub modified: Option<chrono::DateTime<chrono::Utc>>,
50}
51
52/// Search match result
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct SearchMatch {
55    pub path: PathBuf,
56    pub line_number: usize,
57    pub line_content: String,
58    pub match_start: usize,
59    pub match_end: usize,
60}
61
62/// Directory tree node
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct TreeNode {
65    pub path: PathBuf,
66    pub name: String,
67    pub is_dir: bool,
68    pub children: Vec<TreeNode>,
69}
70
71/// File operations service
72pub struct FileTools {
73    /// Files currently in context
74    context: RwLock<HashSet<PathBuf>>,
75}
76
77impl Default for FileTools {
78    fn default() -> Self {
79        Self::new()
80    }
81}
82
83impl FileTools {
84    pub fn new() -> Self {
85        Self {
86            context: RwLock::new(HashSet::new()),
87        }
88    }
89
90    /// Read file contents, optionally with line range
91    pub fn read(
92        &self,
93        path: &Path,
94        start_line: Option<usize>,
95        end_line: Option<usize>,
96    ) -> Result<FileReadResult> {
97        if !path.exists() {
98            return Err(AppError::Io(std::io::Error::new(
99                std::io::ErrorKind::NotFound,
100                format!("File not found: {}", path.display()),
101            )));
102        }
103
104        let content = fs::read_to_string(path)?;
105        let lines: Vec<&str> = content.lines().collect();
106        let total_lines = lines.len();
107
108        let start = start_line.unwrap_or(1).saturating_sub(1);
109        let end = end_line.unwrap_or(total_lines).min(total_lines);
110
111        let selected_content = lines[start..end].join("\n");
112
113        Ok(FileReadResult {
114            path: path.to_path_buf(),
115            content: selected_content,
116            line_count: total_lines,
117            start_line: start + 1,
118            end_line: end,
119        })
120    }
121
122    /// Write content to file
123    pub fn write(&self, path: &Path, content: &str) -> Result<FileWriteResult> {
124        let created = !path.exists();
125        let prior_bytes = (!created).then(|| fs::read(path)).transpose()?;
126        let changed = created || prior_bytes.as_deref() != Some(content.as_bytes());
127
128        // Create parent directories if needed
129        if let Some(parent) = path.parent()
130            && !parent.exists()
131        {
132            fs::create_dir_all(parent)?;
133        }
134
135        if changed {
136            fs::write(path, content)?;
137        }
138
139        Ok(FileWriteResult {
140            path: path.to_path_buf(),
141            bytes_written: content.len(),
142            created,
143            changed,
144        })
145    }
146
147    /// Edit file by replacing text
148    pub fn edit(&self, path: &Path, old_str: &str, new_str: &str) -> Result<FileEditResult> {
149        if !path.exists() {
150            return Err(AppError::Io(std::io::Error::new(
151                std::io::ErrorKind::NotFound,
152                format!("File not found: {}", path.display()),
153            )));
154        }
155
156        let old_content = fs::read_to_string(path)?;
157        let replacements = old_content.matches(old_str).count();
158
159        if replacements == 0 {
160            return Err(AppError::Io(std::io::Error::new(
161                std::io::ErrorKind::NotFound,
162                "String to replace not found in file",
163            )));
164        }
165
166        let new_content = old_content.replace(old_str, new_str);
167        let changed = old_content != new_content;
168        if changed {
169            fs::write(path, &new_content)?;
170        }
171
172        Ok(FileEditResult {
173            path: path.to_path_buf(),
174            replacements,
175            old_content,
176            new_content,
177            changed,
178        })
179    }
180
181    /// Search for pattern in files
182    pub fn search(&self, pattern: &str, path: &Path, recursive: bool) -> Result<Vec<SearchMatch>> {
183        let regex = regex::Regex::new(pattern)
184            .map_err(|e| AppError::Io(std::io::Error::other(format!("Invalid regex: {e}"))))?;
185
186        let mut matches = Vec::new();
187        Self::search_in_path(&regex, path, recursive, &mut matches)?;
188        Ok(matches)
189    }
190
191    fn search_in_path(
192        regex: &regex::Regex,
193        path: &Path,
194        recursive: bool,
195        matches: &mut Vec<SearchMatch>,
196    ) -> Result<()> {
197        if path.is_file() {
198            if let Ok(content) = fs::read_to_string(path) {
199                for (line_num, line) in content.lines().enumerate() {
200                    if let Some(m) = regex.find(line) {
201                        matches.push(SearchMatch {
202                            path: path.to_path_buf(),
203                            line_number: line_num + 1,
204                            line_content: line.to_string(),
205                            match_start: m.start(),
206                            match_end: m.end(),
207                        });
208                    }
209                }
210            }
211        } else if path.is_dir() {
212            for entry in fs::read_dir(path)? {
213                let entry = entry?;
214                let entry_path = entry.path();
215                if entry_path.is_file() {
216                    Self::search_in_path(regex, &entry_path, false, matches)?;
217                } else if recursive && entry_path.is_dir() {
218                    Self::search_in_path(regex, &entry_path, true, matches)?;
219                }
220            }
221        }
222        Ok(())
223    }
224
225    /// List files in directory
226    pub fn list(&self, path: &Path, show_hidden: bool) -> Result<Vec<FileEntry>> {
227        if !path.is_dir() {
228            return Err(AppError::Io(std::io::Error::new(
229                std::io::ErrorKind::NotADirectory,
230                format!("Not a directory: {}", path.display()),
231            )));
232        }
233
234        let mut entries = Vec::new();
235        for entry in fs::read_dir(path)? {
236            let entry = entry?;
237            let name = entry.file_name().to_string_lossy().to_string();
238
239            if !show_hidden && name.starts_with('.') {
240                continue;
241            }
242
243            let metadata = entry.metadata().ok();
244            let modified = metadata
245                .as_ref()
246                .and_then(|m| m.modified().ok())
247                .map(chrono::DateTime::from);
248
249            entries.push(FileEntry {
250                path: entry.path(),
251                name,
252                is_dir: entry.path().is_dir(),
253                size: metadata.as_ref().map(|m| m.len()),
254                modified,
255            });
256        }
257
258        entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
259            (true, false) => std::cmp::Ordering::Less,
260            (false, true) => std::cmp::Ordering::Greater,
261            _ => a.name.cmp(&b.name),
262        });
263
264        Ok(entries)
265    }
266
267    /// Build directory tree.
268    ///
269    /// If `show_hidden` is false, dotfiles/directories (names starting with '.')
270    /// are skipped.
271    pub fn tree(
272        &self,
273        path: &Path,
274        max_depth: Option<usize>,
275        show_hidden: bool,
276    ) -> Result<TreeNode> {
277        Self::build_tree(path, 0, max_depth.unwrap_or(3), show_hidden)
278    }
279
280    fn build_tree(
281        path: &Path,
282        depth: usize,
283        max_depth: usize,
284        show_hidden: bool,
285    ) -> Result<TreeNode> {
286        let name = path
287            .file_name()
288            .map(|n| n.to_string_lossy().to_string())
289            .unwrap_or_else(|| path.display().to_string());
290
291        let mut node = TreeNode {
292            path: path.to_path_buf(),
293            name,
294            is_dir: path.is_dir(),
295            children: Vec::new(),
296        };
297
298        if path.is_dir()
299            && depth < max_depth
300            && let Ok(entries) = fs::read_dir(path)
301        {
302            for entry in entries.flatten() {
303                let entry_path = entry.path();
304                let entry_name = entry.file_name().to_string_lossy().to_string();
305
306                if !show_hidden && entry_name.starts_with('.') {
307                    continue;
308                }
309
310                if let Ok(child) = Self::build_tree(&entry_path, depth + 1, max_depth, show_hidden)
311                {
312                    node.children.push(child);
313                }
314            }
315            node.children.sort_by(|a, b| match (a.is_dir, b.is_dir) {
316                (true, false) => std::cmp::Ordering::Less,
317                (false, true) => std::cmp::Ordering::Greater,
318                _ => a.name.cmp(&b.name),
319            });
320        }
321
322        Ok(node)
323    }
324
325    /// Add files to context
326    pub fn add_to_context(&self, paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
327        let mut context = self
328            .context
329            .write()
330            .map_err(|e| AppError::Io(std::io::Error::other(format!("Lock error: {e}"))))?;
331
332        let mut added = Vec::new();
333        for path in paths {
334            if path.exists() {
335                context.insert(path.clone());
336                added.push(path.clone());
337            }
338        }
339        Ok(added)
340    }
341
342    /// Remove files from context
343    pub fn remove_from_context(&self, paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
344        let mut context = self
345            .context
346            .write()
347            .map_err(|e| AppError::Io(std::io::Error::other(format!("Lock error: {e}"))))?;
348
349        let mut removed = Vec::new();
350        for path in paths {
351            if context.remove(path) {
352                removed.push(path.clone());
353            }
354        }
355        Ok(removed)
356    }
357
358    /// Get current context
359    pub fn get_context(&self) -> Result<Vec<PathBuf>> {
360        let context = self
361            .context
362            .read()
363            .map_err(|e| AppError::Io(std::io::Error::other(format!("Lock error: {e}"))))?;
364        Ok(context.iter().cloned().collect())
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use std::fs;
372    use tempfile::TempDir;
373
374    fn setup_test_dir() -> TempDir {
375        let dir = TempDir::new().unwrap();
376        fs::write(dir.path().join("test.txt"), "line 1\nline 2\nline 3\n").unwrap();
377        fs::write(
378            dir.path().join("hello.rs"),
379            "fn main() {\n    println!(\"Hello\");\n}\n",
380        )
381        .unwrap();
382        fs::create_dir(dir.path().join("subdir")).unwrap();
383        fs::write(dir.path().join("subdir/nested.txt"), "nested content").unwrap();
384        dir
385    }
386
387    #[test]
388    fn test_read_file() {
389        let dir = setup_test_dir();
390        let tools = FileTools::new();
391        let result = tools
392            .read(&dir.path().join("test.txt"), None, None)
393            .unwrap();
394        assert_eq!(result.line_count, 3);
395        assert!(result.content.contains("line 1"));
396    }
397
398    #[test]
399    fn test_read_file_with_range() {
400        let dir = setup_test_dir();
401        let tools = FileTools::new();
402        let result = tools
403            .read(&dir.path().join("test.txt"), Some(2), Some(2))
404            .unwrap();
405        assert_eq!(result.start_line, 2);
406        assert_eq!(result.end_line, 2);
407        assert!(result.content.contains("line 2"));
408    }
409
410    #[test]
411    fn test_write_file() {
412        let dir = setup_test_dir();
413        let tools = FileTools::new();
414        let path = dir.path().join("new_file.txt");
415        let result = tools.write(&path, "new content").unwrap();
416        assert!(result.created);
417        assert!(result.changed);
418        assert_eq!(result.bytes_written, 11);
419        assert_eq!(fs::read_to_string(&path).unwrap(), "new content");
420    }
421
422    #[test]
423    fn test_write_file_reports_unchanged_when_content_matches() {
424        let dir = setup_test_dir();
425        let tools = FileTools::new();
426        let path = dir.path().join("same.txt");
427        fs::write(&path, "same content").unwrap();
428
429        let result = tools.write(&path, "same content").unwrap();
430
431        assert!(!result.created);
432        assert!(!result.changed);
433        assert_eq!(fs::read_to_string(&path).unwrap(), "same content");
434    }
435
436    #[test]
437    fn test_edit_file_reports_unchanged_when_replacement_is_identical() {
438        let dir = setup_test_dir();
439        let tools = FileTools::new();
440        let path = dir.path().join("edit_same.txt");
441        fs::write(&path, "hello world\n").unwrap();
442
443        let result = tools.edit(&path, "world", "world").unwrap();
444
445        assert_eq!(result.replacements, 1);
446        assert!(!result.changed);
447        assert_eq!(fs::read_to_string(&path).unwrap(), "hello world\n");
448    }
449
450    #[test]
451    fn test_list_directory() {
452        let dir = setup_test_dir();
453        let tools = FileTools::new();
454        let entries = tools.list(dir.path(), false).unwrap();
455        assert!(entries.len() >= 3);
456        assert!(entries.iter().any(|e| e.name == "test.txt"));
457        assert!(entries.iter().any(|e| e.name == "subdir" && e.is_dir));
458    }
459
460    #[test]
461    fn test_search_files() {
462        let dir = setup_test_dir();
463        let tools = FileTools::new();
464        let matches = tools.search("line", dir.path(), false).unwrap();
465        assert!(!matches.is_empty());
466        assert!(matches.iter().any(|m| m.line_content.contains("line 1")));
467    }
468
469    #[test]
470    fn test_context_management() {
471        let dir = setup_test_dir();
472        let tools = FileTools::new();
473        let file1 = dir.path().join("test.txt");
474        let file2 = dir.path().join("hello.rs");
475        let paths = vec![file1.clone(), file2.clone()];
476
477        let added = tools.add_to_context(&paths).unwrap();
478        assert_eq!(added.len(), 2);
479
480        let context = tools.get_context().unwrap();
481        assert_eq!(context.len(), 2);
482
483        let removed = tools.remove_from_context(&[file1]).unwrap();
484        assert_eq!(removed.len(), 1);
485
486        let context = tools.get_context().unwrap();
487        assert_eq!(context.len(), 1);
488    }
489
490    #[test]
491    fn test_tree() {
492        let dir = setup_test_dir();
493        let tools = FileTools::new();
494        let tree = tools.tree(dir.path(), Some(2), false).unwrap();
495        assert!(tree.is_dir);
496        assert!(!tree.children.is_empty());
497    }
498
499    #[test]
500    fn test_tree_with_hidden() {
501        let dir = setup_test_dir();
502        std::fs::write(dir.path().join(".hidden.txt"), "secret").unwrap();
503
504        let tools = FileTools::new();
505        let tree = tools.tree(dir.path(), Some(1), true).unwrap();
506        assert!(tree.children.iter().any(|c| c.name == ".hidden.txt"));
507    }
508}