Skip to main content

sgr_agent_tools/
mock_fs.rs

1//! MockFs — in-memory `FileBackend` for testing.
2//!
3//! No disk I/O, fully deterministic, instant.
4//!
5//! ```rust,ignore
6//! use sgr_agent_tools::{MockFs, ReadTool, WriteTool};
7//!
8//! let fs = Arc::new(MockFs::new());
9//! fs.add_file("readme.md", "# Hello");
10//! fs.add_file("src/main.rs", "fn main() {}");
11//!
12//! let read = ReadTool(fs.clone());
13//! let write = WriteTool(fs.clone());
14//! ```
15
16use std::collections::BTreeMap;
17use std::sync::RwLock;
18
19use anyhow::{Result, bail};
20
21use sgr_agent_core::backend::FileBackend;
22
23/// In-memory filesystem for testing. Thread-safe via RwLock.
24pub struct MockFs {
25    files: RwLock<BTreeMap<String, String>>,
26    context_value: RwLock<String>,
27}
28
29impl MockFs {
30    pub fn new() -> Self {
31        Self {
32            files: RwLock::new(BTreeMap::new()),
33            context_value: RwLock::new("2026-01-15 10:00:00".to_string()),
34        }
35    }
36
37    /// Pre-populate a file.
38    pub fn add_file(&self, path: &str, content: &str) {
39        self.files
40            .write()
41            .unwrap()
42            .insert(normalize(path), content.to_string());
43    }
44
45    /// Set what context() returns.
46    pub fn set_context(&self, value: &str) {
47        *self.context_value.write().unwrap() = value.to_string();
48    }
49
50    /// Get all files as snapshot (for assertions).
51    pub fn snapshot(&self) -> BTreeMap<String, String> {
52        self.files.read().unwrap().clone()
53    }
54
55    /// Check if file exists.
56    pub fn exists(&self, path: &str) -> bool {
57        self.files.read().unwrap().contains_key(&normalize(path))
58    }
59
60    /// Get file content (for assertions).
61    pub fn content(&self, path: &str) -> Option<String> {
62        self.files.read().unwrap().get(&normalize(path)).cloned()
63    }
64}
65
66impl Default for MockFs {
67    fn default() -> Self {
68        Self::new()
69    }
70}
71
72fn normalize(path: &str) -> String {
73    path.trim_start_matches('/').to_string()
74}
75
76#[async_trait::async_trait]
77impl FileBackend for MockFs {
78    async fn read(
79        &self,
80        path: &str,
81        number: bool,
82        start_line: i32,
83        end_line: i32,
84    ) -> Result<String> {
85        let files = self.files.read().unwrap();
86        let content = files
87            .get(&normalize(path))
88            .ok_or_else(|| anyhow::anyhow!("file not found: {path}"))?;
89
90        let lines: Vec<&str> = content.lines().collect();
91        let start = if start_line > 0 {
92            (start_line - 1) as usize
93        } else {
94            0
95        };
96        let end = if end_line > 0 {
97            (end_line as usize).min(lines.len())
98        } else {
99            lines.len()
100        };
101
102        let mut out = String::new();
103        for (i, line) in lines[start..end].iter().enumerate() {
104            if number {
105                use std::fmt::Write;
106                let _ = write!(out, "{}\t{}\n", start + i + 1, line);
107            } else {
108                out.push_str(line);
109                out.push('\n');
110            }
111        }
112        Ok(out)
113    }
114
115    async fn write(&self, path: &str, content: &str, start_line: i32, end_line: i32) -> Result<()> {
116        let key = normalize(path);
117        let mut files = self.files.write().unwrap();
118
119        if start_line > 0 && end_line > 0 {
120            let existing = files.get(&key).cloned().unwrap_or_default();
121            let mut lines: Vec<&str> = existing.lines().collect();
122            let start = (start_line - 1) as usize;
123            let end = (end_line as usize).min(lines.len());
124            let new_lines: Vec<&str> = content.lines().collect();
125            lines.splice(start..end, new_lines);
126            files.insert(key, lines.join("\n") + "\n");
127        } else {
128            files.insert(key, content.to_string());
129        }
130        Ok(())
131    }
132
133    async fn delete(&self, path: &str) -> Result<()> {
134        let key = normalize(path);
135        let mut files = self.files.write().unwrap();
136        if files.remove(&key).is_none() {
137            bail!("file not found: {path}");
138        }
139        Ok(())
140    }
141
142    async fn search(&self, root: &str, pattern: &str, limit: i32) -> Result<String> {
143        let re = regex::Regex::new(pattern)?;
144        let files = self.files.read().unwrap();
145        let root_norm = normalize(root);
146        let max = if limit > 0 { limit as usize } else { 500 };
147
148        let mut out = String::new();
149        let mut count = 0;
150        for (path, content) in files.iter() {
151            if !root_norm.is_empty() && root_norm != "/" && !path.starts_with(&root_norm) {
152                continue;
153            }
154            for (i, line) in content.lines().enumerate() {
155                if count >= max {
156                    return Ok(out);
157                }
158                if re.is_match(line) {
159                    use std::fmt::Write;
160                    let _ = write!(out, "{}:{}:{}\n", path, i + 1, line);
161                    count += 1;
162                }
163            }
164        }
165        Ok(out)
166    }
167
168    async fn list(&self, path: &str) -> Result<String> {
169        let files = self.files.read().unwrap();
170        let prefix = normalize(path);
171        let prefix = if prefix.is_empty() || prefix == "/" {
172            String::new()
173        } else {
174            format!("{prefix}/")
175        };
176
177        let mut entries = std::collections::BTreeSet::new();
178        for key in files.keys() {
179            if let Some(rest) = key.strip_prefix(&prefix) {
180                if let Some(slash) = rest.find('/') {
181                    entries.insert(format!("{}/", &rest[..slash]));
182                } else {
183                    entries.insert(rest.to_string());
184                }
185            } else if prefix.is_empty() {
186                if let Some(slash) = key.find('/') {
187                    entries.insert(format!("{}/", &key[..slash]));
188                } else {
189                    entries.insert(key.clone());
190                }
191            }
192        }
193
194        let mut out = format!("$ ls {path}\n");
195        for entry in entries {
196            out.push_str(&entry);
197            out.push('\n');
198        }
199        Ok(out)
200    }
201
202    async fn tree(&self, _root: &str, _level: i32) -> Result<String> {
203        let files = self.files.read().unwrap();
204        let mut out = String::new();
205        for key in files.keys() {
206            out.push_str(key);
207            out.push('\n');
208        }
209        Ok(out)
210    }
211
212    async fn context(&self) -> Result<String> {
213        Ok(self.context_value.read().unwrap().clone())
214    }
215
216    async fn mkdir(&self, _path: &str) -> Result<()> {
217        Ok(()) // directories are implicit in mock
218    }
219
220    async fn move_file(&self, from: &str, to: &str) -> Result<()> {
221        let mut files = self.files.write().unwrap();
222        let content = files
223            .remove(&normalize(from))
224            .ok_or_else(|| anyhow::anyhow!("file not found: {from}"))?;
225        files.insert(normalize(to), content);
226        Ok(())
227    }
228
229    async fn find(&self, root: &str, name: &str, file_type: &str, _limit: i32) -> Result<String> {
230        let files = self.files.read().unwrap();
231        let root_norm = normalize(root);
232        let _ = file_type; // mock doesn't distinguish files/dirs
233
234        let results: Vec<&str> = files
235            .keys()
236            .filter(|k| {
237                (root_norm.is_empty() || root_norm == "/" || k.starts_with(&root_norm))
238                    && k.contains(name)
239            })
240            .map(|k| k.as_str())
241            .collect();
242        Ok(results.join("\n"))
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[tokio::test]
251    async fn basic_crud() {
252        let fs = MockFs::new();
253        fs.add_file("hello.txt", "world");
254
255        let content = fs.read("hello.txt", false, 0, 0).await.unwrap();
256        assert_eq!(content.trim(), "world");
257
258        fs.write("hello.txt", "updated", 0, 0).await.unwrap();
259        assert_eq!(fs.content("hello.txt").unwrap(), "updated");
260
261        fs.delete("hello.txt").await.unwrap();
262        assert!(!fs.exists("hello.txt"));
263    }
264
265    #[tokio::test]
266    async fn search_mock() {
267        let fs = MockFs::new();
268        fs.add_file("a.txt", "hello world\nfoo bar");
269        fs.add_file("b.txt", "baz qux");
270
271        let results = fs.search("/", "hello", 10).await.unwrap();
272        assert!(results.contains("a.txt:1:hello world"));
273        assert!(!results.contains("b.txt"));
274    }
275
276    #[tokio::test]
277    async fn list_mock() {
278        let fs = MockFs::new();
279        fs.add_file("readme.md", "hi");
280        fs.add_file("src/main.rs", "fn main() {}");
281        fs.add_file("src/lib.rs", "pub mod foo;");
282
283        let listing = fs.list("/").await.unwrap();
284        assert!(listing.contains("readme.md"));
285        assert!(listing.contains("src/"));
286
287        let src_listing = fs.list("src").await.unwrap();
288        assert!(src_listing.contains("main.rs"));
289        assert!(src_listing.contains("lib.rs"));
290    }
291
292    #[tokio::test]
293    async fn move_file_mock() {
294        let fs = MockFs::new();
295        fs.add_file("old.txt", "data");
296
297        fs.move_file("old.txt", "new.txt").await.unwrap();
298        assert!(!fs.exists("old.txt"));
299        assert_eq!(fs.content("new.txt").unwrap(), "data");
300    }
301
302    #[tokio::test]
303    async fn ranged_read() {
304        let fs = MockFs::new();
305        fs.add_file("test.txt", "line1\nline2\nline3\nline4");
306
307        let range = fs.read("test.txt", true, 2, 3).await.unwrap();
308        assert!(range.contains("2\tline2"));
309        assert!(range.contains("3\tline3"));
310        assert!(!range.contains("line1"));
311        assert!(!range.contains("line4"));
312    }
313
314    #[tokio::test]
315    async fn context_mock() {
316        let fs = MockFs::new();
317        assert!(fs.context().await.unwrap().contains("2026"));
318
319        fs.set_context("2030-12-25 00:00:00");
320        assert!(fs.context().await.unwrap().contains("2030"));
321    }
322
323    #[tokio::test]
324    async fn snapshot() {
325        let fs = MockFs::new();
326        fs.add_file("a.txt", "1");
327        fs.add_file("b.txt", "2");
328
329        let snap = fs.snapshot();
330        assert_eq!(snap.len(), 2);
331        assert_eq!(snap["a.txt"], "1");
332    }
333}