1use 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#[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#[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#[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#[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#[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#[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
71pub struct FileTools {
73 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 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 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 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 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 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(®ex, path, recursive, &mut matches)?;
188 Ok(matches)
189 }
190
191 fn search_in_path(
192 regex: ®ex::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 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 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 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 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 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}