Skip to main content

cmx_core/gateway/
real.rs

1use anyhow::{Context, Result, bail};
2use chrono::{DateTime, Utc};
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6use super::clock::Clock;
7use super::filesystem::{DirEntry, Filesystem};
8use super::git::GitClient;
9
10#[cfg(feature = "llm")]
11use mojentic::llm::gateways::{OllamaGateway, OpenAIGateway};
12#[cfg(feature = "llm")]
13use mojentic::llm::{LlmBroker, LlmGateway, LlmMessage};
14#[cfg(feature = "llm")]
15use std::future::Future;
16#[cfg(feature = "llm")]
17use std::pin::Pin;
18#[cfg(feature = "llm")]
19use std::sync::Arc;
20
21#[cfg(feature = "llm")]
22use super::llm::LlmClient;
23#[cfg(feature = "llm")]
24use crate::types::{LlmConfig, LlmGatewayType};
25
26// ---------------------------------------------------------------------------
27// RealFilesystem
28// ---------------------------------------------------------------------------
29
30/// Production implementation of [`Filesystem`] that delegates to `std::fs`.
31pub struct RealFilesystem;
32
33impl Filesystem for RealFilesystem {
34    fn exists(&self, path: &Path) -> bool {
35        path.exists()
36    }
37
38    fn is_dir(&self, path: &Path) -> bool {
39        path.is_dir()
40    }
41
42    fn is_file(&self, path: &Path) -> bool {
43        path.is_file()
44    }
45
46    fn read_to_string(&self, path: &Path) -> Result<String> {
47        std::fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))
48    }
49
50    fn read(&self, path: &Path) -> Result<Vec<u8>> {
51        std::fs::read(path).with_context(|| format!("Failed to read {}", path.display()))
52    }
53
54    fn write(&self, path: &Path, contents: &str) -> Result<()> {
55        std::fs::write(path, contents)
56            .with_context(|| format!("Failed to write {}", path.display()))
57    }
58
59    fn write_bytes(&self, path: &Path, contents: &[u8]) -> Result<()> {
60        std::fs::write(path, contents)
61            .with_context(|| format!("Failed to write {}", path.display()))
62    }
63
64    fn create_dir_all(&self, path: &Path) -> Result<()> {
65        std::fs::create_dir_all(path)
66            .with_context(|| format!("Failed to create directory {}", path.display()))
67    }
68
69    fn copy_file(&self, src: &Path, dest: &Path) -> Result<()> {
70        std::fs::copy(src, dest)
71            .with_context(|| format!("Failed to copy {} to {}", src.display(), dest.display()))?;
72        Ok(())
73    }
74
75    fn rename(&self, from: &Path, to: &Path) -> Result<()> {
76        std::fs::rename(from, to)
77            .with_context(|| format!("Failed to rename {} to {}", from.display(), to.display()))
78    }
79
80    fn remove_file(&self, path: &Path) -> Result<()> {
81        std::fs::remove_file(path)
82            .with_context(|| format!("Failed to remove file {}", path.display()))
83    }
84
85    fn remove_dir_all(&self, path: &Path) -> Result<()> {
86        std::fs::remove_dir_all(path)
87            .with_context(|| format!("Failed to remove directory {}", path.display()))
88    }
89
90    fn read_dir(&self, path: &Path) -> Result<Vec<DirEntry>> {
91        let entries = std::fs::read_dir(path)
92            .with_context(|| format!("Failed to read directory {}", path.display()))?;
93
94        let mut result = Vec::new();
95        for entry in entries {
96            let entry =
97                entry.with_context(|| format!("Failed to read entry in {}", path.display()))?;
98            let file_name = entry.file_name().to_string_lossy().to_string();
99            let entry_path = entry.path();
100            let is_dir = entry_path.is_dir();
101            result.push(DirEntry {
102                path: entry_path,
103                file_name,
104                is_dir,
105            });
106        }
107
108        Ok(result)
109    }
110
111    fn canonicalize(&self, path: &Path) -> Result<PathBuf> {
112        path.canonicalize()
113            .with_context(|| format!("Failed to canonicalize {}", path.display()))
114    }
115}
116
117// ---------------------------------------------------------------------------
118// RealGitClient
119// ---------------------------------------------------------------------------
120
121/// Production implementation of [`GitClient`] that shells out to `git`.
122pub struct RealGitClient;
123
124impl GitClient for RealGitClient {
125    fn clone_repo(&self, url: &str, dest: &Path) -> Result<()> {
126        let output = Command::new("git")
127            .args(["clone", url, &dest.display().to_string()])
128            .output()
129            .context("Failed to run git clone")?;
130
131        if !output.status.success() {
132            let stderr = String::from_utf8_lossy(&output.stderr);
133            bail!("git clone failed: {stderr}");
134        }
135
136        Ok(())
137    }
138
139    fn pull(&self, repo_path: &Path) -> Result<()> {
140        let output = Command::new("git")
141            .args(["-C", &repo_path.display().to_string(), "pull", "--quiet"])
142            .output()
143            .context("Failed to run git pull")?;
144
145        if !output.status.success() {
146            let stderr = String::from_utf8_lossy(&output.stderr);
147            bail!("git pull failed: {stderr}");
148        }
149
150        Ok(())
151    }
152}
153
154// ---------------------------------------------------------------------------
155// SystemClock
156// ---------------------------------------------------------------------------
157
158/// Production implementation of [`Clock`] that returns `Utc::now()`.
159pub struct SystemClock;
160
161impl Clock for SystemClock {
162    fn now(&self) -> DateTime<Utc> {
163        Utc::now()
164    }
165}
166
167// ---------------------------------------------------------------------------
168// MojenticLlmClient
169// ---------------------------------------------------------------------------
170
171/// Production [`LlmClient`] that delegates to the mojentic LLM library.
172#[cfg(feature = "llm")]
173pub struct MojenticLlmClient {
174    config: LlmConfig,
175}
176
177#[cfg(feature = "llm")]
178impl MojenticLlmClient {
179    pub fn new(config: LlmConfig) -> Self {
180        Self { config }
181    }
182
183    fn make_broker(&self) -> LlmBroker {
184        let gateway: Arc<dyn LlmGateway + Send + Sync> = match self.config.gateway {
185            LlmGatewayType::OpenAI => Arc::new(OpenAIGateway::default()),
186            LlmGatewayType::Ollama => Arc::new(OllamaGateway::new()),
187        };
188        LlmBroker::new(&self.config.model, gateway, None)
189    }
190}
191
192#[cfg(feature = "llm")]
193impl LlmClient for MojenticLlmClient {
194    fn analyze(
195        &self,
196        system_prompt: &str,
197        user_prompt: &str,
198    ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + '_>> {
199        // not unit-tested: live network boundary
200        let broker = self.make_broker();
201        let messages = vec![
202            LlmMessage::system(system_prompt),
203            LlmMessage::user(user_prompt),
204        ];
205        Box::pin(async move {
206            broker
207                .generate(&messages, None, None, None)
208                .await
209                .context("LLM analysis failed")
210        })
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use tempfile::TempDir;
218
219    // -----------------------------------------------------------------------
220    // RealFilesystem
221    // -----------------------------------------------------------------------
222
223    #[test]
224    fn real_filesystem_write_read_string_roundtrip() {
225        let dir = TempDir::new().unwrap();
226        let path = dir.path().join("hello.txt");
227        let fs = RealFilesystem;
228        fs.write(&path, "hello world").unwrap();
229        assert_eq!(fs.read_to_string(&path).unwrap(), "hello world");
230    }
231
232    #[test]
233    fn real_filesystem_write_bytes_read_roundtrip() {
234        let dir = TempDir::new().unwrap();
235        let path = dir.path().join("bytes.bin");
236        let fs = RealFilesystem;
237        let data = vec![0u8, 1, 2, 255];
238        fs.write_bytes(&path, &data).unwrap();
239        assert_eq!(fs.read(&path).unwrap(), data);
240    }
241
242    #[test]
243    fn real_filesystem_create_dir_all_nested() {
244        let dir = TempDir::new().unwrap();
245        let nested = dir.path().join("a").join("b").join("c");
246        let fs = RealFilesystem;
247        fs.create_dir_all(&nested).unwrap();
248        assert!(nested.is_dir());
249    }
250
251    #[test]
252    fn real_filesystem_copy_file() {
253        let dir = TempDir::new().unwrap();
254        let src = dir.path().join("src.txt");
255        let dst = dir.path().join("dst.txt");
256        let fs = RealFilesystem;
257        fs.write(&src, "copy me").unwrap();
258        fs.copy_file(&src, &dst).unwrap();
259        assert_eq!(fs.read_to_string(&dst).unwrap(), "copy me");
260        // source still exists
261        assert!(fs.exists(&src));
262    }
263
264    #[test]
265    fn real_filesystem_rename() {
266        let dir = TempDir::new().unwrap();
267        let from = dir.path().join("from.txt");
268        let to = dir.path().join("to.txt");
269        let fs = RealFilesystem;
270        fs.write(&from, "data").unwrap();
271        fs.rename(&from, &to).unwrap();
272        assert!(!from.exists());
273        assert_eq!(fs.read_to_string(&to).unwrap(), "data");
274    }
275
276    #[test]
277    fn real_filesystem_remove_file() {
278        let dir = TempDir::new().unwrap();
279        let path = dir.path().join("tmp.txt");
280        let fs = RealFilesystem;
281        fs.write(&path, "").unwrap();
282        assert!(fs.exists(&path));
283        fs.remove_file(&path).unwrap();
284        assert!(!fs.exists(&path));
285    }
286
287    #[test]
288    fn real_filesystem_remove_dir_all() {
289        let dir = TempDir::new().unwrap();
290        let sub = dir.path().join("sub");
291        let file = sub.join("file.txt");
292        let fs = RealFilesystem;
293        fs.create_dir_all(&sub).unwrap();
294        fs.write(&file, "x").unwrap();
295        fs.remove_dir_all(&sub).unwrap();
296        assert!(!sub.exists());
297    }
298
299    #[test]
300    fn real_filesystem_read_dir_entries() {
301        let dir = TempDir::new().unwrap();
302        let fs = RealFilesystem;
303        fs.write(&dir.path().join("alpha.txt"), "a").unwrap();
304        fs.write(&dir.path().join("beta.txt"), "b").unwrap();
305        let entries = fs.read_dir(dir.path()).unwrap();
306        let names: std::collections::BTreeSet<_> =
307            entries.iter().map(|e| e.file_name.as_str()).collect();
308        assert!(names.contains("alpha.txt"));
309        assert!(names.contains("beta.txt"));
310        // Verify DirEntry fields
311        for entry in &entries {
312            assert!(!entry.is_dir);
313            assert!(entry.path.exists());
314        }
315    }
316
317    #[test]
318    fn real_filesystem_read_dir_contains_subdir_entry() {
319        let dir = TempDir::new().unwrap();
320        let sub = dir.path().join("subdir");
321        let fs = RealFilesystem;
322        fs.create_dir_all(&sub).unwrap();
323        let entries = fs.read_dir(dir.path()).unwrap();
324        let subdir_entry = entries.iter().find(|e| e.file_name == "subdir").unwrap();
325        assert!(subdir_entry.is_dir);
326    }
327
328    #[test]
329    fn real_filesystem_exists_is_dir_is_file() {
330        let dir = TempDir::new().unwrap();
331        let path = dir.path().join("f.txt");
332        let fs = RealFilesystem;
333
334        assert!(!fs.exists(&path));
335        assert!(!fs.is_file(&path));
336        assert!(!fs.is_dir(&path));
337
338        fs.write(&path, "").unwrap();
339        assert!(fs.exists(&path));
340        assert!(fs.is_file(&path));
341        assert!(!fs.is_dir(&path));
342
343        assert!(fs.is_dir(dir.path()));
344        assert!(!fs.is_file(dir.path()));
345    }
346
347    #[test]
348    fn real_filesystem_canonicalize() {
349        let dir = TempDir::new().unwrap();
350        let path = dir.path().join("c.txt");
351        let fs = RealFilesystem;
352        fs.write(&path, "").unwrap();
353        let canonical = fs.canonicalize(&path).unwrap();
354        assert!(canonical.is_absolute());
355        assert!(canonical.exists());
356    }
357
358    #[test]
359    fn real_filesystem_read_to_string_missing_returns_err() {
360        let dir = TempDir::new().unwrap();
361        let missing = dir.path().join("no_such_file.txt");
362        let fs = RealFilesystem;
363        let err = fs.read_to_string(&missing).unwrap_err();
364        assert!(err.to_string().contains("Failed to read"));
365    }
366
367    // -----------------------------------------------------------------------
368    // SystemClock
369    // -----------------------------------------------------------------------
370
371    #[test]
372    fn system_clock_returns_current_wall_time() {
373        let before = Utc::now();
374        let clock = SystemClock;
375        let result = clock.now();
376        let after = Utc::now();
377        assert!(result >= before, "clock result should be >= before");
378        assert!(result <= after, "clock result should be <= after");
379    }
380
381    // -----------------------------------------------------------------------
382    // RealGitClient
383    // -----------------------------------------------------------------------
384
385    fn git_available() -> bool {
386        Command::new("git").arg("--version").output().is_ok()
387    }
388
389    #[test]
390    fn real_git_client_clone_and_pull() {
391        if !git_available() {
392            eprintln!("git not found on PATH — skipping RealGitClient tests");
393            return;
394        }
395
396        let source_dir = TempDir::new().unwrap();
397        // Init a bare-ish source repo with one commit
398        Command::new("git")
399            .args(["init", "-b", "main"])
400            .current_dir(source_dir.path())
401            .output()
402            .unwrap();
403        Command::new("git")
404            .args(["config", "user.email", "test@example.com"])
405            .current_dir(source_dir.path())
406            .output()
407            .unwrap();
408        Command::new("git")
409            .args(["config", "user.name", "Test"])
410            .current_dir(source_dir.path())
411            .output()
412            .unwrap();
413        std::fs::write(source_dir.path().join("README.md"), "hello").unwrap();
414        Command::new("git")
415            .args(["add", "."])
416            .current_dir(source_dir.path())
417            .output()
418            .unwrap();
419        Command::new("git")
420            .args(["commit", "-m", "init"])
421            .current_dir(source_dir.path())
422            .output()
423            .unwrap();
424
425        let clone_dir = TempDir::new().unwrap();
426        let dest = clone_dir.path().join("repo");
427        let client = RealGitClient;
428
429        client.clone_repo(&source_dir.path().display().to_string(), &dest).unwrap();
430        assert!(dest.join("README.md").exists());
431
432        // pull should succeed on an up-to-date clone
433        client.pull(&dest).unwrap();
434    }
435
436    #[test]
437    fn real_git_client_clone_nonexistent_returns_err() {
438        if !git_available() {
439            eprintln!("git not found on PATH — skipping RealGitClient error test");
440            return;
441        }
442        let dest = TempDir::new().unwrap();
443        let client = RealGitClient;
444        let err = client
445            .clone_repo("/nonexistent/path/that/does/not/exist", dest.path())
446            .unwrap_err();
447        assert!(err.to_string().contains("git clone failed"));
448    }
449
450    // -----------------------------------------------------------------------
451    // MojenticLlmClient (hermetic only — no network calls)
452    // -----------------------------------------------------------------------
453
454    #[cfg(feature = "llm")]
455    #[test]
456    fn mojentic_llm_client_new_stores_config() {
457        use crate::types::{LlmConfig, LlmGatewayType};
458        let config = LlmConfig {
459            gateway: LlmGatewayType::OpenAI,
460            model: "gpt-4o".to_string(),
461        };
462        let client = MojenticLlmClient::new(config.clone());
463        assert_eq!(client.config.model, "gpt-4o");
464        assert_eq!(client.config.gateway, LlmGatewayType::OpenAI);
465    }
466
467    #[cfg(feature = "llm")]
468    #[test]
469    fn mojentic_llm_client_make_broker_openai_does_not_panic() {
470        use crate::types::{LlmConfig, LlmGatewayType};
471        let client = MojenticLlmClient::new(LlmConfig {
472            gateway: LlmGatewayType::OpenAI,
473            model: "gpt-4o".to_string(),
474        });
475        let _broker = client.make_broker();
476    }
477
478    #[cfg(feature = "llm")]
479    #[test]
480    fn mojentic_llm_client_make_broker_ollama_does_not_panic() {
481        use crate::types::{LlmConfig, LlmGatewayType};
482        let client = MojenticLlmClient::new(LlmConfig {
483            gateway: LlmGatewayType::Ollama,
484            model: "llama3".to_string(),
485        });
486        let _broker = client.make_broker();
487    }
488}