sgr_agent_tools/
mock_fs.rs1use std::collections::BTreeMap;
17use std::sync::RwLock;
18
19use anyhow::{Result, bail};
20
21use sgr_agent_core::backend::FileBackend;
22
23pub 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 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 pub fn set_context(&self, value: &str) {
47 *self.context_value.write().unwrap() = value.to_string();
48 }
49
50 pub fn snapshot(&self) -> BTreeMap<String, String> {
52 self.files.read().unwrap().clone()
53 }
54
55 pub fn exists(&self, path: &str) -> bool {
57 self.files.read().unwrap().contains_key(&normalize(path))
58 }
59
60 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(()) }
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; 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}