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    /// Model patterns for cycling (same format as --models CLI flag).
54    #[serde(
55        default,
56        skip_serializing_if = "Option::is_none",
57        rename = "enabledModels"
58    )]
59    pub enabled_models: Option<Vec<String>>,
60
61    /// Auto-compact enabled (Ctrl+Shift+C toggle).
62    #[serde(
63        default,
64        skip_serializing_if = "Option::is_none",
65        rename = "autoCompact"
66    )]
67    pub auto_compact: Option<bool>,
68
69    /// Tokens to reserve for system prompt + tool defs + response.
70    #[serde(
71        default,
72        skip_serializing_if = "Option::is_none",
73        rename = "compactReserveTokens"
74    )]
75    pub compact_reserve_tokens: Option<u64>,
76
77    /// Number of most-recent tokens to always keep.
78    #[serde(
79        default,
80        skip_serializing_if = "Option::is_none",
81        rename = "compactKeepRecentTokens"
82    )]
83    pub compact_keep_recent_tokens: Option<u64>,
84
85    /// Tracks which fields were explicitly modified during this session.
86    /// Only modified fields are written when saving, preventing unset/default
87    /// fields and project-level overrides from leaking into the global file.
88    #[serde(skip)]
89    pub(crate) modified_fields: HashSet<String>,
90}
91
92impl Settings {
93    // ── Setters that track modification ────────────────────────────────
94
95    /// Set hide_thinking and mark it as modified.
96    pub fn set_hide_thinking(&mut self, value: Option<bool>) {
97        self.hide_thinking = value;
98        self.modified_fields.insert("hideThinkingBlock".into());
99    }
100
101    /// Set collapse_tool_output and mark it as modified.
102    pub fn set_collapse_tool_output(&mut self, value: Option<bool>) {
103        self.collapse_tool_output = value;
104        self.modified_fields.insert("collapseToolOutput".into());
105    }
106
107    /// Set default_thinking_level and mark it as modified.
108    pub fn set_default_thinking_level(&mut self, value: Option<String>) {
109        self.default_thinking_level = value;
110        self.modified_fields.insert("defaultThinkingLevel".into());
111    }
112
113    /// Set enabled_models and mark it as modified.
114    pub fn set_enabled_models(&mut self, value: Option<Vec<String>>) {
115        self.enabled_models = value;
116        self.modified_fields.insert("enabledModels".into());
117    }
118
119    /// Set auto_compact and mark it as modified.
120    pub fn set_auto_compact(&mut self, value: Option<bool>) {
121        self.auto_compact = value;
122        self.modified_fields.insert("autoCompact".into());
123    }
124
125    /// Set compact_reserve_tokens and mark it as modified.
126    pub fn set_compact_reserve_tokens(&mut self, value: Option<u64>) {
127        self.compact_reserve_tokens = value;
128        self.modified_fields.insert("compactReserveTokens".into());
129    }
130
131    /// Set compact_keep_recent_tokens and mark it as modified.
132    pub fn set_compact_keep_recent_tokens(&mut self, value: Option<u64>) {
133        self.compact_keep_recent_tokens = value;
134        self.modified_fields
135            .insert("compactKeepRecentTokens".into());
136    }
137
138    /// Mark a field as modified (for use with the setters or external callers).
139    /// The field name must match the camelCase JSON key (e.g. "hideThinkingBlock").
140    #[doc(hidden)]
141    pub fn mark_modified(&mut self, field: &str) {
142        self.modified_fields.insert(field.to_string());
143    }
144
145    // ── Loading ─────────────────────────────────────────────────────────
146
147    /// Load settings from the global agent config path and project-local path.
148    pub fn load(cwd: &std::path::Path) -> anyhow::Result<Self> {
149        let global_path = Self::global_path()?;
150        Self::load_from(global_path, cwd)
151    }
152
153    /// Load settings with an explicit global config path (for testing).
154    pub fn load_from(
155        global_path: std::path::PathBuf,
156        cwd: &std::path::Path,
157    ) -> anyhow::Result<Self> {
158        let global = Self::load_file(&global_path)?;
159        let project = Self::load_file(&cwd.join(".rab").join("settings.json")).unwrap_or_default();
160        Ok(Self::merge(global, project))
161    }
162
163    fn global_path() -> anyhow::Result<PathBuf> {
164        let dir = directories::BaseDirs::new().context("Could not determine home directory")?;
165        Ok(dir
166            .home_dir()
167            .join(".rab")
168            .join("agent")
169            .join("settings.json"))
170    }
171
172    fn load_file(path: &std::path::Path) -> anyhow::Result<Settings> {
173        if !path.exists() {
174            return Ok(Settings::default());
175        }
176        // Shared lock for reading — blocks if another process holds an exclusive lock.
177        let content = read_file_with_shared_lock(path)?;
178        serde_json::from_str(&content)
179            .with_context(|| format!("Failed to parse {}", path.display()))
180    }
181
182    /// Merge project settings over global. Project values take precedence when set.
183    fn merge(global: Settings, project: Settings) -> Self {
184        Self {
185            default_provider: project.default_provider.or(global.default_provider),
186            default_model: project.default_model.or(global.default_model),
187            default_thinking_level: project
188                .default_thinking_level
189                .or(global.default_thinking_level),
190            tools: if project.tools.is_empty() {
191                global.tools
192            } else {
193                project.tools
194            },
195            exclude_tools: if project.exclude_tools.is_empty() {
196                global.exclude_tools
197            } else {
198                project.exclude_tools
199            },
200            theme: project.theme.or(global.theme),
201            verbose: project.verbose || global.verbose,
202            hide_thinking: project.hide_thinking.or(global.hide_thinking),
203            collapse_tool_output: project.collapse_tool_output.or(global.collapse_tool_output),
204            enabled_models: project.enabled_models.or(global.enabled_models),
205            auto_compact: project.auto_compact.or(global.auto_compact),
206            compact_reserve_tokens: project
207                .compact_reserve_tokens
208                .or(global.compact_reserve_tokens),
209            compact_keep_recent_tokens: project
210                .compact_keep_recent_tokens
211                .or(global.compact_keep_recent_tokens),
212            modified_fields: HashSet::new(),
213        }
214    }
215
216    // ── Saving ──────────────────────────────────────────────────────────
217
218    /// Save only the modified fields to the global config path.
219    /// Unmodified fields are never written, preventing project-level
220    /// overrides and default values from leaking into the global file.
221    ///
222    /// After a successful save, `modified_fields` is cleared so that
223    /// subsequent saves only write fields that changed since the last
224    /// write. This prevents stale modifications from being re-applied
225    /// when a different field is toggled later.
226    ///
227    /// Uses file locking (`flock` on the `.json.lock` file) to prevent
228    /// corruption when multiple rab processes access the same file.
229    /// Atomic write via temp-file + rename prevents partial writes.
230    pub fn save(&mut self) -> anyhow::Result<()> {
231        if self.modified_fields.is_empty() {
232            return Ok(());
233        }
234        let path = Self::global_path()?;
235        self.save_to(path)
236    }
237
238    /// Save only the modified fields to a specific path (for testing).
239    /// Uses file locking and atomic write (temp file + rename).
240    pub fn save_to(&mut self, path: std::path::PathBuf) -> anyhow::Result<()> {
241        if self.modified_fields.is_empty() {
242            return Ok(());
243        }
244
245        if let Some(parent) = path.parent() {
246            std::fs::create_dir_all(parent)?;
247        }
248
249        let self_value = serde_json::to_value(&*self)
250            .with_context(|| format!("Failed to serialize settings to {}", path.display()))?;
251        let content = compute_merged_content(&path, &self_value, &self.modified_fields)?;
252        atomic_write_with_lock(&path, &content)?;
253
254        // Clear modified fields after a successful write.
255        self.modified_fields.clear();
256        Ok(())
257    }
258
259    /// Reload settings from disk (re-reads global + project).
260    /// Clears modified_fields since the freshly loaded settings are unmodified.
261    pub fn reload(&mut self, cwd: &std::path::Path) -> anyhow::Result<()> {
262        let global_path = Self::global_path()?;
263        let global = Self::load_file(&global_path)?;
264        let project = Self::load_file(&cwd.join(".rab").join("settings.json")).unwrap_or_default();
265        let merged = Self::merge(global, project);
266        // Copy all fields from merged into self
267        self.default_provider = merged.default_provider;
268        self.default_model = merged.default_model;
269        self.default_thinking_level = merged.default_thinking_level;
270        self.tools = merged.tools;
271        self.exclude_tools = merged.exclude_tools;
272        self.theme = merged.theme;
273        self.verbose = merged.verbose;
274        self.hide_thinking = merged.hide_thinking;
275        self.collapse_tool_output = merged.collapse_tool_output;
276        self.enabled_models = merged.enabled_models;
277        self.auto_compact = merged.auto_compact;
278        self.compact_reserve_tokens = merged.compact_reserve_tokens;
279        self.compact_keep_recent_tokens = merged.compact_keep_recent_tokens;
280        self.modified_fields.clear();
281        Ok(())
282    }
283
284    /// Resolved model name (defaults to deepseek-v4-flash).
285    pub fn model(&self) -> &str {
286        self.default_model.as_deref().unwrap_or("deepseek-v4-flash")
287    }
288}
289
290// ── File I/O helpers with flock ─────────────────────────────────────
291
292/// Read a file with a shared (read) lock via flock on the `.json.lock` file.
293/// Falls back to an unlocked read if the lock file cannot be opened.
294fn read_file_with_shared_lock(path: &std::path::Path) -> anyhow::Result<String> {
295    let lock_path = path.with_extension("json.lock");
296    if let Ok(lock_file) = std::fs::OpenOptions::new()
297        .create(true)
298        .truncate(false)
299        .read(true)
300        .write(true)
301        .open(&lock_path)
302    {
303        #[cfg(unix)]
304        {
305            use std::os::unix::io::AsRawFd;
306            unsafe {
307                libc::flock(lock_file.as_raw_fd(), libc::LOCK_SH);
308            }
309        }
310        let content = std::fs::read_to_string(path)
311            .with_context(|| format!("Failed to read {}", path.display()))?;
312        #[cfg(unix)]
313        {
314            use std::os::unix::io::AsRawFd;
315            unsafe {
316                libc::flock(lock_file.as_raw_fd(), libc::LOCK_UN);
317            }
318        }
319        Ok(content)
320    } else {
321        // Lock file cannot be opened — fall back to unlocked read.
322        std::fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))
323    }
324}
325
326/// Compute the merged JSON content, combining existing file fields with
327/// only the modified fields from `self_value`.
328fn compute_merged_content(
329    path: &std::path::Path,
330    self_value: &serde_json::Value,
331    modified_fields: &HashSet<String>,
332) -> anyhow::Result<String> {
333    let mut current: serde_json::Value = if path.exists() {
334        let content = std::fs::read_to_string(path)
335            .with_context(|| format!("Failed to read {}", path.display()))?;
336        serde_json::from_str(&content).unwrap_or(serde_json::Value::Object(serde_json::Map::new()))
337    } else {
338        serde_json::Value::Object(serde_json::Map::new())
339    };
340
341    if let (Some(current_obj), Some(self_obj)) = (current.as_object_mut(), self_value.as_object()) {
342        for key in modified_fields {
343            if let Some(value) = self_obj.get(key) {
344                current_obj.insert(key.clone(), value.clone());
345            } else {
346                current_obj.remove(key);
347            }
348        }
349    }
350
351    serde_json::to_string_pretty(&current)
352        .with_context(|| format!("Failed to serialize settings to {}", path.display()))
353}
354
355/// Write content to a file atomically, protected by `flock` on the lock file.
356fn atomic_write_with_lock(path: &std::path::Path, content: &str) -> anyhow::Result<()> {
357    if let Some(parent) = path.parent() {
358        std::fs::create_dir_all(parent)?;
359    }
360
361    // Open (or create) the lock file and acquire an exclusive lock.
362    let lock_path = path.with_extension("json.lock");
363    let lock_file = std::fs::OpenOptions::new()
364        .create(true)
365        .truncate(false)
366        .read(true)
367        .write(true)
368        .open(&lock_path)
369        .with_context(|| format!("Failed to open lock file {}", lock_path.display()))?;
370
371    #[cfg(unix)]
372    {
373        use std::os::unix::io::AsRawFd;
374        if unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX) } != 0 {
375            let err = std::io::Error::last_os_error();
376            anyhow::bail!("Failed to lock {}: {}", lock_path.display(), err);
377        }
378    }
379
380    // Atomic write: temp file + rename.
381    let tmp_path = path.with_extension("json.tmp");
382    std::fs::write(&tmp_path, content)
383        .with_context(|| format!("Failed to write {}", tmp_path.display()))?;
384    std::fs::rename(&tmp_path, path).with_context(|| {
385        format!(
386            "Failed to rename {} to {}",
387            tmp_path.display(),
388            path.display()
389        )
390    })?;
391
392    // Ensure data is on disk before releasing the lock.
393    if let Some(parent) = path.parent()
394        && let Ok(f) = std::fs::File::open(parent)
395    {
396        let _ = f.sync_all();
397    }
398
399    // Release the lock (also happens on drop of lock_file).
400    #[cfg(unix)]
401    {
402        use std::os::unix::io::AsRawFd;
403        unsafe {
404            libc::flock(lock_file.as_raw_fd(), libc::LOCK_UN);
405        }
406    }
407
408    Ok(())
409}
410
411// ── Tests ────────────────────────────────────────────────────────────────
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use std::fs;
417
418    /// Helper: create a temporary file path for testing.
419    fn tmp_path(name: &str) -> PathBuf {
420        std::env::temp_dir().join(format!("rab_settings_test_{}", name))
421    }
422
423    /// Clean up both the file and its lock file.
424    fn cleanup(path: &PathBuf) {
425        let _ = fs::remove_file(path);
426        let _ = fs::remove_file(path.with_extension("json.lock"));
427        let _ = fs::remove_file(path.with_extension("json.tmp"));
428    }
429
430    #[test]
431    fn test_save_and_load_roundtrip() {
432        let path = tmp_path("roundtrip.json");
433        cleanup(&path);
434
435        let mut settings = Settings::default();
436        settings.set_default_thinking_level(Some("high".into()));
437        assert_eq!(settings.modified_fields.len(), 1);
438        assert!(settings.modified_fields.contains("defaultThinkingLevel"));
439        settings.save_to(path.clone()).unwrap();
440        assert!(
441            settings.modified_fields.is_empty(),
442            "modified_fields should be cleared after save"
443        );
444
445        let content = fs::read_to_string(&path).unwrap();
446        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
447        assert_eq!(json["defaultThinkingLevel"], "high");
448
449        let loaded = Settings::load_file(&path).unwrap();
450        assert_eq!(loaded.default_thinking_level.as_deref(), Some("high"));
451
452        cleanup(&path);
453    }
454
455    #[test]
456    fn test_save_multiple_fields_then_load() {
457        let path = tmp_path("multi.json");
458        cleanup(&path);
459
460        let mut settings = Settings::default();
461        settings.set_hide_thinking(Some(true));
462        settings.set_collapse_tool_output(Some(false));
463        settings.set_default_thinking_level(Some("medium".into()));
464        assert_eq!(settings.modified_fields.len(), 3);
465        settings.save_to(path.clone()).unwrap();
466
467        let loaded = Settings::load_file(&path).unwrap();
468        assert_eq!(loaded.hide_thinking, Some(true));
469        assert_eq!(loaded.collapse_tool_output, Some(false));
470        assert_eq!(loaded.default_thinking_level.as_deref(), Some("medium"));
471
472        cleanup(&path);
473    }
474
475    #[test]
476    fn test_incremental_save_preserves_existing_fields() {
477        let path = tmp_path("incremental.json");
478        cleanup(&path);
479
480        let mut s = Settings::default();
481        s.set_hide_thinking(Some(false));
482        s.save_to(path.clone()).unwrap();
483
484        let mut s2 = Settings::load_file(&path).unwrap();
485        assert_eq!(s2.hide_thinking, Some(false));
486        s2.set_default_thinking_level(Some("low".into()));
487        s2.save_to(path.clone()).unwrap();
488
489        let content = fs::read_to_string(&path).unwrap();
490        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
491        assert_eq!(json["hideThinkingBlock"], false);
492        assert_eq!(json["defaultThinkingLevel"], "low");
493
494        let loaded = Settings::load_file(&path).unwrap();
495        assert_eq!(loaded.hide_thinking, Some(false));
496        assert_eq!(loaded.default_thinking_level.as_deref(), Some("low"));
497
498        cleanup(&path);
499    }
500
501    #[test]
502    fn test_unset_field_removed_from_file() {
503        let path = tmp_path("unset.json");
504        cleanup(&path);
505
506        let mut s = Settings::default();
507        s.set_default_thinking_level(Some("high".into()));
508        s.save_to(path.clone()).unwrap();
509
510        let mut s2 = Settings::load_file(&path).unwrap();
511        s2.set_default_thinking_level(None);
512        s2.save_to(path.clone()).unwrap();
513
514        let content = fs::read_to_string(&path).unwrap();
515        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
516        assert!(
517            !json
518                .as_object()
519                .unwrap()
520                .contains_key("defaultThinkingLevel"),
521            "Field should be removed when set to None"
522        );
523
524        let loaded = Settings::load_file(&path).unwrap();
525        assert!(loaded.default_thinking_level.is_none());
526
527        cleanup(&path);
528    }
529
530    #[test]
531    fn test_hide_thinking_roundtrip() {
532        let path = tmp_path("hide.json");
533        cleanup(&path);
534
535        let mut s = Settings::default();
536        s.set_hide_thinking(Some(false));
537        s.save_to(path.clone()).unwrap();
538
539        let loaded = Settings::load_file(&path).unwrap();
540        assert_eq!(loaded.hide_thinking, Some(false));
541
542        let mut s2 = Settings::load_file(&path).unwrap();
543        s2.set_hide_thinking(Some(true));
544        s2.save_to(path.clone()).unwrap();
545
546        let loaded2 = Settings::load_file(&path).unwrap();
547        assert_eq!(loaded2.hide_thinking, Some(true));
548
549        cleanup(&path);
550    }
551
552    #[test]
553    fn test_merge_global_and_project() {
554        let mut global = Settings::default();
555        global.hide_thinking = Some(true);
556        global.default_thinking_level = Some("high".into());
557
558        let mut project = Settings::default();
559        project.hide_thinking = Some(false);
560
561        let merged = Settings::merge(global, project);
562        assert_eq!(merged.hide_thinking, Some(false));
563        assert_eq!(merged.default_thinking_level.as_deref(), Some("high"));
564        assert!(merged.modified_fields.is_empty());
565    }
566
567    #[test]
568    fn test_save_only_modified_fields() {
569        let path = tmp_path("modified_only.json");
570        cleanup(&path);
571
572        let initial = serde_json::json!({
573            "theme": "dark",
574            "defaultModel": "claude-sonnet",
575            "hideThinkingBlock": true
576        });
577        fs::write(&path, serde_json::to_string_pretty(&initial).unwrap()).unwrap();
578
579        let mut s = Settings::load_file(&path).unwrap();
580        assert_eq!(s.hide_thinking, Some(true));
581        assert_eq!(s.theme.as_deref(), Some("dark"));
582        assert_eq!(s.model(), "claude-sonnet");
583
584        s.set_default_thinking_level(Some("low".into()));
585        s.save_to(path.clone()).unwrap();
586
587        let content = fs::read_to_string(&path).unwrap();
588        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
589        assert_eq!(
590            json["hideThinkingBlock"], true,
591            "hideThinkingBlock preserved"
592        );
593        assert_eq!(
594            json["defaultThinkingLevel"], "low",
595            "defaultThinkingLevel added"
596        );
597
598        cleanup(&path);
599    }
600
601    #[test]
602    fn test_clear_modified_fields_only_after_write() {
603        let path = tmp_path("clear_modified.json");
604        cleanup(&path);
605
606        let mut s = Settings::default();
607        s.set_default_thinking_level(Some("xhigh".into()));
608        s.set_hide_thinking(Some(false));
609        s.save_to(path.clone()).unwrap();
610        assert!(s.modified_fields.is_empty());
611
612        s.set_hide_thinking(Some(true));
613        assert_eq!(s.modified_fields.len(), 1);
614        assert!(s.modified_fields.contains("hideThinkingBlock"));
615        s.save_to(path.clone()).unwrap();
616        assert!(s.modified_fields.is_empty());
617
618        cleanup(&path);
619    }
620
621    // ── File locking tests ──────────────────────────────────────────
622
623    #[test]
624    fn test_lock_file_created_and_lock_released() {
625        let path = tmp_path("lock_test.json");
626        cleanup(&path);
627
628        let mut s = Settings::default();
629        s.set_default_thinking_level(Some("high".into()));
630        s.save_to(path.clone()).unwrap();
631
632        let lock_path = path.with_extension("json.lock");
633        assert!(lock_path.exists(), "Lock file should exist after write");
634
635        // Lock should be released — we can acquire it non-blocking.
636        #[cfg(unix)]
637        {
638            use std::os::unix::io::AsRawFd;
639            let lock_file = std::fs::OpenOptions::new()
640                .read(true)
641                .write(true)
642                .open(&lock_path)
643                .unwrap();
644            let result =
645                unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
646            assert_eq!(result, 0, "Lock must be released after write");
647            unsafe {
648                libc::flock(lock_file.as_raw_fd(), libc::LOCK_UN);
649            }
650        }
651
652        cleanup(&path);
653    }
654
655    // ── Integration tests: full persistence cycles ───────────────────
656
657    /// Full startup→session→restore cycle:
658    /// 1. Save settings with hide_thinking and thinking level
659    /// 2. Load from file, verify values
660    /// 3. Modify and save again
661    /// 4. Reload and verify changes
662    /// 5. Verify lock file is cleanly released
663    #[test]
664    fn test_full_persistence_cycle() {
665        let path = tmp_path("full_cycle.json");
666        cleanup(&path);
667
668        // ── Phase 1: initial save ──
669        {
670            let mut settings = Settings::default();
671            settings.set_hide_thinking(Some(false));
672            settings.set_default_thinking_level(Some("xhigh".into()));
673            settings.save_to(path.clone()).unwrap();
674        }
675
676        // ── Phase 2: reload and verify ──
677        {
678            let loaded = Settings::load_file(&path).unwrap();
679            assert_eq!(loaded.hide_thinking, Some(false), "hide_thinking persists");
680            assert_eq!(
681                loaded.default_thinking_level.as_deref(),
682                Some("xhigh"),
683                "thinking level persists"
684            );
685        }
686
687        // ── Phase 3: modify and save again ──
688        {
689            let mut settings = Settings::load_file(&path).unwrap();
690            settings.set_hide_thinking(Some(true));
691            settings.set_default_thinking_level(Some("low".into()));
692            settings.save_to(path.clone()).unwrap();
693        }
694
695        // ── Phase 4: reload and verify both changes ──
696        {
697            let loaded = Settings::load_file(&path).unwrap();
698            assert_eq!(loaded.hide_thinking, Some(true), "hide_thinking updated");
699            assert_eq!(
700                loaded.default_thinking_level.as_deref(),
701                Some("low"),
702                "thinking level updated"
703            );
704        }
705
706        // ── Phase 5: verify lock file is released ──
707        {
708            let lock_path = path.with_extension("json.lock");
709            assert!(lock_path.exists(), "Lock file should exist");
710            #[cfg(unix)]
711            {
712                use std::os::unix::io::AsRawFd;
713                let lock_file = std::fs::OpenOptions::new()
714                    .read(true)
715                    .write(true)
716                    .open(&lock_path)
717                    .unwrap();
718                let result =
719                    unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
720                assert_eq!(result, 0, "Lock must be released after save");
721                unsafe {
722                    libc::flock(lock_file.as_raw_fd(), libc::LOCK_UN);
723                }
724            }
725        }
726
727        cleanup(&path);
728    }
729
730    /// Concurrent access test: two separate Settings instances writing
731    /// to the same file with file locking ensures no corruption.
732    #[test]
733    fn test_concurrent_writes_to_same_file() {
734        let path = tmp_path("concurrent.json");
735        cleanup(&path);
736
737        // Simulate two rab processes (or sequential rapid saves)
738        let mut s1 = Settings::default();
739        s1.set_hide_thinking(Some(true));
740        s1.set_default_thinking_level(Some("xhigh".into()));
741
742        let mut s2 = Settings::default();
743        s2.set_hide_thinking(Some(false));
744        s2.set_default_thinking_level(Some("low".into()));
745
746        // Interleave saves to same path
747        s1.save_to(path.clone()).unwrap();
748        s2.save_to(path.clone()).unwrap();
749
750        // The file should be valid JSON regardless
751        let content = fs::read_to_string(&path).unwrap();
752        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
753        assert!(json.is_object(), "File must be valid JSON, not corrupted");
754
755        // The last writer wins for each field
756        assert_eq!(json["hideThinkingBlock"], false, "s2's hide_thinking");
757        assert_eq!(json["defaultThinkingLevel"], "low", "s2's thinking level");
758
759        cleanup(&path);
760    }
761
762    /// Verify the lock file is cleaned up after save.
763    #[test]
764    fn test_lock_file_cleanup() {
765        let path = tmp_path("lock_cleanup.json");
766        cleanup(&path);
767
768        let mut s = Settings::default();
769        s.set_hide_thinking(Some(true));
770        s.save_to(path.clone()).unwrap();
771
772        let lock_path = path.with_extension("json.lock");
773        assert!(lock_path.exists(), "Lock file should exist");
774
775        // We can also verify the temp file is gone
776        let tmp_path = path.with_extension("json.tmp");
777        assert!(!tmp_path.exists(), "Temp file should be removed");
778
779        cleanup(&path);
780    }
781
782    /// Reload should preserve unchanged fields from disk.
783    #[test]
784    fn test_reload_preserves_unmodified() {
785        let path = tmp_path("reload_preserve.json");
786        cleanup(&path);
787
788        // Create initial file with some fields
789        let initial = serde_json::json!({
790            "theme": "solarized",
791            "defaultModel": "deepseek-v4-pro",
792            "hideThinkingBlock": true
793        });
794        fs::write(&path, serde_json::to_string_pretty(&initial).unwrap()).unwrap();
795
796        // Load, modify only one field, save
797        let mut s = Settings::load_file(&path).unwrap();
798        s.set_default_thinking_level(Some("high".into()));
799        s.save_to(path.clone()).unwrap();
800
801        // Verify all fields preserved
802        let content = fs::read_to_string(&path).unwrap();
803        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
804        assert_eq!(json["theme"], "solarized", "theme preserved");
805        assert_eq!(json["defaultModel"], "deepseek-v4-pro", "model preserved");
806        assert_eq!(
807            json["hideThinkingBlock"], true,
808            "hideThinkingBlock preserved"
809        );
810        assert_eq!(json["defaultThinkingLevel"], "high", "thinking level added");
811
812        cleanup(&path);
813    }
814}