1use std::fs;
8use std::path::PathBuf;
9use std::process::Command;
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::{Result, VaultError};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19pub enum InferenceEngine {
20 Ollama,
21 LmStudio,
22}
23
24impl std::fmt::Display for InferenceEngine {
25 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26 match self {
27 Self::Ollama => write!(f, "Ollama"),
28 Self::LmStudio => write!(f, "LM Studio"),
29 }
30 }
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct RegistrationResult {
36 pub engine: String,
37 pub model_name: String,
38 pub success: bool,
39 pub message: String,
40 pub path: Option<String>,
42}
43
44#[derive(Debug, Clone)]
46pub struct OllamaOptions {
47 pub name: String,
49 pub model_path: PathBuf,
51 pub system_prompt: Option<String>,
53 pub template: Option<String>,
55 pub parameters: Vec<(String, String)>,
57}
58
59#[derive(Debug, Clone)]
61pub struct LmStudioOptions {
62 pub name: String,
64 pub model_path: PathBuf,
66 pub models_dir: Option<PathBuf>,
68}
69
70pub fn register_ollama(opts: &OllamaOptions) -> Result<RegistrationResult> {
74 if !opts.model_path.exists() {
76 return Err(VaultError::InvalidInput(format!(
77 "Model file not found: {}",
78 opts.model_path.display()
79 )));
80 }
81
82 let modelfile_content = build_modelfile(opts)?;
84
85 let modelfile_path = opts.model_path.with_extension("Modelfile");
87 fs::write(&modelfile_path, &modelfile_content)?;
88
89 let ollama_check = Command::new("ollama").arg("--version").output();
91 if ollama_check.is_err() {
92 return Ok(RegistrationResult {
94 engine: "Ollama".to_string(),
95 model_name: opts.name.clone(),
96 success: false,
97 message: format!(
98 "Ollama CLI not found. Modelfile created at: {}\nRun manually: ollama create {} -f {}",
99 modelfile_path.display(),
100 opts.name,
101 modelfile_path.display()
102 ),
103 path: Some(modelfile_path.display().to_string()),
104 });
105 }
106
107 let output = Command::new("ollama")
109 .arg("create")
110 .arg(&opts.name)
111 .arg("-f")
112 .arg(&modelfile_path)
113 .output()
114 .map_err(VaultError::IoError)?;
115
116 let success = output.status.success();
117 let message = if success {
118 format!(
119 "Model '{}' registered with Ollama. Run: ollama run {}",
120 opts.name, opts.name
121 )
122 } else {
123 let stderr = String::from_utf8_lossy(&output.stderr);
124 format!(
125 "Failed to register with Ollama: {}. Modelfile at: {}",
126 stderr.trim(),
127 modelfile_path.display()
128 )
129 };
130
131 Ok(RegistrationResult {
132 engine: "Ollama".to_string(),
133 model_name: opts.name.clone(),
134 success,
135 message,
136 path: Some(modelfile_path.display().to_string()),
137 })
138}
139
140fn build_modelfile(opts: &OllamaOptions) -> Result<String> {
142 let mut lines = vec![format!("FROM {}", opts.model_path.display())];
143
144 if let Some(system) = &opts.system_prompt {
145 lines.push(format!("SYSTEM \"{}\"", system.replace('"', "\\\"")));
146 }
147
148 if let Some(template) = &opts.template {
149 lines.push(format!("TEMPLATE \"{}\"", template.replace('"', "\\\"")));
150 }
151
152 for (key, value) in &opts.parameters {
153 lines.push(format!("PARAMETER {} {}", key, value));
154 }
155
156 Ok(lines.join("\n") + "\n")
157}
158
159pub fn register_lm_studio(opts: &LmStudioOptions) -> Result<RegistrationResult> {
163 if !opts.model_path.exists() {
164 return Err(VaultError::InvalidInput(format!(
165 "Model file not found: {}",
166 opts.model_path.display()
167 )));
168 }
169
170 let models_dir = match &opts.models_dir {
171 Some(d) => d.clone(),
172 None => default_lm_studio_dir()?,
173 };
174
175 let model_dir = models_dir.join(&opts.name);
177 fs::create_dir_all(&model_dir)?;
178
179 let file_name = opts
181 .model_path
182 .file_name()
183 .ok_or_else(|| VaultError::InvalidInput("Invalid model file path".to_string()))?;
184 let dest = model_dir.join(file_name);
185
186 fs::copy(&opts.model_path, &dest)?;
187
188 Ok(RegistrationResult {
189 engine: "LM Studio".to_string(),
190 model_name: opts.name.clone(),
191 success: true,
192 message: format!(
193 "Model '{}' copied to LM Studio models directory. Restart LM Studio to see it.",
194 opts.name
195 ),
196 path: Some(dest.display().to_string()),
197 })
198}
199
200fn default_lm_studio_dir() -> Result<PathBuf> {
202 #[cfg(target_os = "windows")]
203 {
204 if let Ok(home) = std::env::var("USERPROFILE") {
205 return Ok(PathBuf::from(home)
206 .join(".cache")
207 .join("lm-studio")
208 .join("models"));
209 }
210 }
211
212 #[cfg(target_os = "macos")]
213 {
214 if let Ok(home) = std::env::var("HOME") {
215 return Ok(PathBuf::from(home)
216 .join(".cache")
217 .join("lm-studio")
218 .join("models"));
219 }
220 }
221
222 #[cfg(target_os = "linux")]
223 {
224 if let Ok(home) = std::env::var("HOME") {
225 return Ok(PathBuf::from(home)
226 .join(".cache")
227 .join("lm-studio")
228 .join("models"));
229 }
230 }
231
232 Err(VaultError::InvalidInput(
233 "Cannot determine LM Studio models directory. Use --models-dir to specify.".to_string(),
234 ))
235}
236
237#[cfg(test)]
240mod tests {
241 use super::*;
242 use std::io::Write;
243
244 #[test]
245 fn test_build_modelfile_basic() {
246 let opts = OllamaOptions {
247 name: "my-model".to_string(),
248 model_path: PathBuf::from("/tmp/model.gguf"),
249 system_prompt: None,
250 template: None,
251 parameters: vec![],
252 };
253 let mf = build_modelfile(&opts).unwrap();
254 assert!(mf.starts_with("FROM /tmp/model.gguf"));
255 }
256
257 #[test]
258 fn test_build_modelfile_full() {
259 let opts = OllamaOptions {
260 name: "test".to_string(),
261 model_path: PathBuf::from("/models/test.gguf"),
262 system_prompt: Some("You are a helpful assistant.".to_string()),
263 template: Some("{{.Prompt}}".to_string()),
264 parameters: vec![
265 ("temperature".to_string(), "0.7".to_string()),
266 ("top_k".to_string(), "40".to_string()),
267 ],
268 };
269 let mf = build_modelfile(&opts).unwrap();
270 assert!(mf.contains("FROM /models/test.gguf"));
271 assert!(mf.contains("SYSTEM"));
272 assert!(mf.contains("PARAMETER temperature 0.7"));
273 assert!(mf.contains("PARAMETER top_k 40"));
274 }
275
276 #[test]
277 fn test_register_ollama_missing_file() {
278 let opts = OllamaOptions {
279 name: "bad".to_string(),
280 model_path: PathBuf::from("/nonexistent/model.gguf"),
281 system_prompt: None,
282 template: None,
283 parameters: vec![],
284 };
285 let result = register_ollama(&opts);
286 assert!(result.is_err());
287 }
288
289 #[test]
290 fn test_register_lm_studio_missing_file() {
291 let opts = LmStudioOptions {
292 name: "bad".to_string(),
293 model_path: PathBuf::from("/nonexistent/model.gguf"),
294 models_dir: None,
295 };
296 let result = register_lm_studio(&opts);
297 assert!(result.is_err());
298 }
299
300 #[test]
301 fn test_register_lm_studio_copy() {
302 let dir = tempfile::tempdir().unwrap();
303 let model_path = dir.path().join("model.gguf");
304 {
305 let mut f = fs::File::create(&model_path).unwrap();
306 f.write_all(b"fake gguf data").unwrap();
307 }
308
309 let models_dir = dir.path().join("lm-studio-models");
310 let opts = LmStudioOptions {
311 name: "test-model".to_string(),
312 model_path,
313 models_dir: Some(models_dir.clone()),
314 };
315
316 let result = register_lm_studio(&opts).unwrap();
317 assert!(result.success);
318 assert!(models_dir.join("test-model").join("model.gguf").exists());
319 }
320
321 #[test]
322 fn test_inference_engine_display() {
323 assert_eq!(InferenceEngine::Ollama.to_string(), "Ollama");
324 assert_eq!(InferenceEngine::LmStudio.to_string(), "LM Studio");
325 }
326}