Skip to main content

cmx_core/gateway/
fakes.rs

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
15// ---------------------------------------------------------------------------
16// FakeFilesystem
17// ---------------------------------------------------------------------------
18
19/// In-memory filesystem for tests.
20///
21/// Files are stored as byte vectors keyed by their (absolute or relative)
22/// path.  Directory entries are derived automatically from file paths.  All
23/// methods that mutate state accept `&self` via interior mutability
24/// (`RefCell`) so that test helpers and trait implementations share the same
25/// borrow.
26pub 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    /// Cause the next `write()` to the given path to return an error.
46    pub fn set_fail_on_write(&self, path: impl Into<PathBuf>) {
47        *self.fail_on_write.borrow_mut() = Some(path.into());
48    }
49
50    /// Cause `rename()` targeting the given destination path to return an error.
51    pub fn set_fail_on_rename(&self, path: impl Into<PathBuf>) {
52        *self.fail_on_rename.borrow_mut() = Some(path.into());
53    }
54
55    /// Cause all `copy_file()` calls to return an error.
56    pub fn set_fail_on_copy(&self, fail: bool) {
57        *self.fail_on_copy.borrow_mut() = fail;
58    }
59
60    /// Insert a file with the given content, automatically registering all
61    /// ancestor directories.
62    pub fn add_file(&self, path: impl Into<PathBuf>, content: impl Into<Vec<u8>>) {
63        let path = path.into();
64        // Register all ancestor directories
65        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    /// Explicitly register a directory path.
76    pub fn add_dir(&self, path: impl Into<PathBuf>) {
77        self.dirs.borrow_mut().insert(path.into());
78    }
79
80    /// Return the stored content for a path, or `None` if absent.
81    pub fn get_file_content(&self, path: &Path) -> Option<Vec<u8>> {
82        self.files.borrow().get(path).cloned()
83    }
84
85    /// Return true if the path has been added as a file.
86    pub fn file_exists(&self, path: &Path) -> bool {
87        self.files.borrow().contains_key(path)
88    }
89
90    /// Return a sorted snapshot of every file currently stored in the fake filesystem.
91    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        // Remove all files whose path starts with `path`
192        let prefix = path.to_path_buf();
193        self.files.borrow_mut().retain(|k, _| !k.starts_with(&prefix));
194        // Remove all dirs whose path starts with `path`
195        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        // Collect immediate children from files
208        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        // Collect immediate children from dirs
228        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        // In tests we treat the path as already canonical
251        Ok(path.to_path_buf())
252    }
253}
254
255// ---------------------------------------------------------------------------
256// FakeGitClient
257// ---------------------------------------------------------------------------
258
259/// Records git operations without executing them.
260pub 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
300// ---------------------------------------------------------------------------
301// FakeClock
302// ---------------------------------------------------------------------------
303
304/// Always returns the same instant.
305pub 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
321// ---------------------------------------------------------------------------
322// FakeLlmClient
323// ---------------------------------------------------------------------------
324
325/// Returns a canned response string, or fails if `should_fail` is set.
326/// Also records every `(system_prompt, user_prompt)` pair passed to `analyze`.
327pub 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    /// Return the most recent `(system_prompt, user_prompt)` pair, if any.
343    pub fn last_call(&self) -> Option<(String, String)> {
344        self.calls.lock().unwrap().last().cloned()
345    }
346
347    /// Return all recorded `(system_prompt, user_prompt)` pairs in call order.
348    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        // Write to the configured fail path returns Err
510        assert!(fs.write(&fail_path, "data").is_err());
511
512        // Write to a different path still succeeds
513        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        // Verify nothing was copied
528        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        // Source file should be untouched after failed rename
582        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}