Skip to main content

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::{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
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        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, &regex, &mut matches).await?;
144        } else if path.is_dir() {
145            self.grep_dir(&path, &regex, 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: &regex::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: &regex::Regex,
229        recursive: bool,
230        matches: &mut Vec<GrepMatch>,
231    ) -> Result<()> {
232        // Use an iterative approach with explicit queue to avoid stack overflow
233        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; // Skip directories we can't read
238            };
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                    // Skip binary files (simple heuristic)
248                    if let Ok(content) = tokio::fs::read(&path).await
249                        && content.iter().take(1024).any(|&b| b == 0)
250                    {
251                        continue; // Skip binary
252                    }
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
263/// In-memory filesystem for testing
264pub 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        // Create parent directories
333        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        // Check if directory exists and collect file entries
351        {
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            // Find subdirectories
358            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        // Find files in this directory
374        {
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        // Collect all parent directories first
435        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        // Insert all directories at once
446        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        // Check if directory exists
469        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            // Remove all files and subdirs
481            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            // Check if empty first
493            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        // Determine if path is a file or directory
528        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            // Search single file - clone content to release lock early
543            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            // Search directory - collect files to search first
566            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        // Simple glob matching
606        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        // Collect matches from files and dirs - release locks as early as possible
615        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}