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
26pub 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
117pub 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
154pub struct SystemClock;
160
161impl Clock for SystemClock {
162 fn now(&self) -> DateTime<Utc> {
163 Utc::now()
164 }
165}
166
167#[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 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 #[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 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 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 #[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 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 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 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 #[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}