1use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7
8pub const DEFAULT_LM_STUDIO_API_URL: &str = "http://localhost:1234/v1";
9pub const DEFAULT_OLLAMA_API_URL: &str = "http://localhost:11434/v1";
10
11fn default_true() -> bool {
12 true
13}
14
15#[derive(Serialize, Deserialize, Default, Clone, Copy, Debug, PartialEq)]
16pub enum PermissionMode {
17 #[default]
18 Developer,
19 ReadOnly,
20 SystemAdmin,
21}
22
23#[derive(Serialize, Deserialize, Clone, Debug)]
24pub struct HematiteConfig {
25 #[serde(default)]
27 pub mode: PermissionMode,
28 pub permissions: Option<PermissionRules>,
30 #[serde(default)]
32 pub trust: WorkspaceTrustConfig,
33 pub model: Option<String>,
35 pub fast_model: Option<String>,
37 pub think_model: Option<String>,
39 pub embed_model: Option<String>,
41 #[serde(default = "default_true")]
43 pub gemma_native_auto: bool,
44 #[serde(default)]
46 pub gemma_native_formatting: bool,
47 pub api_url: Option<String>,
50 pub voice: Option<String>,
52 pub voice_speed: Option<f32>,
54 pub voice_volume: Option<f32>,
56 pub context_hint: Option<String>,
58 pub deno_path: Option<String>,
62 pub python_path: Option<String>,
63 #[serde(default)]
65 pub verify: VerifyProfilesConfig,
66 #[serde(default)]
68 pub hooks: crate::agent::hooks::RuntimeHookConfig,
69 pub searx_url: Option<String>,
72 #[serde(default = "default_true")]
74 pub auto_start_searx: bool,
75 #[serde(default)]
77 pub auto_stop_searx: bool,
78}
79
80impl Default for HematiteConfig {
81 fn default() -> Self {
82 Self {
83 mode: PermissionMode::Developer,
84 permissions: None,
85 trust: WorkspaceTrustConfig::default(),
86 model: None,
87 fast_model: None,
88 think_model: None,
89 embed_model: None,
90 gemma_native_auto: true,
91 gemma_native_formatting: false,
92 api_url: None,
93 voice: None,
94 voice_speed: None,
95 voice_volume: None,
96 context_hint: None,
97 deno_path: None,
98 python_path: None,
99 verify: VerifyProfilesConfig::default(),
100 hooks: crate::agent::hooks::RuntimeHookConfig::default(),
101 searx_url: None,
102 auto_start_searx: true,
103 auto_stop_searx: false,
104 }
105 }
106}
107
108#[derive(Serialize, Deserialize, Clone, Debug)]
109pub struct WorkspaceTrustConfig {
110 #[serde(default = "default_trusted_workspace_roots")]
112 pub allow: Vec<String>,
113 #[serde(default)]
115 pub deny: Vec<String>,
116}
117
118impl Default for WorkspaceTrustConfig {
119 fn default() -> Self {
120 Self {
121 allow: default_trusted_workspace_roots(),
122 deny: Vec::new(),
123 }
124 }
125}
126
127fn default_trusted_workspace_roots() -> Vec<String> {
128 vec![".".to_string()]
129}
130
131#[derive(Serialize, Deserialize, Default, Clone, Debug)]
132pub struct VerifyProfilesConfig {
133 pub default_profile: Option<String>,
135 #[serde(default)]
137 pub profiles: BTreeMap<String, VerifyProfile>,
138}
139
140#[derive(Serialize, Deserialize, Default, Clone, Debug)]
141pub struct VerifyProfile {
142 pub build: Option<String>,
144 pub test: Option<String>,
146 pub lint: Option<String>,
148 pub fix: Option<String>,
150 pub timeout_secs: Option<u64>,
152}
153
154#[derive(Serialize, Deserialize, Default, Clone, Debug)]
155pub struct PermissionRules {
156 #[serde(default)]
158 pub allow: Vec<String>,
159 #[serde(default)]
161 pub ask: Vec<String>,
162 #[serde(default)]
164 pub deny: Vec<String>,
165}
166
167pub fn settings_path() -> std::path::PathBuf {
168 crate::tools::file_ops::hematite_dir().join("settings.json")
169}
170
171fn load_global_config() -> Option<HematiteConfig> {
173 let home = std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME"))?;
174 let path = std::path::PathBuf::from(home)
175 .join(".hematite")
176 .join("settings.json");
177 let data = std::fs::read_to_string(&path).ok()?;
178 serde_json::from_str(&data).ok()
179}
180
181pub fn load_config() -> HematiteConfig {
185 let path = settings_path();
186
187 let workspace: Option<HematiteConfig> = if path.exists() {
188 let content = std::fs::read_to_string(&path).ok();
189 if let Some(d) = content {
190 match serde_json::from_str(&d) {
191 Ok(cfg) => Some(cfg),
192 Err(_) => None,
193 }
194 } else {
195 None
196 }
197 } else {
198 write_default_config(&path);
199 None
200 };
201
202 let global = load_global_config();
203
204 match (workspace, global) {
205 (Some(ws), Some(gb)) => {
206 HematiteConfig {
208 model: ws.model.or(gb.model),
209 fast_model: ws.fast_model.or(gb.fast_model),
210 think_model: ws.think_model.or(gb.think_model),
211 embed_model: ws.embed_model.or(gb.embed_model),
212 api_url: ws.api_url.or(gb.api_url),
213 voice: if ws.voice != HematiteConfig::default().voice {
214 ws.voice
215 } else {
216 gb.voice
217 },
218 voice_speed: ws.voice_speed.or(gb.voice_speed),
219 voice_volume: ws.voice_volume.or(gb.voice_volume),
220 context_hint: ws.context_hint.or(gb.context_hint),
221 deno_path: ws.deno_path.or(gb.deno_path),
222 python_path: ws.python_path.or(gb.python_path),
223 searx_url: ws.searx_url.or(gb.searx_url),
224 auto_start_searx: ws.auto_start_searx, auto_stop_searx: ws.auto_stop_searx, gemma_native_auto: ws.gemma_native_auto,
227 gemma_native_formatting: ws.gemma_native_formatting,
228 ..ws
229 }
230 }
231 (Some(ws), None) => ws,
232 (None, Some(gb)) => gb,
233 (None, None) => HematiteConfig::default(),
234 }
235}
236
237pub fn save_config(config: &HematiteConfig) -> Result<(), String> {
238 let path = settings_path();
239 if let Some(parent) = path.parent() {
240 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
241 }
242 let json = serde_json::to_string_pretty(config).map_err(|e| e.to_string())?;
243 std::fs::write(&path, json).map_err(|e| e.to_string())
244}
245
246pub fn provider_label_for_api_url(url: &str) -> &'static str {
247 let normalized = url.trim().trim_end_matches('/').to_ascii_lowercase();
248 if normalized.contains("11434") || normalized.contains("ollama") {
249 "Ollama"
250 } else if normalized.contains("1234") || normalized.contains("lmstudio") {
251 "LM Studio"
252 } else {
253 "Custom"
254 }
255}
256
257pub fn default_api_url_for_provider(provider_name: &str) -> &'static str {
258 match provider_name {
259 "Ollama" => DEFAULT_OLLAMA_API_URL,
260 _ => DEFAULT_LM_STUDIO_API_URL,
261 }
262}
263
264pub fn effective_api_url(config: &HematiteConfig, cli_default: &str) -> String {
265 config
266 .api_url
267 .clone()
268 .unwrap_or_else(|| cli_default.to_string())
269}
270
271pub fn set_api_url_override(url: Option<&str>) -> Result<(), String> {
272 let mut config = load_config();
273 config.api_url = url
274 .map(str::trim)
275 .filter(|value| !value.is_empty())
276 .map(|value| value.to_string());
277 save_config(&config)
278}
279
280pub fn preferred_coding_model(config: &HematiteConfig) -> Option<String> {
281 config
282 .think_model
283 .clone()
284 .or(config.model.clone())
285 .or(config.fast_model.clone())
286}
287
288pub fn set_preferred_coding_model(model_id: Option<&str>) -> Result<(), String> {
289 let mut config = load_config();
290 let normalized = model_id
291 .map(str::trim)
292 .filter(|value| !value.is_empty())
293 .map(|value| value.to_string());
294 config.think_model = normalized.clone();
295 if normalized.is_some() {
296 config.model = None;
297 }
298 save_config(&config)
299}
300
301pub fn set_preferred_embed_model(model_id: Option<&str>) -> Result<(), String> {
302 let mut config = load_config();
303 config.embed_model = model_id
304 .map(str::trim)
305 .filter(|value| !value.is_empty())
306 .map(|value| value.to_string());
307 save_config(&config)
308}
309
310pub fn set_gemma_native_formatting(enabled: bool) -> Result<(), String> {
311 set_gemma_native_mode(if enabled { "on" } else { "off" })
312}
313
314pub fn set_gemma_native_mode(mode: &str) -> Result<(), String> {
315 let mut config = load_config();
316 match mode {
317 "on" => {
318 config.gemma_native_auto = false;
319 config.gemma_native_formatting = true;
320 }
321 "off" => {
322 config.gemma_native_auto = false;
323 config.gemma_native_formatting = false;
324 }
325 "auto" => {
326 config.gemma_native_auto = true;
327 config.gemma_native_formatting = false;
328 }
329 _ => return Err(format!("Unknown gemma native mode: {}", mode)),
330 }
331 save_config(&config)
332}
333
334pub fn set_voice(voice_id: &str) -> Result<(), String> {
335 let mut config = load_config();
336 config.voice = Some(voice_id.to_string());
337 save_config(&config)
338}
339
340pub fn effective_voice(config: &HematiteConfig) -> String {
341 config.voice.clone().unwrap_or_else(|| "af_sky".to_string())
342}
343
344pub fn effective_voice_speed(config: &HematiteConfig) -> f32 {
345 config.voice_speed.unwrap_or(1.0).clamp(0.5, 2.0)
346}
347
348pub fn effective_voice_volume(config: &HematiteConfig) -> f32 {
349 config.voice_volume.unwrap_or(1.0).clamp(0.0, 3.0)
350}
351
352pub fn effective_gemma_native_formatting(config: &HematiteConfig, model_name: &str) -> bool {
353 crate::agent::inference::is_hematite_native_model(model_name)
354 && (config.gemma_native_formatting || config.gemma_native_auto)
355}
356
357pub fn gemma_native_mode_label(config: &HematiteConfig, model_name: &str) -> &'static str {
358 if !crate::agent::inference::is_hematite_native_model(model_name) {
359 "inactive"
360 } else if config.gemma_native_formatting {
361 "on"
362 } else if config.gemma_native_auto {
363 "auto"
364 } else {
365 "off"
366 }
367}
368
369fn write_default_config(path: &std::path::Path) {
371 if let Some(parent) = path.parent() {
372 let _ = std::fs::create_dir_all(parent);
373 }
374 let default = r#"{
375 "_comment": "Hematite settings — edit and save, changes apply immediately without restart.",
376
377 "permissions": {
378 "allow": [
379 "cargo *",
380 "git status",
381 "git log *",
382 "git diff *",
383 "git branch *"
384 ],
385 "ask": [],
386 "deny": []
387 },
388
389 "trust": {
390 "allow": ["."],
391 "deny": []
392 },
393
394 "auto_approve_moderate": false,
395
396 "api_url": null,
397 "voice": null,
398 "voice_speed": null,
399 "voice_volume": null,
400 "context_hint": null,
401 "model": null,
402 "fast_model": null,
403 "think_model": null,
404 "embed_model": null,
405 "gemma_native_auto": true,
406 "gemma_native_formatting": false,
407 "searx_url": null,
408 "auto_start_searx": true,
409 "auto_stop_searx": false,
410
411 "verify": {
412 "default_profile": null,
413 "profiles": {
414 "rust": {
415 "build": "cargo build --color never",
416 "test": "cargo test --color never",
417 "lint": "cargo clippy --all-targets --all-features -- -D warnings",
418 "fix": "cargo fmt",
419 "timeout_secs": 120
420 }
421 }
422 },
423
424 "hooks": {
425 "pre_tool_use": [],
426 "post_tool_use": []
427 }
428 }
429"#;
430 let _ = std::fs::write(path, default);
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 #[test]
438 fn provider_label_for_api_url_detects_known_runtimes() {
439 assert_eq!(
440 provider_label_for_api_url("http://localhost:1234/v1"),
441 "LM Studio"
442 );
443 assert_eq!(
444 provider_label_for_api_url("http://localhost:11434/v1"),
445 "Ollama"
446 );
447 assert_eq!(
448 provider_label_for_api_url("https://ai.example.com/v1"),
449 "Custom"
450 );
451 }
452
453 #[test]
454 fn default_api_url_for_provider_maps_presets() {
455 assert_eq!(
456 default_api_url_for_provider("LM Studio"),
457 DEFAULT_LM_STUDIO_API_URL
458 );
459 assert_eq!(
460 default_api_url_for_provider("Ollama"),
461 DEFAULT_OLLAMA_API_URL
462 );
463 assert_eq!(
464 default_api_url_for_provider("Custom"),
465 DEFAULT_LM_STUDIO_API_URL
466 );
467 }
468
469 #[test]
470 fn preferred_coding_model_prefers_think_then_model_then_fast() {
471 let mut config = HematiteConfig::default();
472 config.fast_model = Some("fast".into());
473 assert_eq!(preferred_coding_model(&config), Some("fast".to_string()));
474
475 config.model = Some("main".into());
476 assert_eq!(preferred_coding_model(&config), Some("main".to_string()));
477
478 config.think_model = Some("think".into());
479 assert_eq!(preferred_coding_model(&config), Some("think".to_string()));
480 }
481}
482
483pub fn permission_for_shell(cmd: &str, config: &HematiteConfig) -> PermissionDecision {
491 if let Some(rules) = &config.permissions {
492 for pattern in &rules.deny {
493 if glob_matches(pattern, cmd) {
494 return PermissionDecision::Deny;
495 }
496 }
497 for pattern in &rules.allow {
498 if glob_matches(pattern, cmd) {
499 return PermissionDecision::Allow;
500 }
501 }
502 for pattern in &rules.ask {
503 if glob_matches(pattern, cmd) {
504 return PermissionDecision::Ask;
505 }
506 }
507 }
508 PermissionDecision::UseRiskClassifier
509}
510
511#[derive(Debug, PartialEq)]
512pub enum PermissionDecision {
513 Allow,
514 Deny,
515 Ask,
516 UseRiskClassifier,
517}
518
519pub fn glob_matches(pattern: &str, text: &str) -> bool {
522 let p = pattern.to_lowercase();
523 let t = text.to_lowercase();
524 if p == "*" {
525 return true;
526 }
527 if let Some(star) = p.find('*') {
528 let prefix = &p[..star];
529 let suffix = &p[star + 1..];
530 t.starts_with(prefix) && (suffix.is_empty() || t.ends_with(suffix))
531 } else {
532 t.contains(&p)
533 }
534}