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
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        // Remove all files whose path starts with `path`
187        let prefix = path.to_path_buf();
188        self.files.borrow_mut().retain(|k, _| !k.starts_with(&prefix));
189        // Remove all dirs whose path starts with `path`
190        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        // Collect immediate children from files
203        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        // Collect immediate children from dirs
223        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        // In tests we treat the path as already canonical
246        Ok(path.to_path_buf())
247    }
248}
249
250// ---------------------------------------------------------------------------
251// FakeGitClient
252// ---------------------------------------------------------------------------
253
254/// Records git operations without executing them.
255pub 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
295// ---------------------------------------------------------------------------
296// FakeClock
297// ---------------------------------------------------------------------------
298
299/// Always returns the same instant.
300pub 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
316// ---------------------------------------------------------------------------
317// FakeLlmClient
318// ---------------------------------------------------------------------------
319
320/// Returns a canned response string, or fails if `should_fail` is set.
321/// Also records every `(system_prompt, user_prompt)` pair passed to `analyze`.
322pub 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    /// Return the most recent `(system_prompt, user_prompt)` pair, if any.
338    pub fn last_call(&self) -> Option<(String, String)> {
339        self.calls.lock().unwrap().last().cloned()
340    }
341
342    /// Return all recorded `(system_prompt, user_prompt)` pairs in call order.
343    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        // Write to the configured fail path returns Err
505        assert!(fs.write(&fail_path, "data").is_err());
506
507        // Write to a different path still succeeds
508        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        // Verify nothing was copied
523        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        // Source file should be untouched after failed rename
577        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}