1use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7
8fn default_true() -> bool {
9 true
10}
11
12#[derive(Serialize, Deserialize, Default, Clone, Copy, Debug, PartialEq)]
13pub enum PermissionMode {
14 #[default]
15 Developer,
16 ReadOnly,
17 SystemAdmin,
18}
19
20#[derive(Serialize, Deserialize, Default, Clone, Debug)]
21pub struct HematiteConfig {
22 #[serde(default)]
24 pub mode: PermissionMode,
25 pub permissions: Option<PermissionRules>,
27 #[serde(default)]
29 pub trust: WorkspaceTrustConfig,
30 pub model: Option<String>,
32 pub fast_model: Option<String>,
34 pub think_model: Option<String>,
36 #[serde(default = "default_true")]
38 pub gemma_native_auto: bool,
39 #[serde(default)]
41 pub gemma_native_formatting: bool,
42 pub api_url: Option<String>,
45 pub voice: Option<String>,
47 pub voice_speed: Option<f32>,
49 pub voice_volume: Option<f32>,
51 pub context_hint: Option<String>,
53 pub deno_path: Option<String>,
57 #[serde(default)]
59 pub verify: VerifyProfilesConfig,
60 #[serde(default)]
62 pub hooks: crate::agent::hooks::RuntimeHookConfig,
63 pub searx_url: Option<String>,
66 #[serde(default = "default_true")]
68 pub auto_start_searx: bool,
69}
70
71#[derive(Serialize, Deserialize, Clone, Debug)]
72pub struct WorkspaceTrustConfig {
73 #[serde(default = "default_trusted_workspace_roots")]
75 pub allow: Vec<String>,
76 #[serde(default)]
78 pub deny: Vec<String>,
79}
80
81impl Default for WorkspaceTrustConfig {
82 fn default() -> Self {
83 Self {
84 allow: default_trusted_workspace_roots(),
85 deny: Vec::new(),
86 }
87 }
88}
89
90fn default_trusted_workspace_roots() -> Vec<String> {
91 vec![".".to_string()]
92}
93
94#[derive(Serialize, Deserialize, Default, Clone, Debug)]
95pub struct VerifyProfilesConfig {
96 pub default_profile: Option<String>,
98 #[serde(default)]
100 pub profiles: BTreeMap<String, VerifyProfile>,
101}
102
103#[derive(Serialize, Deserialize, Default, Clone, Debug)]
104pub struct VerifyProfile {
105 pub build: Option<String>,
107 pub test: Option<String>,
109 pub lint: Option<String>,
111 pub fix: Option<String>,
113 pub timeout_secs: Option<u64>,
115}
116
117#[derive(Serialize, Deserialize, Default, Clone, Debug)]
118pub struct PermissionRules {
119 #[serde(default)]
121 pub allow: Vec<String>,
122 #[serde(default)]
124 pub ask: Vec<String>,
125 #[serde(default)]
127 pub deny: Vec<String>,
128}
129
130pub fn settings_path() -> std::path::PathBuf {
131 crate::tools::file_ops::hematite_dir().join("settings.json")
132}
133
134fn load_global_config() -> Option<HematiteConfig> {
136 let home = std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME"))?;
137 let path = std::path::PathBuf::from(home)
138 .join(".hematite")
139 .join("settings.json");
140 let data = std::fs::read_to_string(&path).ok()?;
141 serde_json::from_str(&data).ok()
142}
143
144pub fn load_config() -> HematiteConfig {
148 let path = settings_path();
149
150 let workspace: Option<HematiteConfig> = if path.exists() {
151 std::fs::read_to_string(&path)
152 .ok()
153 .and_then(|d| serde_json::from_str(&d).ok())
154 } else {
155 write_default_config(&path);
156 None
157 };
158
159 let global = load_global_config();
160
161 match (workspace, global) {
162 (Some(ws), Some(gb)) => {
163 HematiteConfig {
165 model: ws.model.or(gb.model),
166 fast_model: ws.fast_model.or(gb.fast_model),
167 think_model: ws.think_model.or(gb.think_model),
168 api_url: ws.api_url.or(gb.api_url),
169 voice: if ws.voice != HematiteConfig::default().voice {
170 ws.voice
171 } else {
172 gb.voice
173 },
174 voice_speed: ws.voice_speed.or(gb.voice_speed),
175 voice_volume: ws.voice_volume.or(gb.voice_volume),
176 context_hint: ws.context_hint.or(gb.context_hint),
177 searx_url: ws.searx_url.or(gb.searx_url),
178 auto_start_searx: ws.auto_start_searx, gemma_native_auto: ws.gemma_native_auto,
180 gemma_native_formatting: ws.gemma_native_formatting,
181 ..ws
182 }
183 }
184 (Some(ws), None) => ws,
185 (None, Some(gb)) => gb,
186 (None, None) => HematiteConfig::default(),
187 }
188}
189
190pub fn save_config(config: &HematiteConfig) -> Result<(), String> {
191 let path = settings_path();
192 if let Some(parent) = path.parent() {
193 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
194 }
195 let json = serde_json::to_string_pretty(config).map_err(|e| e.to_string())?;
196 std::fs::write(&path, json).map_err(|e| e.to_string())
197}
198
199pub fn set_gemma_native_formatting(enabled: bool) -> Result<(), String> {
200 set_gemma_native_mode(if enabled { "on" } else { "off" })
201}
202
203pub fn set_gemma_native_mode(mode: &str) -> Result<(), String> {
204 let mut config = load_config();
205 match mode {
206 "on" => {
207 config.gemma_native_auto = false;
208 config.gemma_native_formatting = true;
209 }
210 "off" => {
211 config.gemma_native_auto = false;
212 config.gemma_native_formatting = false;
213 }
214 "auto" => {
215 config.gemma_native_auto = true;
216 config.gemma_native_formatting = false;
217 }
218 _ => return Err(format!("Unknown gemma native mode: {}", mode)),
219 }
220 save_config(&config)
221}
222
223pub fn set_voice(voice_id: &str) -> Result<(), String> {
224 let mut config = load_config();
225 config.voice = Some(voice_id.to_string());
226 save_config(&config)
227}
228
229pub fn effective_voice(config: &HematiteConfig) -> String {
230 config.voice.clone().unwrap_or_else(|| "af_sky".to_string())
231}
232
233pub fn effective_voice_speed(config: &HematiteConfig) -> f32 {
234 config.voice_speed.unwrap_or(1.0).clamp(0.5, 2.0)
235}
236
237pub fn effective_voice_volume(config: &HematiteConfig) -> f32 {
238 config.voice_volume.unwrap_or(1.0).clamp(0.0, 3.0)
239}
240
241pub fn effective_gemma_native_formatting(config: &HematiteConfig, model_name: &str) -> bool {
242 crate::agent::inference::is_hematite_native_model(model_name)
243 && (config.gemma_native_formatting || config.gemma_native_auto)
244}
245
246pub fn gemma_native_mode_label(config: &HematiteConfig, model_name: &str) -> &'static str {
247 if !crate::agent::inference::is_hematite_native_model(model_name) {
248 "inactive"
249 } else if config.gemma_native_formatting {
250 "on"
251 } else if config.gemma_native_auto {
252 "auto"
253 } else {
254 "off"
255 }
256}
257
258fn write_default_config(path: &std::path::Path) {
260 if let Some(parent) = path.parent() {
261 let _ = std::fs::create_dir_all(parent);
262 }
263 let default = r#"{
264 "_comment": "Hematite settings — edit and save, changes apply immediately without restart.",
265
266 "permissions": {
267 "allow": [
268 "cargo *",
269 "git status",
270 "git log *",
271 "git diff *",
272 "git branch *"
273 ],
274 "ask": [],
275 "deny": []
276 },
277
278 "trust": {
279 "allow": ["."],
280 "deny": []
281 },
282
283 "auto_approve_moderate": false,
284
285 "api_url": null,
286 "voice": null,
287 "voice_speed": null,
288 "voice_volume": null,
289 "context_hint": null,
290 "model": null,
291 "fast_model": null,
292 "think_model": null,
293 "gemma_native_auto": true,
294 "gemma_native_formatting": false,
295 "searx_url": null,
296
297 "verify": {
298 "default_profile": null,
299 "profiles": {
300 "rust": {
301 "build": "cargo build --color never",
302 "test": "cargo test --color never",
303 "lint": "cargo clippy --all-targets --all-features -- -D warnings",
304 "fix": "cargo fmt",
305 "timeout_secs": 120
306 }
307 }
308 },
309
310 "hooks": {
311 "pre_tool_use": [],
312 "post_tool_use": []
313 }
314}
315"#;
316 let _ = std::fs::write(path, default);
317}
318
319pub fn permission_for_shell(cmd: &str, config: &HematiteConfig) -> PermissionDecision {
327 if let Some(rules) = &config.permissions {
328 for pattern in &rules.deny {
329 if glob_matches(pattern, cmd) {
330 return PermissionDecision::Deny;
331 }
332 }
333 for pattern in &rules.allow {
334 if glob_matches(pattern, cmd) {
335 return PermissionDecision::Allow;
336 }
337 }
338 for pattern in &rules.ask {
339 if glob_matches(pattern, cmd) {
340 return PermissionDecision::Ask;
341 }
342 }
343 }
344 PermissionDecision::UseRiskClassifier
345}
346
347#[derive(Debug, PartialEq)]
348pub enum PermissionDecision {
349 Allow,
350 Deny,
351 Ask,
352 UseRiskClassifier,
353}
354
355pub fn glob_matches(pattern: &str, text: &str) -> bool {
358 let p = pattern.to_lowercase();
359 let t = text.to_lowercase();
360 if p == "*" {
361 return true;
362 }
363 if let Some(star) = p.find('*') {
364 let prefix = &p[..star];
365 let suffix = &p[star + 1..];
366 t.starts_with(prefix) && (suffix.is_empty() || t.ends_with(suffix))
367 } else {
368 t.contains(&p)
369 }
370}