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