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
181static CONFIG_CACHE: std::sync::Mutex<Option<(std::time::Instant, HematiteConfig)>> =
182 std::sync::Mutex::new(None);
183
184const CONFIG_TTL: std::time::Duration = std::time::Duration::from_millis(500);
185
186pub fn invalidate_config_cache() {
189 if let Ok(mut g) = CONFIG_CACHE.lock() {
190 *g = None;
191 }
192}
193
194pub fn load_config() -> HematiteConfig {
199 if let Ok(g) = CONFIG_CACHE.lock() {
201 if let Some((t, ref cfg)) = *g {
202 if t.elapsed() < CONFIG_TTL {
203 return cfg.clone();
204 }
205 }
206 }
207
208 let cfg = load_config_uncached();
209
210 if let Ok(mut g) = CONFIG_CACHE.lock() {
211 *g = Some((std::time::Instant::now(), cfg.clone()));
212 }
213 cfg
214}
215
216fn load_config_uncached() -> HematiteConfig {
217 let path = settings_path();
218
219 let workspace: Option<HematiteConfig> = if path.exists() {
220 let content = std::fs::read_to_string(&path).ok();
221 if let Some(d) = content {
222 serde_json::from_str(&d).ok()
223 } else {
224 None
225 }
226 } else {
227 write_default_config(&path);
228 None
229 };
230
231 let global = load_global_config();
232
233 match (workspace, global) {
234 (Some(ws), Some(gb)) => {
235 HematiteConfig {
237 model: ws.model.or(gb.model),
238 fast_model: ws.fast_model.or(gb.fast_model),
239 think_model: ws.think_model.or(gb.think_model),
240 embed_model: ws.embed_model.or(gb.embed_model),
241 api_url: ws.api_url.or(gb.api_url),
242 voice: if ws.voice != HematiteConfig::default().voice {
243 ws.voice
244 } else {
245 gb.voice
246 },
247 voice_speed: ws.voice_speed.or(gb.voice_speed),
248 voice_volume: ws.voice_volume.or(gb.voice_volume),
249 context_hint: ws.context_hint.or(gb.context_hint),
250 deno_path: ws.deno_path.or(gb.deno_path),
251 python_path: ws.python_path.or(gb.python_path),
252 searx_url: ws.searx_url.or(gb.searx_url),
253 auto_start_searx: ws.auto_start_searx, auto_stop_searx: ws.auto_stop_searx, gemma_native_auto: ws.gemma_native_auto,
256 gemma_native_formatting: ws.gemma_native_formatting,
257 ..ws
258 }
259 }
260 (Some(ws), None) => ws,
261 (None, Some(gb)) => gb,
262 (None, None) => HematiteConfig::default(),
263 }
264}
265
266pub fn save_config(config: &HematiteConfig) -> Result<(), String> {
267 let path = settings_path();
268 if let Some(parent) = path.parent() {
269 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
270 }
271 let json = serde_json::to_string_pretty(config).map_err(|e| e.to_string())?;
272 std::fs::write(&path, json).map_err(|e| e.to_string())?;
273 invalidate_config_cache();
274 Ok(())
275}
276
277pub fn provider_label_for_api_url(url: &str) -> &'static str {
278 let normalized = url.trim().trim_end_matches('/').to_ascii_lowercase();
279 if normalized.contains("11434") || normalized.contains("ollama") {
280 "Ollama"
281 } else if normalized.contains("1234") || normalized.contains("lmstudio") {
282 "LM Studio"
283 } else {
284 "Custom"
285 }
286}
287
288pub fn default_api_url_for_provider(provider_name: &str) -> &'static str {
289 match provider_name {
290 "Ollama" => DEFAULT_OLLAMA_API_URL,
291 _ => DEFAULT_LM_STUDIO_API_URL,
292 }
293}
294
295pub fn effective_api_url(config: &HematiteConfig, cli_default: &str) -> String {
296 config
297 .api_url
298 .clone()
299 .unwrap_or_else(|| cli_default.to_string())
300}
301
302pub fn set_api_url_override(url: Option<&str>) -> Result<(), String> {
303 let mut config = load_config();
304 config.api_url = url
305 .map(str::trim)
306 .filter(|value| !value.is_empty())
307 .map(|value| value.to_string());
308 save_config(&config)
309}
310
311pub fn preferred_coding_model(config: &HematiteConfig) -> Option<String> {
312 config
313 .think_model
314 .clone()
315 .or_else(|| config.model.clone())
316 .or_else(|| config.fast_model.clone())
317}
318
319pub fn set_preferred_coding_model(model_id: Option<&str>) -> Result<(), String> {
320 let mut config = load_config();
321 let normalized = model_id
322 .map(str::trim)
323 .filter(|value| !value.is_empty())
324 .map(|value| value.to_string());
325 config.think_model = normalized.clone();
326 if normalized.is_some() {
327 config.model = None;
328 }
329 save_config(&config)
330}
331
332pub fn set_preferred_embed_model(model_id: Option<&str>) -> Result<(), String> {
333 let mut config = load_config();
334 config.embed_model = model_id
335 .map(str::trim)
336 .filter(|value| !value.is_empty())
337 .map(|value| value.to_string());
338 save_config(&config)
339}
340
341pub fn set_gemma_native_formatting(enabled: bool) -> Result<(), String> {
342 set_gemma_native_mode(if enabled { "on" } else { "off" })
343}
344
345pub fn set_gemma_native_mode(mode: &str) -> Result<(), String> {
346 let mut config = load_config();
347 match mode {
348 "on" => {
349 config.gemma_native_auto = false;
350 config.gemma_native_formatting = true;
351 }
352 "off" => {
353 config.gemma_native_auto = false;
354 config.gemma_native_formatting = false;
355 }
356 "auto" => {
357 config.gemma_native_auto = true;
358 config.gemma_native_formatting = false;
359 }
360 _ => return Err(format!("Unknown gemma native mode: {}", mode)),
361 }
362 save_config(&config)
363}
364
365pub fn set_voice(voice_id: &str) -> Result<(), String> {
366 let mut config = load_config();
367 config.voice = Some(voice_id.to_string());
368 save_config(&config)
369}
370
371pub fn effective_voice(config: &HematiteConfig) -> String {
372 config.voice.clone().unwrap_or_else(|| "af_sky".to_string())
373}
374
375pub fn effective_voice_speed(config: &HematiteConfig) -> f32 {
376 config.voice_speed.unwrap_or(1.0).clamp(0.5, 2.0)
377}
378
379pub fn effective_voice_volume(config: &HematiteConfig) -> f32 {
380 config.voice_volume.unwrap_or(1.0).clamp(0.0, 3.0)
381}
382
383pub fn effective_gemma_native_formatting(config: &HematiteConfig, model_name: &str) -> bool {
384 crate::agent::inference::is_hematite_native_model(model_name)
385 && (config.gemma_native_formatting || config.gemma_native_auto)
386}
387
388pub fn gemma_native_mode_label(config: &HematiteConfig, model_name: &str) -> &'static str {
389 if !crate::agent::inference::is_hematite_native_model(model_name) {
390 "inactive"
391 } else if config.gemma_native_formatting {
392 "on"
393 } else if config.gemma_native_auto {
394 "auto"
395 } else {
396 "off"
397 }
398}
399
400fn write_default_config(path: &std::path::Path) {
402 if let Some(parent) = path.parent() {
403 let _ = std::fs::create_dir_all(parent);
404 }
405 let default = r#"{
406 "_comment": "Hematite settings — edit and save, changes apply immediately without restart.",
407
408 "permissions": {
409 "allow": [
410 "cargo *",
411 "git status",
412 "git log *",
413 "git diff *",
414 "git branch *"
415 ],
416 "ask": [],
417 "deny": []
418 },
419
420 "trust": {
421 "allow": ["."],
422 "deny": []
423 },
424
425 "auto_approve_moderate": false,
426
427 "api_url": null,
428 "voice": null,
429 "voice_speed": null,
430 "voice_volume": null,
431 "context_hint": null,
432 "model": null,
433 "fast_model": null,
434 "think_model": null,
435 "embed_model": null,
436 "gemma_native_auto": true,
437 "gemma_native_formatting": false,
438 "searx_url": null,
439 "auto_start_searx": true,
440 "auto_stop_searx": false,
441
442 "verify": {
443 "default_profile": null,
444 "profiles": {
445 "rust": {
446 "build": "cargo build --color never",
447 "test": "cargo test --color never",
448 "lint": "cargo clippy --all-targets --all-features -- -D warnings",
449 "fix": "cargo fmt",
450 "timeout_secs": 120
451 }
452 }
453 },
454
455 "hooks": {
456 "pre_tool_use": [],
457 "post_tool_use": []
458 }
459 }
460"#;
461 let _ = std::fs::write(path, default);
462}
463
464#[cfg(test)]
465mod tests {
466 use super::*;
467
468 #[test]
469 fn provider_label_for_api_url_detects_known_runtimes() {
470 assert_eq!(
471 provider_label_for_api_url("http://localhost:1234/v1"),
472 "LM Studio"
473 );
474 assert_eq!(
475 provider_label_for_api_url("http://localhost:11434/v1"),
476 "Ollama"
477 );
478 assert_eq!(
479 provider_label_for_api_url("https://ai.example.com/v1"),
480 "Custom"
481 );
482 }
483
484 #[test]
485 fn default_api_url_for_provider_maps_presets() {
486 assert_eq!(
487 default_api_url_for_provider("LM Studio"),
488 DEFAULT_LM_STUDIO_API_URL
489 );
490 assert_eq!(
491 default_api_url_for_provider("Ollama"),
492 DEFAULT_OLLAMA_API_URL
493 );
494 assert_eq!(
495 default_api_url_for_provider("Custom"),
496 DEFAULT_LM_STUDIO_API_URL
497 );
498 }
499
500 #[test]
501 #[allow(clippy::field_reassign_with_default)]
502 fn preferred_coding_model_prefers_think_then_model_then_fast() {
503 let mut config = HematiteConfig::default();
504 config.fast_model = Some("fast".into());
505 assert_eq!(preferred_coding_model(&config), Some("fast".to_string()));
506
507 config.model = Some("main".into());
508 assert_eq!(preferred_coding_model(&config), Some("main".to_string()));
509
510 config.think_model = Some("think".into());
511 assert_eq!(preferred_coding_model(&config), Some("think".to_string()));
512 }
513}
514
515pub fn permission_for_shell(cmd: &str, config: &HematiteConfig) -> PermissionDecision {
523 if let Some(rules) = &config.permissions {
524 for pattern in &rules.deny {
525 if glob_matches(pattern, cmd) {
526 return PermissionDecision::Deny;
527 }
528 }
529 for pattern in &rules.allow {
530 if glob_matches(pattern, cmd) {
531 return PermissionDecision::Allow;
532 }
533 }
534 for pattern in &rules.ask {
535 if glob_matches(pattern, cmd) {
536 return PermissionDecision::Ask;
537 }
538 }
539 }
540 PermissionDecision::UseRiskClassifier
541}
542
543#[derive(Debug, PartialEq)]
544pub enum PermissionDecision {
545 Allow,
546 Deny,
547 Ask,
548 UseRiskClassifier,
549}
550
551pub fn glob_matches(pattern: &str, text: &str) -> bool {
554 let p = pattern.to_lowercase();
555 let t = text.to_lowercase();
556 if p == "*" {
557 return true;
558 }
559 if let Some(star) = p.find('*') {
560 let prefix = &p[..star];
561 let suffix = &p[star + 1..];
562 t.starts_with(prefix) && (suffix.is_empty() || t.ends_with(suffix))
563 } else {
564 t.contains(&p)
565 }
566}