1use anyhow::Context;
2use serde::{Deserialize, Serialize};
3use std::collections::HashSet;
4use std::path::PathBuf;
5
6fn is_false(v: &bool) -> bool {
8 !*v
9}
10
11#[derive(Debug, Clone, Serialize, Deserialize, Default)]
14#[serde(rename_all = "camelCase")]
15pub struct Settings {
16 #[serde(default, skip_serializing_if = "Option::is_none")]
17 pub default_provider: Option<String>,
18
19 #[serde(default, skip_serializing_if = "Option::is_none")]
20 pub default_model: Option<String>,
21
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub default_thinking_level: Option<String>,
24
25 #[serde(default, skip_serializing_if = "Vec::is_empty")]
26 pub tools: Vec<String>,
27
28 #[serde(default, skip_serializing_if = "Vec::is_empty")]
29 pub exclude_tools: Vec<String>,
30
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub theme: Option<String>,
33
34 #[serde(default, skip_serializing_if = "is_false")]
35 pub verbose: bool,
36
37 #[serde(
39 default,
40 skip_serializing_if = "Option::is_none",
41 rename = "hideThinkingBlock"
42 )]
43 pub hide_thinking: Option<bool>,
44
45 #[serde(
47 default,
48 skip_serializing_if = "Option::is_none",
49 rename = "collapseToolOutput"
50 )]
51 pub collapse_tool_output: Option<bool>,
52
53 #[serde(
55 default,
56 skip_serializing_if = "Option::is_none",
57 rename = "autoCompact"
58 )]
59 pub auto_compact: Option<bool>,
60
61 #[serde(
63 default,
64 skip_serializing_if = "Option::is_none",
65 rename = "compactReserveTokens"
66 )]
67 pub compact_reserve_tokens: Option<u64>,
68
69 #[serde(
71 default,
72 skip_serializing_if = "Option::is_none",
73 rename = "compactKeepRecentTokens"
74 )]
75 pub compact_keep_recent_tokens: Option<u64>,
76
77 #[serde(skip)]
81 pub(crate) modified_fields: HashSet<String>,
82}
83
84impl Settings {
85 pub fn set_hide_thinking(&mut self, value: Option<bool>) {
89 self.hide_thinking = value;
90 self.modified_fields.insert("hideThinkingBlock".into());
91 }
92
93 pub fn set_collapse_tool_output(&mut self, value: Option<bool>) {
95 self.collapse_tool_output = value;
96 self.modified_fields.insert("collapseToolOutput".into());
97 }
98
99 pub fn set_default_thinking_level(&mut self, value: Option<String>) {
101 self.default_thinking_level = value;
102 self.modified_fields.insert("defaultThinkingLevel".into());
103 }
104
105 pub fn set_auto_compact(&mut self, value: Option<bool>) {
107 self.auto_compact = value;
108 self.modified_fields.insert("autoCompact".into());
109 }
110
111 pub fn set_compact_reserve_tokens(&mut self, value: Option<u64>) {
113 self.compact_reserve_tokens = value;
114 self.modified_fields.insert("compactReserveTokens".into());
115 }
116
117 pub fn set_compact_keep_recent_tokens(&mut self, value: Option<u64>) {
119 self.compact_keep_recent_tokens = value;
120 self.modified_fields
121 .insert("compactKeepRecentTokens".into());
122 }
123
124 #[doc(hidden)]
127 pub fn mark_modified(&mut self, field: &str) {
128 self.modified_fields.insert(field.to_string());
129 }
130
131 pub fn load(cwd: &std::path::Path) -> anyhow::Result<Self> {
135 let global_path = Self::global_path()?;
136 Self::load_from(global_path, cwd)
137 }
138
139 pub fn load_from(
141 global_path: std::path::PathBuf,
142 cwd: &std::path::Path,
143 ) -> anyhow::Result<Self> {
144 let global = Self::load_file(&global_path)?;
145 let project = Self::load_file(&cwd.join(".rab").join("settings.json")).unwrap_or_default();
146 Ok(Self::merge(global, project))
147 }
148
149 fn global_path() -> anyhow::Result<PathBuf> {
150 let dir = directories::BaseDirs::new().context("Could not determine home directory")?;
151 Ok(dir
152 .home_dir()
153 .join(".rab")
154 .join("agent")
155 .join("settings.json"))
156 }
157
158 fn load_file(path: &std::path::Path) -> anyhow::Result<Settings> {
159 if !path.exists() {
160 return Ok(Settings::default());
161 }
162 let content = read_file_with_shared_lock(path)?;
164 serde_json::from_str(&content)
165 .with_context(|| format!("Failed to parse {}", path.display()))
166 }
167
168 fn merge(global: Settings, project: Settings) -> Self {
170 Self {
171 default_provider: project.default_provider.or(global.default_provider),
172 default_model: project.default_model.or(global.default_model),
173 default_thinking_level: project
174 .default_thinking_level
175 .or(global.default_thinking_level),
176 tools: if project.tools.is_empty() {
177 global.tools
178 } else {
179 project.tools
180 },
181 exclude_tools: if project.exclude_tools.is_empty() {
182 global.exclude_tools
183 } else {
184 project.exclude_tools
185 },
186 theme: project.theme.or(global.theme),
187 verbose: project.verbose || global.verbose,
188 hide_thinking: project.hide_thinking.or(global.hide_thinking),
189 collapse_tool_output: project.collapse_tool_output.or(global.collapse_tool_output),
190 auto_compact: project.auto_compact.or(global.auto_compact),
191 compact_reserve_tokens: project
192 .compact_reserve_tokens
193 .or(global.compact_reserve_tokens),
194 compact_keep_recent_tokens: project
195 .compact_keep_recent_tokens
196 .or(global.compact_keep_recent_tokens),
197 modified_fields: HashSet::new(),
198 }
199 }
200
201 pub fn save(&mut self) -> anyhow::Result<()> {
216 if self.modified_fields.is_empty() {
217 return Ok(());
218 }
219 let path = Self::global_path()?;
220 self.save_to(path)
221 }
222
223 pub fn save_to(&mut self, path: std::path::PathBuf) -> anyhow::Result<()> {
226 if self.modified_fields.is_empty() {
227 return Ok(());
228 }
229
230 if let Some(parent) = path.parent() {
231 std::fs::create_dir_all(parent)?;
232 }
233
234 let self_value = serde_json::to_value(&*self)
235 .with_context(|| format!("Failed to serialize settings to {}", path.display()))?;
236 let content = compute_merged_content(&path, &self_value, &self.modified_fields)?;
237 atomic_write_with_lock(&path, &content)?;
238
239 self.modified_fields.clear();
241 Ok(())
242 }
243
244 pub fn reload(&mut self, cwd: &std::path::Path) -> anyhow::Result<()> {
247 let global_path = Self::global_path()?;
248 let global = Self::load_file(&global_path)?;
249 let project = Self::load_file(&cwd.join(".rab").join("settings.json")).unwrap_or_default();
250 let merged = Self::merge(global, project);
251 self.default_provider = merged.default_provider;
253 self.default_model = merged.default_model;
254 self.default_thinking_level = merged.default_thinking_level;
255 self.tools = merged.tools;
256 self.exclude_tools = merged.exclude_tools;
257 self.theme = merged.theme;
258 self.verbose = merged.verbose;
259 self.hide_thinking = merged.hide_thinking;
260 self.collapse_tool_output = merged.collapse_tool_output;
261 self.auto_compact = merged.auto_compact;
262 self.compact_reserve_tokens = merged.compact_reserve_tokens;
263 self.compact_keep_recent_tokens = merged.compact_keep_recent_tokens;
264 self.modified_fields.clear();
265 Ok(())
266 }
267
268 pub fn model(&self) -> &str {
270 self.default_model.as_deref().unwrap_or("deepseek-v4-flash")
271 }
272}
273
274fn read_file_with_shared_lock(path: &std::path::Path) -> anyhow::Result<String> {
279 let lock_path = path.with_extension("json.lock");
280 if let Ok(lock_file) = std::fs::OpenOptions::new()
281 .create(true)
282 .truncate(false)
283 .read(true)
284 .write(true)
285 .open(&lock_path)
286 {
287 #[cfg(unix)]
288 {
289 use std::os::unix::io::AsRawFd;
290 unsafe {
291 libc::flock(lock_file.as_raw_fd(), libc::LOCK_SH);
292 }
293 }
294 let content = std::fs::read_to_string(path)
295 .with_context(|| format!("Failed to read {}", path.display()))?;
296 #[cfg(unix)]
297 {
298 use std::os::unix::io::AsRawFd;
299 unsafe {
300 libc::flock(lock_file.as_raw_fd(), libc::LOCK_UN);
301 }
302 }
303 Ok(content)
304 } else {
305 std::fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))
307 }
308}
309
310fn compute_merged_content(
313 path: &std::path::Path,
314 self_value: &serde_json::Value,
315 modified_fields: &HashSet<String>,
316) -> anyhow::Result<String> {
317 let mut current: serde_json::Value = if path.exists() {
318 let content = std::fs::read_to_string(path)
319 .with_context(|| format!("Failed to read {}", path.display()))?;
320 serde_json::from_str(&content).unwrap_or(serde_json::Value::Object(serde_json::Map::new()))
321 } else {
322 serde_json::Value::Object(serde_json::Map::new())
323 };
324
325 if let (Some(current_obj), Some(self_obj)) = (current.as_object_mut(), self_value.as_object()) {
326 for key in modified_fields {
327 if let Some(value) = self_obj.get(key) {
328 current_obj.insert(key.clone(), value.clone());
329 } else {
330 current_obj.remove(key);
331 }
332 }
333 }
334
335 serde_json::to_string_pretty(¤t)
336 .with_context(|| format!("Failed to serialize settings to {}", path.display()))
337}
338
339fn atomic_write_with_lock(path: &std::path::Path, content: &str) -> anyhow::Result<()> {
341 if let Some(parent) = path.parent() {
342 std::fs::create_dir_all(parent)?;
343 }
344
345 let lock_path = path.with_extension("json.lock");
347 let lock_file = std::fs::OpenOptions::new()
348 .create(true)
349 .truncate(false)
350 .read(true)
351 .write(true)
352 .open(&lock_path)
353 .with_context(|| format!("Failed to open lock file {}", lock_path.display()))?;
354
355 #[cfg(unix)]
356 {
357 use std::os::unix::io::AsRawFd;
358 if unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX) } != 0 {
359 let err = std::io::Error::last_os_error();
360 anyhow::bail!("Failed to lock {}: {}", lock_path.display(), err);
361 }
362 }
363
364 let tmp_path = path.with_extension("json.tmp");
366 std::fs::write(&tmp_path, content)
367 .with_context(|| format!("Failed to write {}", tmp_path.display()))?;
368 std::fs::rename(&tmp_path, path).with_context(|| {
369 format!(
370 "Failed to rename {} to {}",
371 tmp_path.display(),
372 path.display()
373 )
374 })?;
375
376 if let Some(parent) = path.parent()
378 && let Ok(f) = std::fs::File::open(parent)
379 {
380 let _ = f.sync_all();
381 }
382
383 #[cfg(unix)]
385 {
386 use std::os::unix::io::AsRawFd;
387 unsafe {
388 libc::flock(lock_file.as_raw_fd(), libc::LOCK_UN);
389 }
390 }
391
392 Ok(())
393}
394
395#[cfg(test)]
398mod tests {
399 use super::*;
400 use std::fs;
401
402 fn tmp_path(name: &str) -> PathBuf {
404 std::env::temp_dir().join(format!("rab_settings_test_{}", name))
405 }
406
407 fn cleanup(path: &PathBuf) {
409 let _ = fs::remove_file(path);
410 let _ = fs::remove_file(path.with_extension("json.lock"));
411 let _ = fs::remove_file(path.with_extension("json.tmp"));
412 }
413
414 #[test]
415 fn test_save_and_load_roundtrip() {
416 let path = tmp_path("roundtrip.json");
417 cleanup(&path);
418
419 let mut settings = Settings::default();
420 settings.set_default_thinking_level(Some("high".into()));
421 assert_eq!(settings.modified_fields.len(), 1);
422 assert!(settings.modified_fields.contains("defaultThinkingLevel"));
423 settings.save_to(path.clone()).unwrap();
424 assert!(
425 settings.modified_fields.is_empty(),
426 "modified_fields should be cleared after save"
427 );
428
429 let content = fs::read_to_string(&path).unwrap();
430 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
431 assert_eq!(json["defaultThinkingLevel"], "high");
432
433 let loaded = Settings::load_file(&path).unwrap();
434 assert_eq!(loaded.default_thinking_level.as_deref(), Some("high"));
435
436 cleanup(&path);
437 }
438
439 #[test]
440 fn test_save_multiple_fields_then_load() {
441 let path = tmp_path("multi.json");
442 cleanup(&path);
443
444 let mut settings = Settings::default();
445 settings.set_hide_thinking(Some(true));
446 settings.set_collapse_tool_output(Some(false));
447 settings.set_default_thinking_level(Some("medium".into()));
448 assert_eq!(settings.modified_fields.len(), 3);
449 settings.save_to(path.clone()).unwrap();
450
451 let loaded = Settings::load_file(&path).unwrap();
452 assert_eq!(loaded.hide_thinking, Some(true));
453 assert_eq!(loaded.collapse_tool_output, Some(false));
454 assert_eq!(loaded.default_thinking_level.as_deref(), Some("medium"));
455
456 cleanup(&path);
457 }
458
459 #[test]
460 fn test_incremental_save_preserves_existing_fields() {
461 let path = tmp_path("incremental.json");
462 cleanup(&path);
463
464 let mut s = Settings::default();
465 s.set_hide_thinking(Some(false));
466 s.save_to(path.clone()).unwrap();
467
468 let mut s2 = Settings::load_file(&path).unwrap();
469 assert_eq!(s2.hide_thinking, Some(false));
470 s2.set_default_thinking_level(Some("low".into()));
471 s2.save_to(path.clone()).unwrap();
472
473 let content = fs::read_to_string(&path).unwrap();
474 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
475 assert_eq!(json["hideThinkingBlock"], false);
476 assert_eq!(json["defaultThinkingLevel"], "low");
477
478 let loaded = Settings::load_file(&path).unwrap();
479 assert_eq!(loaded.hide_thinking, Some(false));
480 assert_eq!(loaded.default_thinking_level.as_deref(), Some("low"));
481
482 cleanup(&path);
483 }
484
485 #[test]
486 fn test_unset_field_removed_from_file() {
487 let path = tmp_path("unset.json");
488 cleanup(&path);
489
490 let mut s = Settings::default();
491 s.set_default_thinking_level(Some("high".into()));
492 s.save_to(path.clone()).unwrap();
493
494 let mut s2 = Settings::load_file(&path).unwrap();
495 s2.set_default_thinking_level(None);
496 s2.save_to(path.clone()).unwrap();
497
498 let content = fs::read_to_string(&path).unwrap();
499 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
500 assert!(
501 !json
502 .as_object()
503 .unwrap()
504 .contains_key("defaultThinkingLevel"),
505 "Field should be removed when set to None"
506 );
507
508 let loaded = Settings::load_file(&path).unwrap();
509 assert!(loaded.default_thinking_level.is_none());
510
511 cleanup(&path);
512 }
513
514 #[test]
515 fn test_hide_thinking_roundtrip() {
516 let path = tmp_path("hide.json");
517 cleanup(&path);
518
519 let mut s = Settings::default();
520 s.set_hide_thinking(Some(false));
521 s.save_to(path.clone()).unwrap();
522
523 let loaded = Settings::load_file(&path).unwrap();
524 assert_eq!(loaded.hide_thinking, Some(false));
525
526 let mut s2 = Settings::load_file(&path).unwrap();
527 s2.set_hide_thinking(Some(true));
528 s2.save_to(path.clone()).unwrap();
529
530 let loaded2 = Settings::load_file(&path).unwrap();
531 assert_eq!(loaded2.hide_thinking, Some(true));
532
533 cleanup(&path);
534 }
535
536 #[test]
537 fn test_merge_global_and_project() {
538 let mut global = Settings::default();
539 global.hide_thinking = Some(true);
540 global.default_thinking_level = Some("high".into());
541
542 let mut project = Settings::default();
543 project.hide_thinking = Some(false);
544
545 let merged = Settings::merge(global, project);
546 assert_eq!(merged.hide_thinking, Some(false));
547 assert_eq!(merged.default_thinking_level.as_deref(), Some("high"));
548 assert!(merged.modified_fields.is_empty());
549 }
550
551 #[test]
552 fn test_save_only_modified_fields() {
553 let path = tmp_path("modified_only.json");
554 cleanup(&path);
555
556 let initial = serde_json::json!({
557 "theme": "dark",
558 "defaultModel": "claude-sonnet",
559 "hideThinkingBlock": true
560 });
561 fs::write(&path, serde_json::to_string_pretty(&initial).unwrap()).unwrap();
562
563 let mut s = Settings::load_file(&path).unwrap();
564 assert_eq!(s.hide_thinking, Some(true));
565 assert_eq!(s.theme.as_deref(), Some("dark"));
566 assert_eq!(s.model(), "claude-sonnet");
567
568 s.set_default_thinking_level(Some("low".into()));
569 s.save_to(path.clone()).unwrap();
570
571 let content = fs::read_to_string(&path).unwrap();
572 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
573 assert_eq!(
574 json["hideThinkingBlock"], true,
575 "hideThinkingBlock preserved"
576 );
577 assert_eq!(
578 json["defaultThinkingLevel"], "low",
579 "defaultThinkingLevel added"
580 );
581
582 cleanup(&path);
583 }
584
585 #[test]
586 fn test_clear_modified_fields_only_after_write() {
587 let path = tmp_path("clear_modified.json");
588 cleanup(&path);
589
590 let mut s = Settings::default();
591 s.set_default_thinking_level(Some("xhigh".into()));
592 s.set_hide_thinking(Some(false));
593 s.save_to(path.clone()).unwrap();
594 assert!(s.modified_fields.is_empty());
595
596 s.set_hide_thinking(Some(true));
597 assert_eq!(s.modified_fields.len(), 1);
598 assert!(s.modified_fields.contains("hideThinkingBlock"));
599 s.save_to(path.clone()).unwrap();
600 assert!(s.modified_fields.is_empty());
601
602 cleanup(&path);
603 }
604
605 #[test]
608 fn test_lock_file_created_and_lock_released() {
609 let path = tmp_path("lock_test.json");
610 cleanup(&path);
611
612 let mut s = Settings::default();
613 s.set_default_thinking_level(Some("high".into()));
614 s.save_to(path.clone()).unwrap();
615
616 let lock_path = path.with_extension("json.lock");
617 assert!(lock_path.exists(), "Lock file should exist after write");
618
619 #[cfg(unix)]
621 {
622 use std::os::unix::io::AsRawFd;
623 let lock_file = std::fs::OpenOptions::new()
624 .read(true)
625 .write(true)
626 .open(&lock_path)
627 .unwrap();
628 let result =
629 unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
630 assert_eq!(result, 0, "Lock must be released after write");
631 unsafe {
632 libc::flock(lock_file.as_raw_fd(), libc::LOCK_UN);
633 }
634 }
635
636 cleanup(&path);
637 }
638
639 #[test]
648 fn test_full_persistence_cycle() {
649 let path = tmp_path("full_cycle.json");
650 cleanup(&path);
651
652 {
654 let mut settings = Settings::default();
655 settings.set_hide_thinking(Some(false));
656 settings.set_default_thinking_level(Some("xhigh".into()));
657 settings.save_to(path.clone()).unwrap();
658 }
659
660 {
662 let loaded = Settings::load_file(&path).unwrap();
663 assert_eq!(loaded.hide_thinking, Some(false), "hide_thinking persists");
664 assert_eq!(
665 loaded.default_thinking_level.as_deref(),
666 Some("xhigh"),
667 "thinking level persists"
668 );
669 }
670
671 {
673 let mut settings = Settings::load_file(&path).unwrap();
674 settings.set_hide_thinking(Some(true));
675 settings.set_default_thinking_level(Some("low".into()));
676 settings.save_to(path.clone()).unwrap();
677 }
678
679 {
681 let loaded = Settings::load_file(&path).unwrap();
682 assert_eq!(loaded.hide_thinking, Some(true), "hide_thinking updated");
683 assert_eq!(
684 loaded.default_thinking_level.as_deref(),
685 Some("low"),
686 "thinking level updated"
687 );
688 }
689
690 {
692 let lock_path = path.with_extension("json.lock");
693 assert!(lock_path.exists(), "Lock file should exist");
694 #[cfg(unix)]
695 {
696 use std::os::unix::io::AsRawFd;
697 let lock_file = std::fs::OpenOptions::new()
698 .read(true)
699 .write(true)
700 .open(&lock_path)
701 .unwrap();
702 let result =
703 unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
704 assert_eq!(result, 0, "Lock must be released after save");
705 unsafe {
706 libc::flock(lock_file.as_raw_fd(), libc::LOCK_UN);
707 }
708 }
709 }
710
711 cleanup(&path);
712 }
713
714 #[test]
717 fn test_concurrent_writes_to_same_file() {
718 let path = tmp_path("concurrent.json");
719 cleanup(&path);
720
721 let mut s1 = Settings::default();
723 s1.set_hide_thinking(Some(true));
724 s1.set_default_thinking_level(Some("xhigh".into()));
725
726 let mut s2 = Settings::default();
727 s2.set_hide_thinking(Some(false));
728 s2.set_default_thinking_level(Some("low".into()));
729
730 s1.save_to(path.clone()).unwrap();
732 s2.save_to(path.clone()).unwrap();
733
734 let content = fs::read_to_string(&path).unwrap();
736 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
737 assert!(json.is_object(), "File must be valid JSON, not corrupted");
738
739 assert_eq!(json["hideThinkingBlock"], false, "s2's hide_thinking");
741 assert_eq!(json["defaultThinkingLevel"], "low", "s2's thinking level");
742
743 cleanup(&path);
744 }
745
746 #[test]
748 fn test_lock_file_cleanup() {
749 let path = tmp_path("lock_cleanup.json");
750 cleanup(&path);
751
752 let mut s = Settings::default();
753 s.set_hide_thinking(Some(true));
754 s.save_to(path.clone()).unwrap();
755
756 let lock_path = path.with_extension("json.lock");
757 assert!(lock_path.exists(), "Lock file should exist");
758
759 let tmp_path = path.with_extension("json.tmp");
761 assert!(!tmp_path.exists(), "Temp file should be removed");
762
763 cleanup(&path);
764 }
765
766 #[test]
768 fn test_reload_preserves_unmodified() {
769 let path = tmp_path("reload_preserve.json");
770 cleanup(&path);
771
772 let initial = serde_json::json!({
774 "theme": "solarized",
775 "defaultModel": "deepseek-v4-pro",
776 "hideThinkingBlock": true
777 });
778 fs::write(&path, serde_json::to_string_pretty(&initial).unwrap()).unwrap();
779
780 let mut s = Settings::load_file(&path).unwrap();
782 s.set_default_thinking_level(Some("high".into()));
783 s.save_to(path.clone()).unwrap();
784
785 let content = fs::read_to_string(&path).unwrap();
787 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
788 assert_eq!(json["theme"], "solarized", "theme preserved");
789 assert_eq!(json["defaultModel"], "deepseek-v4-pro", "model preserved");
790 assert_eq!(
791 json["hideThinkingBlock"], true,
792 "hideThinkingBlock preserved"
793 );
794 assert_eq!(json["defaultThinkingLevel"], "high", "thinking level added");
795
796 cleanup(&path);
797 }
798}