1use crate::environment::{self, Environment, ExecResult, FileEntry, GrepMatch};
8use anyhow::{Context, Result};
9use async_trait::async_trait;
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::sync::RwLock;
13
14pub struct LocalFileSystem {
16 root: PathBuf,
17}
18
19impl LocalFileSystem {
20 #[must_use]
21 pub fn new(root: impl Into<PathBuf>) -> Self {
22 Self { root: root.into() }
23 }
24
25 fn resolve(&self, path: &str) -> PathBuf {
26 let joined = if Path::new(path).is_absolute() {
27 PathBuf::from(path)
28 } else {
29 self.root.join(path)
30 };
31 environment::normalize_path_buf(&joined)
32 }
33}
34
35#[async_trait]
36impl Environment for LocalFileSystem {
37 async fn read_file(&self, path: &str) -> Result<String> {
38 let path = self.resolve(path);
39 tokio::fs::read_to_string(&path)
40 .await
41 .with_context(|| format!("Failed to read file: {}", path.display()))
42 }
43
44 async fn read_file_bytes(&self, path: &str) -> Result<Vec<u8>> {
45 let path = self.resolve(path);
46 tokio::fs::read(&path)
47 .await
48 .with_context(|| format!("Failed to read file: {}", path.display()))
49 }
50
51 async fn write_file(&self, path: &str, content: &str) -> Result<()> {
52 let path = self.resolve(path);
53 if let Some(parent) = path.parent() {
54 tokio::fs::create_dir_all(parent).await?;
55 }
56 tokio::fs::write(&path, content)
57 .await
58 .with_context(|| format!("Failed to write file: {}", path.display()))
59 }
60
61 async fn write_file_bytes(&self, path: &str, content: &[u8]) -> Result<()> {
62 let path = self.resolve(path);
63 if let Some(parent) = path.parent() {
64 tokio::fs::create_dir_all(parent).await?;
65 }
66 tokio::fs::write(&path, content)
67 .await
68 .with_context(|| format!("Failed to write file: {}", path.display()))
69 }
70
71 async fn list_dir(&self, path: &str) -> Result<Vec<FileEntry>> {
72 let path = self.resolve(path);
73 let mut entries = Vec::new();
74 let mut dir = tokio::fs::read_dir(&path)
75 .await
76 .with_context(|| format!("Failed to read directory: {}", path.display()))?;
77
78 while let Some(entry) = dir.next_entry().await? {
79 let metadata = entry.metadata().await?;
80 entries.push(FileEntry {
81 name: entry.file_name().to_string_lossy().to_string(),
82 path: entry.path().to_string_lossy().to_string(),
83 is_dir: metadata.is_dir(),
84 size: if metadata.is_file() {
85 Some(metadata.len())
86 } else {
87 None
88 },
89 });
90 }
91
92 Ok(entries)
93 }
94
95 async fn exists(&self, path: &str) -> Result<bool> {
96 let path = self.resolve(path);
97 Ok(tokio::fs::try_exists(&path).await.unwrap_or(false))
98 }
99
100 async fn is_dir(&self, path: &str) -> Result<bool> {
101 let path = self.resolve(path);
102 Ok(tokio::fs::metadata(&path)
103 .await
104 .map(|m| m.is_dir())
105 .unwrap_or(false))
106 }
107
108 async fn is_file(&self, path: &str) -> Result<bool> {
109 let path = self.resolve(path);
110 Ok(tokio::fs::metadata(&path)
111 .await
112 .map(|m| m.is_file())
113 .unwrap_or(false))
114 }
115
116 async fn create_dir(&self, path: &str) -> Result<()> {
117 let path = self.resolve(path);
118 tokio::fs::create_dir_all(&path)
119 .await
120 .with_context(|| format!("Failed to create directory: {}", path.display()))
121 }
122
123 async fn delete_file(&self, path: &str) -> Result<()> {
124 let path = self.resolve(path);
125 tokio::fs::remove_file(&path)
126 .await
127 .with_context(|| format!("Failed to delete file: {}", path.display()))
128 }
129
130 async fn delete_dir(&self, path: &str, recursive: bool) -> Result<()> {
131 let path = self.resolve(path);
132 if recursive {
133 tokio::fs::remove_dir_all(&path)
134 .await
135 .with_context(|| format!("Failed to delete directory: {}", path.display()))
136 } else {
137 tokio::fs::remove_dir(&path)
138 .await
139 .with_context(|| format!("Failed to delete directory: {}", path.display()))
140 }
141 }
142
143 async fn grep(&self, pattern: &str, path: &str, recursive: bool) -> Result<Vec<GrepMatch>> {
144 let path = self.resolve(path);
145 let regex = regex::Regex::new(pattern).context("Invalid regex pattern")?;
146 let mut matches = Vec::new();
147
148 if path.is_file() {
149 self.grep_file(&path, ®ex, &mut matches).await?;
150 } else if path.is_dir() {
151 self.grep_dir(&path, ®ex, recursive, &mut matches)
152 .await?;
153 }
154
155 Ok(matches)
156 }
157
158 async fn glob(&self, pattern: &str) -> Result<Vec<String>> {
159 let pattern_path = self.resolve(pattern);
160 let pattern_str = pattern_path.to_string_lossy();
161
162 let paths: Vec<String> = glob::glob(&pattern_str)
163 .context("Invalid glob pattern")?
164 .filter_map(std::result::Result::ok)
165 .map(|p| p.to_string_lossy().to_string())
166 .collect();
167
168 Ok(paths)
169 }
170
171 async fn exec(&self, command: &str, timeout_ms: Option<u64>) -> Result<ExecResult> {
172 use std::process::Stdio;
173 use tokio::process::Command;
174
175 let timeout = std::time::Duration::from_millis(timeout_ms.unwrap_or(120_000));
176
177 let output = tokio::time::timeout(
178 timeout,
179 Command::new("sh")
180 .arg("-c")
181 .arg(command)
182 .current_dir(&self.root)
183 .stdout(Stdio::piped())
184 .stderr(Stdio::piped())
185 .output(),
186 )
187 .await
188 .context("Command timed out")?
189 .context("Failed to execute command")?;
190
191 Ok(ExecResult {
192 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
193 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
194 exit_code: output.status.code().unwrap_or(-1),
195 })
196 }
197
198 fn root(&self) -> &str {
199 self.root.to_str().unwrap_or_else(|| {
200 log::error!(
201 "LocalFileSystem root path contains invalid UTF-8: {}",
202 self.root.to_string_lossy()
203 );
204 "/"
205 })
206 }
207}
208
209impl LocalFileSystem {
210 async fn grep_file(
211 &self,
212 path: &Path,
213 regex: ®ex::Regex,
214 matches: &mut Vec<GrepMatch>,
215 ) -> Result<()> {
216 let content = tokio::fs::read_to_string(path).await?;
217 for (line_num, line) in content.lines().enumerate() {
218 if let Some(m) = regex.find(line) {
219 matches.push(GrepMatch {
220 path: path.to_string_lossy().to_string(),
221 line_number: line_num + 1,
222 line_content: line.to_string(),
223 match_start: m.start(),
224 match_end: m.end(),
225 });
226 }
227 }
228 Ok(())
229 }
230
231 async fn grep_dir(
232 &self,
233 start_dir: &Path,
234 regex: ®ex::Regex,
235 recursive: bool,
236 matches: &mut Vec<GrepMatch>,
237 ) -> Result<()> {
238 let mut dirs_to_process = vec![start_dir.to_path_buf()];
240
241 while let Some(dir) = dirs_to_process.pop() {
242 let Ok(mut entries) = tokio::fs::read_dir(&dir).await else {
243 continue; };
245
246 while let Ok(Some(entry)) = entries.next_entry().await {
247 let path = entry.path();
248 let Ok(metadata) = entry.metadata().await else {
249 continue;
250 };
251
252 if metadata.is_file() {
253 if let Ok(content) = tokio::fs::read(&path).await
255 && content.iter().take(1024).any(|&b| b == 0)
256 {
257 continue; }
259 let _ = self.grep_file(&path, regex, matches).await;
260 } else if metadata.is_dir() && recursive {
261 dirs_to_process.push(path);
262 }
263 }
264 }
265 Ok(())
266 }
267}
268
269pub struct InMemoryFileSystem {
271 root: String,
272 files: RwLock<HashMap<String, Vec<u8>>>,
273 dirs: RwLock<std::collections::HashSet<String>>,
274}
275
276impl InMemoryFileSystem {
277 #[must_use]
278 pub fn new(root: impl Into<String>) -> Self {
279 let root = root.into();
280 let dirs = RwLock::new({
281 let mut set = std::collections::HashSet::new();
282 set.insert(root.clone());
283 set
284 });
285 Self {
286 root,
287 files: RwLock::new(HashMap::new()),
288 dirs,
289 }
290 }
291
292 fn normalize_path(&self, path: &str) -> String {
293 if path.starts_with('/') {
294 path.to_string()
295 } else {
296 format!("{}/{}", self.root.trim_end_matches('/'), path)
297 }
298 }
299
300 fn parent_dir(path: &str) -> Option<String> {
301 Path::new(path)
302 .parent()
303 .map(|p| p.to_string_lossy().to_string())
304 }
305}
306
307#[async_trait]
308impl Environment for InMemoryFileSystem {
309 async fn read_file(&self, path: &str) -> Result<String> {
310 let path = self.normalize_path(path);
311 self.files
312 .read()
313 .ok()
314 .context("lock poisoned")?
315 .get(&path)
316 .map(|bytes| String::from_utf8_lossy(bytes).to_string())
317 .ok_or_else(|| anyhow::anyhow!("File not found: {path}"))
318 }
319
320 async fn read_file_bytes(&self, path: &str) -> Result<Vec<u8>> {
321 let path = self.normalize_path(path);
322 self.files
323 .read()
324 .ok()
325 .context("lock poisoned")?
326 .get(&path)
327 .cloned()
328 .ok_or_else(|| anyhow::anyhow!("File not found: {path}"))
329 }
330
331 async fn write_file(&self, path: &str, content: &str) -> Result<()> {
332 self.write_file_bytes(path, content.as_bytes()).await
333 }
334
335 async fn write_file_bytes(&self, path: &str, content: &[u8]) -> Result<()> {
336 let path = self.normalize_path(path);
337
338 if let Some(parent) = Self::parent_dir(&path) {
340 self.create_dir(&parent).await?;
341 }
342
343 self.files
344 .write()
345 .ok()
346 .context("lock poisoned")?
347 .insert(path, content.to_vec());
348 Ok(())
349 }
350
351 async fn list_dir(&self, path: &str) -> Result<Vec<FileEntry>> {
352 let path = self.normalize_path(path);
353 let prefix = format!("{}/", path.trim_end_matches('/'));
354 let mut entries = Vec::new();
355
356 {
358 let dirs = self.dirs.read().ok().context("lock poisoned")?;
359 if !dirs.contains(&path) {
360 anyhow::bail!("Directory not found: {path}");
361 }
362
363 for dir_path in dirs.iter() {
365 if dir_path.starts_with(&prefix) && dir_path != &path {
366 let relative = &dir_path[prefix.len()..];
367 if !relative.contains('/') {
368 entries.push(FileEntry {
369 name: relative.to_string(),
370 path: dir_path.clone(),
371 is_dir: true,
372 size: None,
373 });
374 }
375 }
376 }
377 }
378
379 {
381 let files = self.files.read().ok().context("lock poisoned")?;
382 for (file_path, content) in files.iter() {
383 if file_path.starts_with(&prefix) {
384 let relative = &file_path[prefix.len()..];
385 if !relative.contains('/') {
386 entries.push(FileEntry {
387 name: relative.to_string(),
388 path: file_path.clone(),
389 is_dir: false,
390 size: Some(content.len() as u64),
391 });
392 }
393 }
394 }
395 }
396
397 Ok(entries)
398 }
399
400 async fn exists(&self, path: &str) -> Result<bool> {
401 let path = self.normalize_path(path);
402 let in_files = self
403 .files
404 .read()
405 .ok()
406 .context("lock poisoned")?
407 .contains_key(&path);
408 let in_dirs = self
409 .dirs
410 .read()
411 .ok()
412 .context("lock poisoned")?
413 .contains(&path);
414 Ok(in_files || in_dirs)
415 }
416
417 async fn is_dir(&self, path: &str) -> Result<bool> {
418 let path = self.normalize_path(path);
419 Ok(self
420 .dirs
421 .read()
422 .ok()
423 .context("lock poisoned")?
424 .contains(&path))
425 }
426
427 async fn is_file(&self, path: &str) -> Result<bool> {
428 let path = self.normalize_path(path);
429 Ok(self
430 .files
431 .read()
432 .ok()
433 .context("lock poisoned")?
434 .contains_key(&path))
435 }
436
437 async fn create_dir(&self, path: &str) -> Result<()> {
438 let path = self.normalize_path(path);
439
440 let mut current = String::new();
442 let dirs_to_create: Vec<String> = path
443 .split('/')
444 .filter(|p| !p.is_empty())
445 .map(|part| {
446 current = format!("{current}/{part}");
447 current.clone()
448 })
449 .collect();
450
451 for dir in dirs_to_create {
453 self.dirs.write().ok().context("lock poisoned")?.insert(dir);
454 }
455
456 Ok(())
457 }
458
459 async fn delete_file(&self, path: &str) -> Result<()> {
460 let path = self.normalize_path(path);
461 self.files
462 .write()
463 .ok()
464 .context("lock poisoned")?
465 .remove(&path)
466 .ok_or_else(|| anyhow::anyhow!("File not found: {path}"))?;
467 Ok(())
468 }
469
470 async fn delete_dir(&self, path: &str, recursive: bool) -> Result<()> {
471 let path = self.normalize_path(path);
472 let prefix = format!("{}/", path.trim_end_matches('/'));
473
474 if !self
476 .dirs
477 .read()
478 .ok()
479 .context("lock poisoned")?
480 .contains(&path)
481 {
482 anyhow::bail!("Directory not found: {path}");
483 }
484
485 if recursive {
486 self.files
488 .write()
489 .ok()
490 .context("lock poisoned")?
491 .retain(|k, _| !k.starts_with(&prefix));
492 self.dirs
493 .write()
494 .ok()
495 .context("lock poisoned")?
496 .retain(|k| !k.starts_with(&prefix) && k != &path);
497 } else {
498 let has_files = self
500 .files
501 .read()
502 .ok()
503 .context("lock poisoned")?
504 .keys()
505 .any(|k| k.starts_with(&prefix));
506 let has_subdirs = self
507 .dirs
508 .read()
509 .ok()
510 .context("lock poisoned")?
511 .iter()
512 .any(|k| k.starts_with(&prefix) && k != &path);
513
514 if has_files || has_subdirs {
515 anyhow::bail!("Directory not empty: {path}");
516 }
517
518 self.dirs
519 .write()
520 .ok()
521 .context("lock poisoned")?
522 .remove(&path);
523 }
524
525 Ok(())
526 }
527
528 async fn grep(&self, pattern: &str, path: &str, recursive: bool) -> Result<Vec<GrepMatch>> {
529 let path = self.normalize_path(path);
530 let regex = regex::Regex::new(pattern).context("Invalid regex pattern")?;
531 let mut matches = Vec::new();
532
533 let is_file = self
535 .files
536 .read()
537 .ok()
538 .context("lock poisoned")?
539 .contains_key(&path);
540 let is_dir = self
541 .dirs
542 .read()
543 .ok()
544 .context("lock poisoned")?
545 .contains(&path);
546
547 if is_file {
548 let content = self
550 .files
551 .read()
552 .ok()
553 .context("lock poisoned")?
554 .get(&path)
555 .cloned();
556 if let Some(content) = content {
557 let content = String::from_utf8_lossy(&content);
558 for (line_num, line) in content.lines().enumerate() {
559 if let Some(m) = regex.find(line) {
560 matches.push(GrepMatch {
561 path: path.clone(),
562 line_number: line_num + 1,
563 line_content: line.to_string(),
564 match_start: m.start(),
565 match_end: m.end(),
566 });
567 }
568 }
569 }
570 } else if is_dir {
571 let prefix = format!("{}/", path.trim_end_matches('/'));
573 let files_to_search: Vec<_> = {
574 let files = self.files.read().ok().context("lock poisoned")?;
575 files
576 .iter()
577 .filter(|(file_path, _)| {
578 if recursive {
579 file_path.starts_with(&prefix)
580 } else {
581 file_path.starts_with(&prefix)
582 && !file_path[prefix.len()..].contains('/')
583 }
584 })
585 .map(|(k, v)| (k.clone(), v.clone()))
586 .collect()
587 };
588
589 for (file_path, content) in files_to_search {
590 let content = String::from_utf8_lossy(&content);
591 for (line_num, line) in content.lines().enumerate() {
592 if let Some(m) = regex.find(line) {
593 matches.push(GrepMatch {
594 path: file_path.clone(),
595 line_number: line_num + 1,
596 line_content: line.to_string(),
597 match_start: m.start(),
598 match_end: m.end(),
599 });
600 }
601 }
602 }
603 }
604
605 Ok(matches)
606 }
607
608 async fn glob(&self, pattern: &str) -> Result<Vec<String>> {
609 let pattern = self.normalize_path(pattern);
610
611 let regex_pattern = pattern
613 .replace("**", "\x00")
614 .replace('*', "[^/]*")
615 .replace('\x00', ".*")
616 .replace('?', ".");
617 let regex =
618 regex::Regex::new(&format!("^{regex_pattern}$")).context("Invalid glob pattern")?;
619
620 let mut matches: Vec<String> = self
622 .files
623 .read()
624 .ok()
625 .context("lock poisoned")?
626 .keys()
627 .filter(|p| regex.is_match(p))
628 .cloned()
629 .collect();
630
631 matches.extend(
632 self.dirs
633 .read()
634 .ok()
635 .context("lock poisoned")?
636 .iter()
637 .filter(|p| regex.is_match(p))
638 .cloned(),
639 );
640
641 matches.sort();
642 matches.dedup();
643 Ok(matches)
644 }
645
646 fn root(&self) -> &str {
647 &self.root
648 }
649}
650
651#[cfg(test)]
652mod tests {
653 use super::*;
654
655 #[tokio::test]
656 async fn test_in_memory_write_and_read() -> Result<()> {
657 let fs = InMemoryFileSystem::new("/workspace");
658
659 fs.write_file("test.txt", "Hello, World!").await?;
660 let content = fs.read_file("test.txt").await?;
661
662 assert_eq!(content, "Hello, World!");
663 Ok(())
664 }
665
666 #[tokio::test]
667 async fn test_in_memory_exists() -> Result<()> {
668 let fs = InMemoryFileSystem::new("/workspace");
669
670 assert!(!fs.exists("test.txt").await?);
671 fs.write_file("test.txt", "content").await?;
672 assert!(fs.exists("test.txt").await?);
673 Ok(())
674 }
675
676 #[tokio::test]
677 async fn test_in_memory_directories() -> Result<()> {
678 let fs = InMemoryFileSystem::new("/workspace");
679
680 fs.create_dir("src/lib").await?;
681 assert!(fs.is_dir("src").await?);
682 assert!(fs.is_dir("src/lib").await?);
683 assert!(!fs.is_file("src").await?);
684 Ok(())
685 }
686
687 #[tokio::test]
688 async fn test_in_memory_list_dir() -> Result<()> {
689 let fs = InMemoryFileSystem::new("/workspace");
690
691 fs.write_file("file1.txt", "content1").await?;
692 fs.write_file("file2.txt", "content2").await?;
693 fs.create_dir("subdir").await?;
694
695 let entries = fs.list_dir("/workspace").await?;
696 assert_eq!(entries.len(), 3);
697
698 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
699 assert!(names.contains(&"file1.txt"));
700 assert!(names.contains(&"file2.txt"));
701 assert!(names.contains(&"subdir"));
702 Ok(())
703 }
704
705 #[tokio::test]
706 async fn test_in_memory_grep() -> Result<()> {
707 let fs = InMemoryFileSystem::new("/workspace");
708
709 fs.write_file("test.rs", "fn main() {\n println!(\"Hello\");\n}")
710 .await?;
711
712 let matches = fs.grep("println", "/workspace", true).await?;
713 assert_eq!(matches.len(), 1);
714 assert_eq!(matches[0].line_number, 2);
715 assert!(matches[0].line_content.contains("println"));
716 Ok(())
717 }
718
719 #[tokio::test]
720 async fn test_in_memory_glob() -> Result<()> {
721 let fs = InMemoryFileSystem::new("/workspace");
722
723 fs.write_file("src/main.rs", "fn main() {}").await?;
724 fs.write_file("src/lib.rs", "pub mod foo;").await?;
725 fs.write_file("tests/test.rs", "// test").await?;
726
727 let matches = fs.glob("/workspace/src/*.rs").await?;
728 assert_eq!(matches.len(), 2);
729 Ok(())
730 }
731
732 #[tokio::test]
733 async fn test_in_memory_delete() -> Result<()> {
734 let fs = InMemoryFileSystem::new("/workspace");
735
736 fs.write_file("test.txt", "content").await?;
737 assert!(fs.exists("test.txt").await?);
738
739 fs.delete_file("test.txt").await?;
740 assert!(!fs.exists("test.txt").await?);
741 Ok(())
742 }
743}