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