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