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)
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, &regex, &mut matches).await?;
150        } else if path.is_dir() {
151            self.grep_dir(&path, &regex, 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: &regex::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: &regex::Regex,
235        recursive: bool,
236        matches: &mut Vec<GrepMatch>,
237    ) -> Result<()> {
238        // Use an iterative approach with explicit queue to avoid stack overflow
239        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; // Skip directories we can't read
244            };
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                    // Skip binary files (simple heuristic)
254                    if let Ok(content) = tokio::fs::read(&path).await
255                        && content.iter().take(1024).any(|&b| b == 0)
256                    {
257                        continue; // Skip binary
258                    }
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
269/// In-memory filesystem for testing
270pub 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        // Create parent directories
339        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        // Check if directory exists and collect file entries
357        {
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            // Find subdirectories
364            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        // Find files in this directory
380        {
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        // Collect all parent directories first
441        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        // Insert all directories at once
452        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        // Check if directory exists
475        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            // Remove all files and subdirs
487            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            // Check if empty first
499            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        // Determine if path is a file or directory
534        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            // Search single file - clone content to release lock early
549            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            // Search directory - collect files to search first
572            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        // Simple glob matching
612        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        // Collect matches from files and dirs - release locks as early as possible
621        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}