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