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 api_url_is_local(url: &str) -> bool {
298 let lower = url.trim().to_ascii_lowercase();
299 let lower = lower.trim_end_matches('/');
300 let host_part = lower
302 .strip_prefix("https://")
303 .or_else(|| lower.strip_prefix("http://"))
304 .unwrap_or(lower);
305 let authority = host_part.split('/').next().unwrap_or("");
307 let host = if authority.starts_with('[') {
309 authority
310 .trim_start_matches('[')
311 .split(']')
312 .next()
313 .unwrap_or("")
314 } else {
315 if authority.contains("::") {
318 authority.split('/').next().unwrap_or(authority)
320 } else {
321 authority.split(':').next().unwrap_or(authority)
322 }
323 };
324 host == "localhost"
325 || host == "127.0.0.1"
326 || host.starts_with("127.")
327 || host == "::1"
328 || host == "[::1]"
329}
330
331pub fn effective_api_url(config: &HematiteConfig, cli_default: &str) -> String {
332 match &config.api_url {
333 Some(url) if !api_url_is_local(url) => {
334 eprintln!(
335 "[hematite] WARNING: workspace settings.json is redirecting the inference \
336 endpoint to a remote host: {url}. Verify this is intentional."
337 );
338 url.clone()
339 }
340 Some(url) => url.clone(),
341 None => cli_default.to_string(),
342 }
343}
344
345pub fn set_api_url_override(url: Option<&str>) -> Result<(), String> {
346 let mut config = load_config();
347 config.api_url = url
348 .map(str::trim)
349 .filter(|value| !value.is_empty())
350 .map(|value| value.to_string());
351 save_config(&config)
352}
353
354pub fn preferred_coding_model(config: &HematiteConfig) -> Option<String> {
355 config
356 .think_model
357 .clone()
358 .or_else(|| config.model.clone())
359 .or_else(|| config.fast_model.clone())
360}
361
362pub fn set_preferred_coding_model(model_id: Option<&str>) -> Result<(), String> {
363 let mut config = load_config();
364 let normalized = model_id
365 .map(str::trim)
366 .filter(|value| !value.is_empty())
367 .map(|value| value.to_string());
368 config.think_model = normalized.clone();
369 if normalized.is_some() {
370 config.model = None;
371 }
372 save_config(&config)
373}
374
375pub fn set_preferred_embed_model(model_id: Option<&str>) -> Result<(), String> {
376 let mut config = load_config();
377 config.embed_model = model_id
378 .map(str::trim)
379 .filter(|value| !value.is_empty())
380 .map(|value| value.to_string());
381 save_config(&config)
382}
383
384pub fn set_gemma_native_formatting(enabled: bool) -> Result<(), String> {
385 set_gemma_native_mode(if enabled { "on" } else { "off" })
386}
387
388pub fn set_gemma_native_mode(mode: &str) -> Result<(), String> {
389 let mut config = load_config();
390 match mode {
391 "on" => {
392 config.gemma_native_auto = false;
393 config.gemma_native_formatting = true;
394 }
395 "off" => {
396 config.gemma_native_auto = false;
397 config.gemma_native_formatting = false;
398 }
399 "auto" => {
400 config.gemma_native_auto = true;
401 config.gemma_native_formatting = false;
402 }
403 _ => return Err(format!("Unknown gemma native mode: {}", mode)),
404 }
405 save_config(&config)
406}
407
408pub fn set_voice(voice_id: &str) -> Result<(), String> {
409 let mut config = load_config();
410 config.voice = Some(voice_id.to_string());
411 save_config(&config)
412}
413
414pub fn effective_voice(config: &HematiteConfig) -> String {
415 config.voice.clone().unwrap_or_else(|| "af_sky".to_string())
416}
417
418pub fn effective_voice_speed(config: &HematiteConfig) -> f32 {
419 config.voice_speed.unwrap_or(1.0).clamp(0.5, 2.0)
420}
421
422pub fn effective_voice_volume(config: &HematiteConfig) -> f32 {
423 config.voice_volume.unwrap_or(1.0).clamp(0.0, 3.0)
424}
425
426pub fn effective_gemma_native_formatting(config: &HematiteConfig, model_name: &str) -> bool {
427 crate::agent::inference::is_hematite_native_model(model_name)
428 && (config.gemma_native_formatting || config.gemma_native_auto)
429}
430
431pub fn gemma_native_mode_label(config: &HematiteConfig, model_name: &str) -> &'static str {
432 if !crate::agent::inference::is_hematite_native_model(model_name) {
433 "inactive"
434 } else if config.gemma_native_formatting {
435 "on"
436 } else if config.gemma_native_auto {
437 "auto"
438 } else {
439 "off"
440 }
441}
442
443fn write_default_config(path: &std::path::Path) {
445 if let Some(parent) = path.parent() {
446 let _ = std::fs::create_dir_all(parent);
447 }
448 let default = r#"{
449 "_comment": "Hematite settings — edit and save, changes apply immediately without restart.",
450
451 "permissions": {
452 "allow": [
453 "cargo *",
454 "git status",
455 "git log *",
456 "git diff *",
457 "git branch *"
458 ],
459 "ask": [],
460 "deny": []
461 },
462
463 "trust": {
464 "allow": ["."],
465 "deny": []
466 },
467
468 "auto_approve_moderate": false,
469
470 "api_url": null,
471 "voice": null,
472 "voice_speed": null,
473 "voice_volume": null,
474 "context_hint": null,
475 "model": null,
476 "fast_model": null,
477 "think_model": null,
478 "embed_model": null,
479 "gemma_native_auto": true,
480 "gemma_native_formatting": false,
481 "searx_url": null,
482 "auto_start_searx": true,
483 "auto_stop_searx": false,
484
485 "verify": {
486 "default_profile": null,
487 "profiles": {
488 "rust": {
489 "build": "cargo build --color never",
490 "test": "cargo test --color never",
491 "lint": "cargo clippy --all-targets --all-features -- -D warnings",
492 "fix": "cargo fmt",
493 "timeout_secs": 120
494 }
495 }
496 },
497
498 "hooks": {
499 "pre_tool_use": [],
500 "post_tool_use": []
501 }
502 }
503"#;
504 let _ = std::fs::write(path, default);
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510
511 #[test]
512 fn provider_label_for_api_url_detects_known_runtimes() {
513 assert_eq!(
514 provider_label_for_api_url("http://localhost:1234/v1"),
515 "LM Studio"
516 );
517 assert_eq!(
518 provider_label_for_api_url("http://localhost:11434/v1"),
519 "Ollama"
520 );
521 assert_eq!(
522 provider_label_for_api_url("https://ai.example.com/v1"),
523 "Custom"
524 );
525 }
526
527 #[test]
528 fn default_api_url_for_provider_maps_presets() {
529 assert_eq!(
530 default_api_url_for_provider("LM Studio"),
531 DEFAULT_LM_STUDIO_API_URL
532 );
533 assert_eq!(
534 default_api_url_for_provider("Ollama"),
535 DEFAULT_OLLAMA_API_URL
536 );
537 assert_eq!(
538 default_api_url_for_provider("Custom"),
539 DEFAULT_LM_STUDIO_API_URL
540 );
541 }
542
543 #[test]
544 #[allow(clippy::field_reassign_with_default)]
545 fn preferred_coding_model_prefers_think_then_model_then_fast() {
546 let mut config = HematiteConfig::default();
547 config.fast_model = Some("fast".into());
548 assert_eq!(preferred_coding_model(&config), Some("fast".to_string()));
549
550 config.model = Some("main".into());
551 assert_eq!(preferred_coding_model(&config), Some("main".to_string()));
552
553 config.think_model = Some("think".into());
554 assert_eq!(preferred_coding_model(&config), Some("think".to_string()));
555 }
556}
557
558pub fn permission_for_shell(cmd: &str, config: &HematiteConfig) -> PermissionDecision {
566 if let Some(rules) = &config.permissions {
567 for pattern in &rules.deny {
568 if glob_matches(pattern, cmd) {
569 return PermissionDecision::Deny;
570 }
571 }
572 for pattern in &rules.allow {
573 if glob_matches(pattern, cmd) {
574 return PermissionDecision::Allow;
575 }
576 }
577 for pattern in &rules.ask {
578 if glob_matches(pattern, cmd) {
579 return PermissionDecision::Ask;
580 }
581 }
582 }
583 PermissionDecision::UseRiskClassifier
584}
585
586#[derive(Debug, PartialEq)]
587pub enum PermissionDecision {
588 Allow,
589 Deny,
590 Ask,
591 UseRiskClassifier,
592}
593
594pub fn glob_matches(pattern: &str, text: &str) -> bool {
597 let p = pattern.to_lowercase();
598 let t = text.to_lowercase();
599 if p == "*" {
600 return true;
601 }
602 if let Some(star) = p.find('*') {
603 let prefix = &p[..star];
604 let suffix = &p[star + 1..];
605 t.starts_with(prefix) && (suffix.is_empty() || t.ends_with(suffix))
606 } else {
607 t.contains(&p)
608 }
609}