Skip to main content

rab/agent/
settings.rs

1use anyhow::Context;
2use serde::{Deserialize, Serialize};
3use std::collections::HashSet;
4use std::path::PathBuf;
5
6/// Helper: skip serializing `false` for `verbose`.
7fn is_false(v: &bool) -> bool {
8    !*v
9}
10
11/// Settings schema matching pi's settings.json format.
12/// API keys live in auth.json, not here.
13#[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    /// Hide thinking blocks (Ctrl+T toggle). Persisted to settings.json.
38    #[serde(
39        default,
40        skip_serializing_if = "Option::is_none",
41        rename = "hideThinkingBlock"
42    )]
43    pub hide_thinking: Option<bool>,
44
45    /// Collapse tool output (Ctrl+O toggle). Persisted to settings.json.
46    #[serde(
47        default,
48        skip_serializing_if = "Option::is_none",
49        rename = "collapseToolOutput"
50    )]
51    pub collapse_tool_output: Option<bool>,
52
53    /// Auto-compact enabled (Ctrl+Shift+C toggle).
54    #[serde(
55        default,
56        skip_serializing_if = "Option::is_none",
57        rename = "autoCompact"
58    )]
59    pub auto_compact: Option<bool>,
60
61    /// Tokens to reserve for system prompt + tool defs + response.
62    #[serde(
63        default,
64        skip_serializing_if = "Option::is_none",
65        rename = "compactReserveTokens"
66    )]
67    pub compact_reserve_tokens: Option<u64>,
68
69    /// Number of most-recent tokens to always keep.
70    #[serde(
71        default,
72        skip_serializing_if = "Option::is_none",
73        rename = "compactKeepRecentTokens"
74    )]
75    pub compact_keep_recent_tokens: Option<u64>,
76
77    /// Tracks which fields were explicitly modified during this session.
78    /// Only modified fields are written when saving, preventing unset/default
79    /// fields and project-level overrides from leaking into the global file.
80    #[serde(skip)]
81    pub(crate) modified_fields: HashSet<String>,
82}
83
84impl Settings {
85    // ── Setters that track modification ────────────────────────────────
86
87    /// Set hide_thinking and mark it as modified.
88    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    /// Set collapse_tool_output and mark it as modified.
94    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    /// Set default_thinking_level and mark it as modified.
100    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    /// Set auto_compact and mark it as modified.
106    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    /// Set compact_reserve_tokens and mark it as modified.
112    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    /// Set compact_keep_recent_tokens and mark it as modified.
118    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    /// Mark a field as modified (for use with the setters or external callers).
125    /// The field name must match the camelCase JSON key (e.g. "hideThinkingBlock").
126    #[doc(hidden)]
127    pub fn mark_modified(&mut self, field: &str) {
128        self.modified_fields.insert(field.to_string());
129    }
130
131    // ── Loading ─────────────────────────────────────────────────────────
132
133    /// Load settings from the global agent config path and project-local path.
134    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    /// Load settings with an explicit global config path (for testing).
140    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        // Shared lock for reading — blocks if another process holds an exclusive lock.
163        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    /// Merge project settings over global. Project values take precedence when set.
169    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    // ── Saving ──────────────────────────────────────────────────────────
202
203    /// Save only the modified fields to the global config path.
204    /// Unmodified fields are never written, preventing project-level
205    /// overrides and default values from leaking into the global file.
206    ///
207    /// After a successful save, `modified_fields` is cleared so that
208    /// subsequent saves only write fields that changed since the last
209    /// write. This prevents stale modifications from being re-applied
210    /// when a different field is toggled later.
211    ///
212    /// Uses file locking (`flock` on the `.json.lock` file) to prevent
213    /// corruption when multiple rab processes access the same file.
214    /// Atomic write via temp-file + rename prevents partial writes.
215    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    /// Save only the modified fields to a specific path (for testing).
224    /// Uses file locking and atomic write (temp file + rename).
225    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        // Clear modified fields after a successful write.
240        self.modified_fields.clear();
241        Ok(())
242    }
243
244    /// Reload settings from disk (re-reads global + project).
245    /// Clears modified_fields since the freshly loaded settings are unmodified.
246    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        // Copy all fields from merged into self
252        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    /// Resolved model name (defaults to deepseek-v4-flask).
269    pub fn model(&self) -> &str {
270        self.default_model.as_deref().unwrap_or("deepseek-v4-flash")
271    }
272}
273
274// ── File I/O helpers with flock ─────────────────────────────────────
275
276/// Read a file with a shared (read) lock via flock on the `.json.lock` file.
277/// Falls back to an unlocked read if the lock file cannot be opened.
278fn 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        // Lock file cannot be opened — fall back to unlocked read.
306        std::fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))
307    }
308}
309
310/// Compute the merged JSON content, combining existing file fields with
311/// only the modified fields from `self_value`.
312fn 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(&current)
336        .with_context(|| format!("Failed to serialize settings to {}", path.display()))
337}
338
339/// Write content to a file atomically, protected by `flock` on the lock file.
340fn 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    // Open (or create) the lock file and acquire an exclusive lock.
346    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    // Atomic write: temp file + rename.
365    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    // Ensure data is on disk before releasing the lock.
377    if let Some(parent) = path.parent()
378        && let Ok(f) = std::fs::File::open(parent)
379    {
380        let _ = f.sync_all();
381    }
382
383    // Release the lock (also happens on drop of lock_file).
384    #[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// ── Tests ────────────────────────────────────────────────────────────────
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use std::fs;
401
402    /// Helper: create a temporary file path for testing.
403    fn tmp_path(name: &str) -> PathBuf {
404        std::env::temp_dir().join(format!("rab_settings_test_{}", name))
405    }
406
407    /// Clean up both the file and its lock file.
408    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    // ── File locking tests ──────────────────────────────────────────
606
607    #[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        // Lock should be released — we can acquire it non-blocking.
620        #[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    // ── Integration tests: full persistence cycles ───────────────────
640
641    /// Full startup→session→restore cycle:
642    /// 1. Save settings with hide_thinking and thinking level
643    /// 2. Load from file, verify values
644    /// 3. Modify and save again
645    /// 4. Reload and verify changes
646    /// 5. Verify lock file is cleanly released
647    #[test]
648    fn test_full_persistence_cycle() {
649        let path = tmp_path("full_cycle.json");
650        cleanup(&path);
651
652        // ── Phase 1: initial save ──
653        {
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        // ── Phase 2: reload and verify ──
661        {
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        // ── Phase 3: modify and save again ──
672        {
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        // ── Phase 4: reload and verify both changes ──
680        {
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        // ── Phase 5: verify lock file is released ──
691        {
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    /// Concurrent access test: two separate Settings instances writing
715    /// to the same file with file locking ensures no corruption.
716    #[test]
717    fn test_concurrent_writes_to_same_file() {
718        let path = tmp_path("concurrent.json");
719        cleanup(&path);
720
721        // Simulate two rab processes (or sequential rapid saves)
722        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        // Interleave saves to same path
731        s1.save_to(path.clone()).unwrap();
732        s2.save_to(path.clone()).unwrap();
733
734        // The file should be valid JSON regardless
735        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        // The last writer wins for each field
740        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    /// Verify the lock file is cleaned up after save.
747    #[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        // We can also verify the temp file is gone
760        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    /// Reload should preserve unchanged fields from disk.
767    #[test]
768    fn test_reload_preserves_unmodified() {
769        let path = tmp_path("reload_preserve.json");
770        cleanup(&path);
771
772        // Create initial file with some fields
773        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        // Load, modify only one field, save
781        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        // Verify all fields preserved
786        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}