1use anyhow::{Result, bail};
2use chrono::{DateTime, Utc};
3use std::cell::RefCell;
4use std::collections::{BTreeMap, BTreeSet};
5use std::future::Future;
6use std::path::{Path, PathBuf};
7use std::pin::Pin;
8use std::sync::Mutex;
9
10use super::clock::Clock;
11use super::filesystem::{DirEntry, Filesystem};
12use super::git::GitClient;
13use super::llm::LlmClient;
14
15pub struct FakeFilesystem {
27 files: RefCell<BTreeMap<PathBuf, Vec<u8>>>,
28 dirs: RefCell<BTreeSet<PathBuf>>,
29 fail_on_write: RefCell<Option<PathBuf>>,
30 fail_on_rename: RefCell<Option<PathBuf>>,
31 fail_on_copy: RefCell<bool>,
32}
33
34impl FakeFilesystem {
35 pub fn new() -> Self {
36 Self {
37 files: RefCell::new(BTreeMap::new()),
38 dirs: RefCell::new(BTreeSet::new()),
39 fail_on_write: RefCell::new(None),
40 fail_on_rename: RefCell::new(None),
41 fail_on_copy: RefCell::new(false),
42 }
43 }
44
45 pub fn set_fail_on_write(&self, path: impl Into<PathBuf>) {
47 *self.fail_on_write.borrow_mut() = Some(path.into());
48 }
49
50 pub fn set_fail_on_rename(&self, path: impl Into<PathBuf>) {
52 *self.fail_on_rename.borrow_mut() = Some(path.into());
53 }
54
55 pub fn set_fail_on_copy(&self, fail: bool) {
57 *self.fail_on_copy.borrow_mut() = fail;
58 }
59
60 pub fn add_file(&self, path: impl Into<PathBuf>, content: impl Into<Vec<u8>>) {
63 let path = path.into();
64 let mut current = path.parent();
66 while let Some(parent) = current {
67 if parent != Path::new("") {
68 self.dirs.borrow_mut().insert(parent.to_path_buf());
69 }
70 current = parent.parent();
71 }
72 self.files.borrow_mut().insert(path, content.into());
73 }
74
75 pub fn add_dir(&self, path: impl Into<PathBuf>) {
77 self.dirs.borrow_mut().insert(path.into());
78 }
79
80 pub fn get_file_content(&self, path: &Path) -> Option<Vec<u8>> {
82 self.files.borrow().get(path).cloned()
83 }
84
85 pub fn file_exists(&self, path: &Path) -> bool {
87 self.files.borrow().contains_key(path)
88 }
89
90 pub fn snapshot_files(&self) -> BTreeMap<PathBuf, Vec<u8>> {
92 self.files.borrow().clone()
93 }
94}
95
96impl Default for FakeFilesystem {
97 fn default() -> Self {
98 Self::new()
99 }
100}
101
102impl Filesystem for FakeFilesystem {
103 fn exists(&self, path: &Path) -> bool {
104 self.files.borrow().contains_key(path) || self.dirs.borrow().contains(path)
105 }
106
107 fn is_dir(&self, path: &Path) -> bool {
108 self.dirs.borrow().contains(path)
109 }
110
111 fn is_file(&self, path: &Path) -> bool {
112 self.files.borrow().contains_key(path)
113 }
114
115 fn read_to_string(&self, path: &Path) -> Result<String> {
116 match self.files.borrow().get(path).cloned() {
117 Some(bytes) => Ok(String::from_utf8(bytes)?),
118 None => bail!("File not found: {}", path.display()),
119 }
120 }
121
122 fn read(&self, path: &Path) -> Result<Vec<u8>> {
123 match self.files.borrow().get(path).cloned() {
124 Some(bytes) => Ok(bytes),
125 None => bail!("File not found: {}", path.display()),
126 }
127 }
128
129 fn write(&self, path: &Path, contents: &str) -> Result<()> {
130 if self.fail_on_write.borrow().as_deref() == Some(path) {
131 bail!("Failed to write {}", path.display());
132 }
133 self.add_file(path.to_path_buf(), contents.as_bytes().to_vec());
134 Ok(())
135 }
136
137 fn write_bytes(&self, path: &Path, contents: &[u8]) -> Result<()> {
138 self.add_file(path.to_path_buf(), contents.to_vec());
139 Ok(())
140 }
141
142 fn create_dir_all(&self, path: &Path) -> Result<()> {
143 let mut current = Some(path);
144 while let Some(p) = current {
145 if p != Path::new("") {
146 self.dirs.borrow_mut().insert(p.to_path_buf());
147 }
148 current = p.parent();
149 }
150 Ok(())
151 }
152
153 fn copy_file(&self, src: &Path, dest: &Path) -> Result<()> {
154 if *self.fail_on_copy.borrow() {
155 bail!(
156 "FakeFilesystem: copy_file configured to fail ({} -> {})",
157 src.display(),
158 dest.display()
159 );
160 }
161 let bytes = self
162 .files
163 .borrow()
164 .get(src)
165 .cloned()
166 .ok_or_else(|| anyhow::anyhow!("Source file not found: {}", src.display()))?;
167 self.add_file(dest.to_path_buf(), bytes);
168 Ok(())
169 }
170
171 fn rename(&self, from: &Path, to: &Path) -> Result<()> {
172 if self.fail_on_rename.borrow().as_deref() == Some(to) {
173 bail!("FakeFilesystem: rename configured to fail for {}", to.display());
174 }
175 let bytes = self.files.borrow().get(from).cloned().ok_or_else(|| {
176 anyhow::anyhow!("Source file not found for rename: {}", from.display())
177 })?;
178 self.add_file(to.to_path_buf(), bytes);
179 self.files.borrow_mut().remove(from);
180 Ok(())
181 }
182
183 fn remove_file(&self, path: &Path) -> Result<()> {
184 if self.files.borrow_mut().remove(path).is_none() {
185 bail!("File not found: {}", path.display());
186 }
187 Ok(())
188 }
189
190 fn remove_dir_all(&self, path: &Path) -> Result<()> {
191 let prefix = path.to_path_buf();
193 self.files.borrow_mut().retain(|k, _| !k.starts_with(&prefix));
194 self.dirs.borrow_mut().retain(|k| !k.starts_with(&prefix));
196 Ok(())
197 }
198
199 fn read_dir(&self, path: &Path) -> Result<Vec<DirEntry>> {
200 if !self.dirs.borrow().contains(path) && !self.files.borrow().contains_key(path) {
201 bail!("Failed to read directory {}", path.display());
202 }
203
204 let mut seen: BTreeSet<PathBuf> = BTreeSet::new();
205 let mut entries = Vec::new();
206
207 for file_path in self.files.borrow().keys() {
209 if let Some(parent) = file_path.parent()
210 && parent == path
211 {
212 let file_name = file_path
213 .file_name()
214 .expect("file path with a matched parent must have a final component")
215 .to_string_lossy()
216 .to_string();
217 if seen.insert(file_path.clone()) {
218 entries.push(DirEntry {
219 path: file_path.clone(),
220 file_name,
221 is_dir: false,
222 });
223 }
224 }
225 }
226
227 for dir_path in self.dirs.borrow().clone() {
229 if let Some(parent) = dir_path.parent()
230 && parent == path
231 && seen.insert(dir_path.clone())
232 {
233 let file_name = dir_path
234 .file_name()
235 .expect("dir path with a matched parent must have a final component")
236 .to_string_lossy()
237 .to_string();
238 entries.push(DirEntry {
239 path: dir_path,
240 file_name,
241 is_dir: true,
242 });
243 }
244 }
245
246 Ok(entries)
247 }
248
249 fn canonicalize(&self, path: &Path) -> Result<PathBuf> {
250 Ok(path.to_path_buf())
252 }
253}
254
255pub struct FakeGitClient {
261 pub cloned: RefCell<Vec<(String, PathBuf)>>,
262 pub pulled: RefCell<Vec<PathBuf>>,
263 pub should_fail: bool,
264}
265
266impl FakeGitClient {
267 pub fn new() -> Self {
268 Self {
269 cloned: RefCell::new(Vec::new()),
270 pulled: RefCell::new(Vec::new()),
271 should_fail: false,
272 }
273 }
274}
275
276impl Default for FakeGitClient {
277 fn default() -> Self {
278 Self::new()
279 }
280}
281
282impl GitClient for FakeGitClient {
283 fn clone_repo(&self, url: &str, dest: &Path) -> Result<()> {
284 if self.should_fail {
285 bail!("FakeGitClient: clone_repo configured to fail");
286 }
287 self.cloned.borrow_mut().push((url.to_string(), dest.to_path_buf()));
288 Ok(())
289 }
290
291 fn pull(&self, repo_path: &Path) -> Result<()> {
292 if self.should_fail {
293 bail!("FakeGitClient: pull configured to fail");
294 }
295 self.pulled.borrow_mut().push(repo_path.to_path_buf());
296 Ok(())
297 }
298}
299
300pub struct FakeClock {
306 pub now: DateTime<Utc>,
307}
308
309impl FakeClock {
310 pub fn at(now: DateTime<Utc>) -> Self {
311 Self { now }
312 }
313}
314
315impl Clock for FakeClock {
316 fn now(&self) -> DateTime<Utc> {
317 self.now
318 }
319}
320
321pub struct FakeLlmClient {
328 pub response: String,
329 pub should_fail: bool,
330 pub calls: Mutex<Vec<(String, String)>>,
331}
332
333impl FakeLlmClient {
334 pub fn new(response: impl Into<String>) -> Self {
335 Self {
336 response: response.into(),
337 should_fail: false,
338 calls: Mutex::new(Vec::new()),
339 }
340 }
341
342 pub fn last_call(&self) -> Option<(String, String)> {
344 self.calls.lock().unwrap().last().cloned()
345 }
346
347 pub fn all_calls(&self) -> Vec<(String, String)> {
349 self.calls.lock().unwrap().clone()
350 }
351}
352
353impl LlmClient for FakeLlmClient {
354 fn analyze(
355 &self,
356 system_prompt: &str,
357 user_prompt: &str,
358 ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + '_>> {
359 self.calls
360 .lock()
361 .unwrap()
362 .push((system_prompt.to_string(), user_prompt.to_string()));
363 let should_fail = self.should_fail;
364 let response = self.response.clone();
365 Box::pin(async move {
366 if should_fail {
367 bail!("FakeLlmClient: analyze configured to fail");
368 }
369 Ok(response)
370 })
371 }
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 #[test]
379 fn fake_filesystem_add_file_and_exists() {
380 let fs = FakeFilesystem::new();
381 let path = PathBuf::from("/home/user/test.txt");
382 fs.add_file(path.clone(), b"hello".to_vec());
383 assert!(fs.exists(&path));
384 assert!(fs.is_file(&path));
385 assert!(!fs.is_dir(&path));
386 }
387
388 #[test]
389 fn fake_filesystem_add_file_registers_parent_dirs() {
390 let fs = FakeFilesystem::new();
391 fs.add_file("/home/user/subdir/file.txt", "content");
392 assert!(fs.is_dir(&PathBuf::from("/home/user/subdir")));
393 assert!(fs.is_dir(&PathBuf::from("/home/user")));
394 assert!(fs.is_dir(&PathBuf::from("/home")));
395 }
396
397 #[test]
398 fn fake_filesystem_read_to_string_returns_content() {
399 let fs = FakeFilesystem::new();
400 fs.add_file("/tmp/a.txt", "hello world");
401 let content = fs.read_to_string(Path::new("/tmp/a.txt")).unwrap();
402 assert_eq!(content, "hello world");
403 }
404
405 #[test]
406 fn fake_filesystem_read_missing_file_errors() {
407 let fs = FakeFilesystem::new();
408 assert!(fs.read_to_string(Path::new("/nonexistent.txt")).is_err());
409 }
410
411 #[test]
412 fn fake_filesystem_write_then_read() {
413 let fs = FakeFilesystem::new();
414 let path = PathBuf::from("/tmp/out.txt");
415 fs.write(&path, "written content").unwrap();
416 assert_eq!(fs.read_to_string(&path).unwrap(), "written content");
417 }
418
419 #[test]
420 fn fake_filesystem_remove_file() {
421 let fs = FakeFilesystem::new();
422 let path = PathBuf::from("/tmp/to_remove.txt");
423 fs.add_file(path.clone(), "data");
424 fs.remove_file(&path).unwrap();
425 assert!(!fs.exists(&path));
426 }
427
428 #[test]
429 fn fake_filesystem_remove_dir_all_removes_children() {
430 let fs = FakeFilesystem::new();
431 fs.add_file("/skills/my-skill/SKILL.md", "---\n---\n");
432 fs.add_file("/skills/my-skill/tool.py", "code");
433 fs.remove_dir_all(Path::new("/skills/my-skill")).unwrap();
434 assert!(!fs.exists(Path::new("/skills/my-skill/SKILL.md")));
435 assert!(!fs.exists(Path::new("/skills/my-skill/tool.py")));
436 }
437
438 #[test]
439 fn fake_filesystem_read_dir_lists_children() {
440 let fs = FakeFilesystem::new();
441 fs.add_file("/agents/alpha.md", "# agent");
442 fs.add_file("/agents/beta.md", "# agent");
443 let entries = fs.read_dir(Path::new("/agents")).unwrap();
444 let names: BTreeSet<_> = entries.iter().map(|e| e.file_name.as_str()).collect();
445 assert!(names.contains("alpha.md"));
446 assert!(names.contains("beta.md"));
447 }
448
449 #[test]
450 fn fake_git_client_records_clone() {
451 let git = FakeGitClient::new();
452 git.clone_repo("https://example.com/repo.git", Path::new("/tmp/repo")).unwrap();
453 let cloned = git.cloned.borrow();
454 assert_eq!(cloned.len(), 1);
455 assert_eq!(cloned[0].0, "https://example.com/repo.git");
456 }
457
458 #[test]
459 fn fake_git_client_records_pull() {
460 let git = FakeGitClient::new();
461 git.pull(Path::new("/tmp/repo")).unwrap();
462 let pulled = git.pulled.borrow();
463 assert_eq!(pulled.len(), 1);
464 assert_eq!(pulled[0], PathBuf::from("/tmp/repo"));
465 }
466
467 #[test]
468 fn fake_clock_returns_fixed_time() {
469 use chrono::TimeZone;
470 let fixed = Utc.with_ymd_and_hms(2024, 6, 1, 12, 0, 0).unwrap();
471 let clock = FakeClock::at(fixed);
472 assert_eq!(clock.now(), fixed);
473 }
474
475 #[tokio::test]
476 async fn fake_llm_client_returns_canned_response() {
477 let client = FakeLlmClient::new("This is the analysis.");
478 let result = client.analyze("system", "user").await.unwrap();
479 assert_eq!(result, "This is the analysis.");
480 }
481
482 #[tokio::test]
483 async fn fake_llm_client_captures_prompts() {
484 let client = FakeLlmClient::new("result");
485 assert!(client.last_call().is_none(), "no calls yet");
486 client.analyze("sys", "usr").await.unwrap();
487 assert_eq!(
488 client.last_call(),
489 Some(("sys".to_string(), "usr".to_string())),
490 "captures the (system, user) pair"
491 );
492 client.analyze("sys2", "usr2").await.unwrap();
493 assert_eq!(client.all_calls().len(), 2, "accumulates calls");
494 assert_eq!(
495 client.last_call(),
496 Some(("sys2".to_string(), "usr2".to_string())),
497 "last_call returns the most recent"
498 );
499 }
500
501 #[test]
502 fn fake_filesystem_write_fails_on_configured_path() {
503 let fs = FakeFilesystem::new();
504 let fail_path = PathBuf::from("/config/restricted.json");
505 let other_path = PathBuf::from("/config/allowed.json");
506
507 fs.set_fail_on_write(fail_path.clone());
508
509 assert!(fs.write(&fail_path, "data").is_err());
511
512 assert!(fs.write(&other_path, "data").is_ok());
514 assert_eq!(fs.read_to_string(&other_path).unwrap(), "data");
515 }
516
517 #[test]
518 fn fake_filesystem_copy_fails_when_configured() {
519 let fs = FakeFilesystem::new();
520 let src = PathBuf::from("/src/file.txt");
521 let dest = PathBuf::from("/dest/file.txt");
522 fs.add_file(src.clone(), "content");
523
524 fs.set_fail_on_copy(true);
525
526 assert!(fs.copy_file(&src, &dest).is_err());
527 assert!(!fs.file_exists(&dest));
529 }
530
531 #[tokio::test]
532 async fn fake_llm_client_fails_when_configured() {
533 let client = FakeLlmClient {
534 response: "unreachable".to_string(),
535 should_fail: true,
536 calls: Mutex::new(Vec::new()),
537 };
538 let result = client.analyze("system", "user").await;
539 assert!(result.is_err());
540 let msg = result.unwrap_err().to_string();
541 assert!(msg.contains("configured to fail"), "unexpected: {msg}");
542 }
543
544 #[test]
545 fn fake_filesystem_rename_moves_file_and_removes_source() {
546 let fs = FakeFilesystem::new();
547 let from = PathBuf::from("/tmp/source.txt");
548 let to = PathBuf::from("/tmp/dest.txt");
549 fs.add_file(from.clone(), "content");
550
551 fs.rename(&from, &to).unwrap();
552
553 assert!(!fs.file_exists(&from), "source should be removed after rename");
554 assert!(fs.file_exists(&to), "destination should exist after rename");
555 assert_eq!(fs.read_to_string(&to).unwrap(), "content");
556 }
557
558 #[test]
559 fn fake_filesystem_rename_replaces_existing_destination() {
560 let fs = FakeFilesystem::new();
561 let from = PathBuf::from("/tmp/source.txt");
562 let to = PathBuf::from("/tmp/dest.txt");
563 fs.add_file(from.clone(), "new content");
564 fs.add_file(to.clone(), "old content");
565
566 fs.rename(&from, &to).unwrap();
567
568 assert!(!fs.file_exists(&from));
569 assert_eq!(fs.read_to_string(&to).unwrap(), "new content");
570 }
571
572 #[test]
573 fn fake_filesystem_rename_fails_on_configured_destination() {
574 let fs = FakeFilesystem::new();
575 let from = PathBuf::from("/tmp/source.txt");
576 let to = PathBuf::from("/tmp/restricted.txt");
577 fs.add_file(from.clone(), "content");
578 fs.set_fail_on_rename(to.clone());
579
580 assert!(fs.rename(&from, &to).is_err());
581 assert!(fs.file_exists(&from), "source should remain after failed rename");
583 }
584
585 #[test]
586 fn fake_filesystem_rename_errors_when_source_absent() {
587 let fs = FakeFilesystem::new();
588 let from = PathBuf::from("/tmp/nonexistent.txt");
589 let to = PathBuf::from("/tmp/dest.txt");
590
591 let result = fs.rename(&from, &to);
592 assert!(result.is_err());
593 let msg = result.unwrap_err().to_string();
594 assert!(msg.contains("nonexistent.txt"), "unexpected: {msg}");
595 }
596}