1use parking_lot::RwLock;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::PathBuf;
5use std::sync::Arc;
6use thiserror::Error;
7
8#[derive(Debug, Error)]
9pub enum SettingsError {
10 #[error(transparent)]
11 Io(#[from] std::io::Error),
12 #[error(transparent)]
13 Json(#[from] serde_json::Error),
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Settings {
18 #[serde(default)]
22 pub allow_cloud_ai: bool,
23
24 #[serde(default = "default_allow_local_ai")]
30 pub allow_local_ai: bool,
31
32 #[serde(default = "default_local_ai_base_url")]
38 pub local_ai_base_url: Option<String>,
39
40 #[serde(default)]
44 pub llama_server_binary: Option<String>,
45
46 #[serde(default)]
49 pub llama_server_model: Option<String>,
50
51 #[serde(default)]
54 pub llama_params: HashMap<String, crate::llm_server::LlamaParams>,
55
56 #[serde(default)]
61 pub local_ai_models: Vec<String>,
62
63 #[serde(default = "default_providers_enabled")]
66 pub providers_enabled: HashMap<String, bool>,
67
68 #[serde(default = "default_theme")]
72 pub theme: String,
73
74 #[serde(default = "default_confirm_destructive")]
79 pub confirm_destructive: bool,
80
81 #[serde(default = "default_scheduler_enabled")]
84 pub scheduler_enabled: bool,
85
86 #[serde(default = "default_scheduler_poll_secs")]
88 pub scheduler_poll_secs: u64,
89
90 #[serde(default = "default_max_agentic_iterations")]
95 pub max_agentic_iterations: u32,
96
97 #[serde(default = "default_max_agentic_seconds")]
102 pub max_agentic_seconds: u32,
103
104 #[serde(default = "default_max_agentic_tokens")]
109 pub max_agentic_tokens: u32,
110}
111
112fn default_providers_enabled() -> HashMap<String, bool> {
113 let mut m = HashMap::new();
114 m.insert("claude".into(), true);
115 m.insert("openai".into(), true);
116 m.insert("gemini".into(), true);
117 m.insert("nvidia".into(), true);
118 m.insert("deepseek".into(), true);
119 m.insert("local".into(), true);
120 m
121}
122
123fn default_max_agentic_iterations() -> u32 {
124 crate::MAX_AGENTIC_ITERATIONS
125}
126
127fn default_max_agentic_seconds() -> u32 {
128 0
129}
130
131fn default_max_agentic_tokens() -> u32 {
132 0
133}
134
135fn default_allow_local_ai() -> bool {
136 true
137}
138
139fn default_local_ai_base_url() -> Option<String> {
140 None
143}
144
145fn default_theme() -> String {
146 "dark".into()
147}
148
149fn default_confirm_destructive() -> bool {
150 true
151}
152
153fn default_scheduler_enabled() -> bool {
154 true
155}
156
157fn default_scheduler_poll_secs() -> u64 {
158 30
159}
160
161impl Default for Settings {
162 fn default() -> Self {
163 Self {
164 allow_cloud_ai: false,
165 allow_local_ai: default_allow_local_ai(),
166 local_ai_base_url: default_local_ai_base_url(),
167 llama_server_binary: None,
168 llama_server_model: None,
169 llama_params: HashMap::new(),
170 local_ai_models: Vec::new(),
171 providers_enabled: default_providers_enabled(),
172 theme: default_theme(),
173 confirm_destructive: default_confirm_destructive(),
174 scheduler_enabled: default_scheduler_enabled(),
175 scheduler_poll_secs: default_scheduler_poll_secs(),
176 max_agentic_iterations: default_max_agentic_iterations(),
177 max_agentic_seconds: default_max_agentic_seconds(),
178 max_agentic_tokens: default_max_agentic_tokens(),
179 }
180 }
181}
182
183#[derive(Clone)]
184pub struct SettingsStore {
185 path: Option<PathBuf>,
186 inner: Arc<RwLock<Settings>>,
187}
188
189impl SettingsStore {
190 pub fn open(path: PathBuf) -> Self {
191 let inner = match std::fs::read_to_string(&path) {
192 Ok(body) => serde_json::from_str::<Settings>(&body).unwrap_or_default(),
193 Err(_) => Settings::default(),
194 };
195 Self {
196 path: Some(path),
197 inner: Arc::new(RwLock::new(inner)),
198 }
199 }
200
201 pub fn in_memory() -> Self {
202 Self {
203 path: None,
204 inner: Arc::new(RwLock::new(Settings::default())),
205 }
206 }
207
208 pub fn snapshot(&self) -> Settings {
209 self.inner.read().clone()
210 }
211
212 pub fn allow_cloud_ai(&self) -> bool {
213 self.inner.read().allow_cloud_ai
214 }
215
216 pub fn allow_local_ai(&self) -> bool {
217 self.inner.read().allow_local_ai
218 }
219
220 pub fn local_ai_base_url(&self) -> Option<String> {
221 self.inner
224 .read()
225 .local_ai_base_url
226 .clone()
227 .filter(|s| !s.trim().is_empty())
228 }
229
230 pub fn local_ai_models(&self) -> Vec<String> {
231 self.inner.read().local_ai_models.clone()
232 }
233
234 pub fn llama_server_binary(&self) -> Option<String> {
235 self.inner.read().llama_server_binary.clone()
236 }
237
238 pub fn llama_server_model(&self) -> Option<String> {
239 self.inner.read().llama_server_model.clone()
240 }
241
242 pub fn provider_enabled(&self, name: &str) -> bool {
243 self.inner
244 .read()
245 .providers_enabled
246 .get(name)
247 .copied()
248 .unwrap_or(true)
249 }
250
251 pub fn theme(&self) -> String {
252 self.inner.read().theme.clone()
253 }
254
255 pub fn confirm_destructive(&self) -> bool {
256 self.inner.read().confirm_destructive
257 }
258
259 pub fn scheduler_enabled(&self) -> bool {
260 self.inner.read().scheduler_enabled
261 }
262
263 pub fn scheduler_poll_secs(&self) -> u64 {
264 self.inner
265 .read()
266 .scheduler_poll_secs
267 .clamp(5, 24 * 60 * 60)
268 }
269
270 pub fn max_agentic_iterations(&self) -> u32 {
271 self.inner.read().max_agentic_iterations.clamp(1, 50)
272 }
273
274 pub fn max_agentic_seconds(&self) -> u32 {
277 self.inner.read().max_agentic_seconds.min(24 * 60 * 60)
278 }
279
280 pub fn max_agentic_tokens(&self) -> u32 {
282 self.inner.read().max_agentic_tokens
283 }
284
285 pub fn update(&self, patch: SettingsPatch) -> Result<Settings, SettingsError> {
286 let mut guard = self.inner.write();
287 if let Some(v) = patch.allow_cloud_ai {
288 guard.allow_cloud_ai = v;
289 }
290 if let Some(v) = patch.allow_local_ai {
291 guard.allow_local_ai = v;
292 }
293 if let Some(u) = patch.local_ai_base_url {
294 guard.local_ai_base_url = Some(u);
295 }
296 if let Some(models) = patch.local_ai_models {
297 guard.local_ai_models = models;
298 }
299 if let Some(b) = patch.llama_server_binary {
300 guard.llama_server_binary = Some(b);
301 }
302 if let Some(m) = patch.llama_server_model {
303 guard.llama_server_model = Some(m);
304 }
305 if let Some(map) = patch.llama_params {
306 for (k, v) in map {
307 guard.llama_params.insert(k, v);
308 }
309 }
310 if let Some(map) = patch.providers_enabled {
311 for (k, v) in map {
312 guard.providers_enabled.insert(k, v);
313 }
314 }
315 if let Some(t) = patch.theme {
316 guard.theme = t;
317 }
318 if let Some(v) = patch.confirm_destructive {
319 guard.confirm_destructive = v;
320 }
321 if let Some(v) = patch.scheduler_enabled {
322 guard.scheduler_enabled = v;
323 }
324 if let Some(v) = patch.scheduler_poll_secs {
325 guard.scheduler_poll_secs = v.clamp(5, 24 * 60 * 60);
326 }
327 if let Some(v) = patch.max_agentic_iterations {
328 guard.max_agentic_iterations = v.clamp(1, 50);
329 }
330 if let Some(v) = patch.max_agentic_seconds {
331 guard.max_agentic_seconds = v.min(24 * 60 * 60);
332 }
333 if let Some(v) = patch.max_agentic_tokens {
334 guard.max_agentic_tokens = v;
335 }
336 let snapshot = guard.clone();
337 drop(guard);
338
339 if let Some(path) = &self.path {
340 if let Some(parent) = path.parent() {
341 let _ = std::fs::create_dir_all(parent);
342 }
343 std::fs::write(path, serde_json::to_string_pretty(&snapshot)?)?;
344 }
345 Ok(snapshot)
346 }
347}
348
349#[derive(Debug, Clone, Default, Serialize, Deserialize)]
350pub struct SettingsPatch {
351 pub allow_cloud_ai: Option<bool>,
352 pub allow_local_ai: Option<bool>,
353 pub local_ai_base_url: Option<String>,
354 pub local_ai_models: Option<Vec<String>>,
355 pub llama_server_binary: Option<String>,
356 pub llama_server_model: Option<String>,
357 pub llama_params: Option<HashMap<String, crate::llm_server::LlamaParams>>,
358 pub providers_enabled: Option<HashMap<String, bool>>,
359 pub theme: Option<String>,
360 pub confirm_destructive: Option<bool>,
361 pub scheduler_enabled: Option<bool>,
362 pub scheduler_poll_secs: Option<u64>,
363 pub max_agentic_iterations: Option<u32>,
364 pub max_agentic_seconds: Option<u32>,
365 pub max_agentic_tokens: Option<u32>,
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 #[test]
373 fn default_is_zero_egress() {
374 let s = Settings::default();
375 assert!(!s.allow_cloud_ai);
376 }
377
378 #[test]
379 fn default_theme_is_dark() {
380 assert_eq!(Settings::default().theme, "dark");
381 }
382
383 #[test]
384 fn update_theme_persists() {
385 let store = SettingsStore::in_memory();
386 store
387 .update(SettingsPatch {
388 theme: Some("light".into()),
389 ..Default::default()
390 })
391 .unwrap();
392 assert_eq!(store.theme(), "light");
393 }
394
395 #[test]
396 fn max_agentic_iterations_defaults_and_clamps() {
397 let store = SettingsStore::in_memory();
398 assert_eq!(store.max_agentic_iterations(), crate::MAX_AGENTIC_ITERATIONS);
399 store
401 .update(SettingsPatch {
402 max_agentic_iterations: Some(0),
403 ..Default::default()
404 })
405 .unwrap();
406 assert_eq!(store.max_agentic_iterations(), 1);
407 store
408 .update(SettingsPatch {
409 max_agentic_iterations: Some(12),
410 ..Default::default()
411 })
412 .unwrap();
413 assert_eq!(store.max_agentic_iterations(), 12);
414 }
415
416 #[test]
417 fn max_agentic_seconds_defaults_unlimited_and_clamps() {
418 let store = SettingsStore::in_memory();
419 assert_eq!(store.max_agentic_seconds(), 0); store
421 .update(SettingsPatch {
422 max_agentic_seconds: Some(120),
423 ..Default::default()
424 })
425 .unwrap();
426 assert_eq!(store.max_agentic_seconds(), 120);
427 store
428 .update(SettingsPatch {
429 max_agentic_seconds: Some(999_999),
430 ..Default::default()
431 })
432 .unwrap();
433 assert_eq!(store.max_agentic_seconds(), 24 * 60 * 60); }
435
436 #[test]
437 fn max_agentic_tokens_defaults_unlimited() {
438 let store = SettingsStore::in_memory();
439 assert_eq!(store.max_agentic_tokens(), 0); store
441 .update(SettingsPatch {
442 max_agentic_tokens: Some(50_000),
443 ..Default::default()
444 })
445 .unwrap();
446 assert_eq!(store.max_agentic_tokens(), 50_000);
447 }
448
449 #[test]
450 fn update_persists_to_disk_when_path_set() {
451 let tmp = std::env::temp_dir().join(format!("flow-settings-{}.json", uuid::Uuid::new_v4()));
452 let store = SettingsStore::open(tmp.clone());
453 store
454 .update(SettingsPatch {
455 allow_cloud_ai: Some(true),
456 ..Default::default()
457 })
458 .unwrap();
459 let body = std::fs::read_to_string(&tmp).unwrap();
460 assert!(body.contains("\"allow_cloud_ai\": true"));
461 let _ = std::fs::remove_file(&tmp);
462 }
463
464 #[test]
465 fn provider_enabled_defaults_to_true_for_unknown_names() {
466 let store = SettingsStore::in_memory();
467 assert!(store.provider_enabled("custom"));
468 }
469
470 #[test]
471 fn update_can_disable_a_provider() {
472 let store = SettingsStore::in_memory();
473 let mut patch = HashMap::new();
474 patch.insert("openai".into(), false);
475 store
476 .update(SettingsPatch {
477 allow_cloud_ai: Some(true),
478 providers_enabled: Some(patch),
479 ..Default::default()
480 })
481 .unwrap();
482 assert!(!store.provider_enabled("openai"));
483 assert!(store.provider_enabled("claude"));
484 }
485}