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