agent_sdk/
filesystem.rs

1//! Filesystem implementations for the Environment trait.
2//!
3//! Provides:
4//! - `LocalFileSystem` - Standard filesystem operations using `std::fs`
5//! - `InMemoryFileSystem` - In-memory filesystem for testing
6
7use 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
14/// Local filesystem implementation using `std::fs`
15pub 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, &regex, &mut matches).await?;
149        } else if path.is_dir() {
150            self.grep_dir(&path, &regex, 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: &regex::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: &regex::Regex,
228        recursive: bool,
229        matches: &mut Vec<GrepMatch>,
230    ) -> Result<()> {
231        // Use an iterative approach with explicit queue to avoid stack overflow
232        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; // Skip directories we can't read
237            };
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                    // Skip binary files (simple heuristic)
247                    if let Ok(content) = tokio::fs::read(&path).await
248                        && content.iter().take(1024).any(|&b| b == 0)
249                    {
250                        continue; // Skip binary
251                    }
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
262/// In-memory filesystem for testing
263pub 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        // Create parent directories
332        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        // Check if directory exists and collect file entries
350        {
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            // Find subdirectories
357            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        // Find files in this directory
373        {
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        // Collect all parent directories first
434        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        // Insert all directories at once
445        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        // Check if directory exists
468        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            // Remove all files and subdirs
480            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            // Check if empty first
492            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        // Determine if path is a file or directory
527        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            // Search single file - clone content to release lock early
542            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            // Search directory - collect files to search first
565            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        // Simple glob matching
605        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        // Collect matches from files and dirs - release locks as early as possible
614        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}