use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub const DEFAULT_LM_STUDIO_API_URL: &str = "http://localhost:1234/v1";
pub const DEFAULT_OLLAMA_API_URL: &str = "http://localhost:11434/v1";
fn default_true() -> bool {
true
}
#[derive(Serialize, Deserialize, Default, Clone, Copy, Debug, PartialEq)]
pub enum PermissionMode {
#[default]
Developer,
ReadOnly,
SystemAdmin,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct HematiteConfig {
#[serde(default)]
pub mode: PermissionMode,
pub permissions: Option<PermissionRules>,
#[serde(default)]
pub trust: WorkspaceTrustConfig,
pub model: Option<String>,
pub fast_model: Option<String>,
pub think_model: Option<String>,
pub embed_model: Option<String>,
#[serde(default = "default_true")]
pub gemma_native_auto: bool,
#[serde(default)]
pub gemma_native_formatting: bool,
pub api_url: Option<String>,
pub voice: Option<String>,
pub voice_speed: Option<f32>,
pub voice_volume: Option<f32>,
pub context_hint: Option<String>,
pub deno_path: Option<String>,
pub python_path: Option<String>,
#[serde(default)]
pub verify: VerifyProfilesConfig,
#[serde(default)]
pub hooks: crate::agent::hooks::RuntimeHookConfig,
pub searx_url: Option<String>,
#[serde(default = "default_true")]
pub auto_start_searx: bool,
#[serde(default)]
pub auto_stop_searx: bool,
}
impl Default for HematiteConfig {
fn default() -> Self {
Self {
mode: PermissionMode::Developer,
permissions: None,
trust: WorkspaceTrustConfig::default(),
model: None,
fast_model: None,
think_model: None,
embed_model: None,
gemma_native_auto: true,
gemma_native_formatting: false,
api_url: None,
voice: None,
voice_speed: None,
voice_volume: None,
context_hint: None,
deno_path: None,
python_path: None,
verify: VerifyProfilesConfig::default(),
hooks: crate::agent::hooks::RuntimeHookConfig::default(),
searx_url: None,
auto_start_searx: true,
auto_stop_searx: false,
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct WorkspaceTrustConfig {
#[serde(default = "default_trusted_workspace_roots")]
pub allow: Vec<String>,
#[serde(default)]
pub deny: Vec<String>,
}
impl Default for WorkspaceTrustConfig {
fn default() -> Self {
Self {
allow: default_trusted_workspace_roots(),
deny: Vec::new(),
}
}
}
fn default_trusted_workspace_roots() -> Vec<String> {
vec![".".to_string()]
}
#[derive(Serialize, Deserialize, Default, Clone, Debug)]
pub struct VerifyProfilesConfig {
pub default_profile: Option<String>,
#[serde(default)]
pub profiles: BTreeMap<String, VerifyProfile>,
}
#[derive(Serialize, Deserialize, Default, Clone, Debug)]
pub struct VerifyProfile {
pub build: Option<String>,
pub test: Option<String>,
pub lint: Option<String>,
pub fix: Option<String>,
pub timeout_secs: Option<u64>,
}
#[derive(Serialize, Deserialize, Default, Clone, Debug)]
pub struct PermissionRules {
#[serde(default)]
pub allow: Vec<String>,
#[serde(default)]
pub ask: Vec<String>,
#[serde(default)]
pub deny: Vec<String>,
}
pub fn settings_path() -> std::path::PathBuf {
crate::tools::file_ops::hematite_dir().join("settings.json")
}
fn load_global_config() -> Option<HematiteConfig> {
let home = std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME"))?;
let path = std::path::PathBuf::from(home)
.join(".hematite")
.join("settings.json");
let data = std::fs::read_to_string(&path).ok()?;
serde_json::from_str(&data).ok()
}
pub fn load_config() -> HematiteConfig {
let path = settings_path();
let workspace: Option<HematiteConfig> = if path.exists() {
let content = std::fs::read_to_string(&path).ok();
if let Some(d) = content {
match serde_json::from_str(&d) {
Ok(cfg) => Some(cfg),
Err(_) => None,
}
} else {
None
}
} else {
write_default_config(&path);
None
};
let global = load_global_config();
match (workspace, global) {
(Some(ws), Some(gb)) => {
HematiteConfig {
model: ws.model.or(gb.model),
fast_model: ws.fast_model.or(gb.fast_model),
think_model: ws.think_model.or(gb.think_model),
embed_model: ws.embed_model.or(gb.embed_model),
api_url: ws.api_url.or(gb.api_url),
voice: if ws.voice != HematiteConfig::default().voice {
ws.voice
} else {
gb.voice
},
voice_speed: ws.voice_speed.or(gb.voice_speed),
voice_volume: ws.voice_volume.or(gb.voice_volume),
context_hint: ws.context_hint.or(gb.context_hint),
deno_path: ws.deno_path.or(gb.deno_path),
python_path: ws.python_path.or(gb.python_path),
searx_url: ws.searx_url.or(gb.searx_url),
auto_start_searx: ws.auto_start_searx, auto_stop_searx: ws.auto_stop_searx, gemma_native_auto: ws.gemma_native_auto,
gemma_native_formatting: ws.gemma_native_formatting,
..ws
}
}
(Some(ws), None) => ws,
(None, Some(gb)) => gb,
(None, None) => HematiteConfig::default(),
}
}
pub fn save_config(config: &HematiteConfig) -> Result<(), String> {
let path = settings_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let json = serde_json::to_string_pretty(config).map_err(|e| e.to_string())?;
std::fs::write(&path, json).map_err(|e| e.to_string())
}
pub fn provider_label_for_api_url(url: &str) -> &'static str {
let normalized = url.trim().trim_end_matches('/').to_ascii_lowercase();
if normalized.contains("11434") || normalized.contains("ollama") {
"Ollama"
} else if normalized.contains("1234") || normalized.contains("lmstudio") {
"LM Studio"
} else {
"Custom"
}
}
pub fn default_api_url_for_provider(provider_name: &str) -> &'static str {
match provider_name {
"Ollama" => DEFAULT_OLLAMA_API_URL,
_ => DEFAULT_LM_STUDIO_API_URL,
}
}
pub fn effective_api_url(config: &HematiteConfig, cli_default: &str) -> String {
config
.api_url
.clone()
.unwrap_or_else(|| cli_default.to_string())
}
pub fn set_api_url_override(url: Option<&str>) -> Result<(), String> {
let mut config = load_config();
config.api_url = url
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.to_string());
save_config(&config)
}
pub fn preferred_coding_model(config: &HematiteConfig) -> Option<String> {
config
.think_model
.clone()
.or(config.model.clone())
.or(config.fast_model.clone())
}
pub fn set_preferred_coding_model(model_id: Option<&str>) -> Result<(), String> {
let mut config = load_config();
let normalized = model_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.to_string());
config.think_model = normalized.clone();
if normalized.is_some() {
config.model = None;
}
save_config(&config)
}
pub fn set_preferred_embed_model(model_id: Option<&str>) -> Result<(), String> {
let mut config = load_config();
config.embed_model = model_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.to_string());
save_config(&config)
}
pub fn set_gemma_native_formatting(enabled: bool) -> Result<(), String> {
set_gemma_native_mode(if enabled { "on" } else { "off" })
}
pub fn set_gemma_native_mode(mode: &str) -> Result<(), String> {
let mut config = load_config();
match mode {
"on" => {
config.gemma_native_auto = false;
config.gemma_native_formatting = true;
}
"off" => {
config.gemma_native_auto = false;
config.gemma_native_formatting = false;
}
"auto" => {
config.gemma_native_auto = true;
config.gemma_native_formatting = false;
}
_ => return Err(format!("Unknown gemma native mode: {}", mode)),
}
save_config(&config)
}
pub fn set_voice(voice_id: &str) -> Result<(), String> {
let mut config = load_config();
config.voice = Some(voice_id.to_string());
save_config(&config)
}
pub fn effective_voice(config: &HematiteConfig) -> String {
config.voice.clone().unwrap_or_else(|| "af_sky".to_string())
}
pub fn effective_voice_speed(config: &HematiteConfig) -> f32 {
config.voice_speed.unwrap_or(1.0).clamp(0.5, 2.0)
}
pub fn effective_voice_volume(config: &HematiteConfig) -> f32 {
config.voice_volume.unwrap_or(1.0).clamp(0.0, 3.0)
}
pub fn effective_gemma_native_formatting(config: &HematiteConfig, model_name: &str) -> bool {
crate::agent::inference::is_hematite_native_model(model_name)
&& (config.gemma_native_formatting || config.gemma_native_auto)
}
pub fn gemma_native_mode_label(config: &HematiteConfig, model_name: &str) -> &'static str {
if !crate::agent::inference::is_hematite_native_model(model_name) {
"inactive"
} else if config.gemma_native_formatting {
"on"
} else if config.gemma_native_auto {
"auto"
} else {
"off"
}
}
fn write_default_config(path: &std::path::Path) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let default = r#"{
"_comment": "Hematite settings — edit and save, changes apply immediately without restart.",
"permissions": {
"allow": [
"cargo *",
"git status",
"git log *",
"git diff *",
"git branch *"
],
"ask": [],
"deny": []
},
"trust": {
"allow": ["."],
"deny": []
},
"auto_approve_moderate": false,
"api_url": null,
"voice": null,
"voice_speed": null,
"voice_volume": null,
"context_hint": null,
"model": null,
"fast_model": null,
"think_model": null,
"embed_model": null,
"gemma_native_auto": true,
"gemma_native_formatting": false,
"searx_url": null,
"auto_start_searx": true,
"auto_stop_searx": false,
"verify": {
"default_profile": null,
"profiles": {
"rust": {
"build": "cargo build --color never",
"test": "cargo test --color never",
"lint": "cargo clippy --all-targets --all-features -- -D warnings",
"fix": "cargo fmt",
"timeout_secs": 120
}
}
},
"hooks": {
"pre_tool_use": [],
"post_tool_use": []
}
}
"#;
let _ = std::fs::write(path, default);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_label_for_api_url_detects_known_runtimes() {
assert_eq!(
provider_label_for_api_url("http://localhost:1234/v1"),
"LM Studio"
);
assert_eq!(
provider_label_for_api_url("http://localhost:11434/v1"),
"Ollama"
);
assert_eq!(
provider_label_for_api_url("https://ai.example.com/v1"),
"Custom"
);
}
#[test]
fn default_api_url_for_provider_maps_presets() {
assert_eq!(
default_api_url_for_provider("LM Studio"),
DEFAULT_LM_STUDIO_API_URL
);
assert_eq!(
default_api_url_for_provider("Ollama"),
DEFAULT_OLLAMA_API_URL
);
assert_eq!(
default_api_url_for_provider("Custom"),
DEFAULT_LM_STUDIO_API_URL
);
}
#[test]
fn preferred_coding_model_prefers_think_then_model_then_fast() {
let mut config = HematiteConfig::default();
config.fast_model = Some("fast".into());
assert_eq!(preferred_coding_model(&config), Some("fast".to_string()));
config.model = Some("main".into());
assert_eq!(preferred_coding_model(&config), Some("main".to_string()));
config.think_model = Some("think".into());
assert_eq!(preferred_coding_model(&config), Some("think".to_string()));
}
}
pub fn permission_for_shell(cmd: &str, config: &HematiteConfig) -> PermissionDecision {
if let Some(rules) = &config.permissions {
for pattern in &rules.deny {
if glob_matches(pattern, cmd) {
return PermissionDecision::Deny;
}
}
for pattern in &rules.allow {
if glob_matches(pattern, cmd) {
return PermissionDecision::Allow;
}
}
for pattern in &rules.ask {
if glob_matches(pattern, cmd) {
return PermissionDecision::Ask;
}
}
}
PermissionDecision::UseRiskClassifier
}
#[derive(Debug, PartialEq)]
pub enum PermissionDecision {
Allow,
Deny,
Ask,
UseRiskClassifier,
}
pub fn glob_matches(pattern: &str, text: &str) -> bool {
let p = pattern.to_lowercase();
let t = text.to_lowercase();
if p == "*" {
return true;
}
if let Some(star) = p.find('*') {
let prefix = &p[..star];
let suffix = &p[star + 1..];
t.starts_with(prefix) && (suffix.is_empty() || t.ends_with(suffix))
} else {
t.contains(&p)
}
}