Skip to main content

rab/agent/
settings.rs

1use anyhow::Context;
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5/// Settings schema matching pi's settings.json format.
6/// API keys live in auth.json, not here.
7#[derive(Debug, Clone, Serialize, Deserialize, Default)]
8#[serde(rename_all = "camelCase")]
9pub struct Settings {
10    #[serde(default)]
11    pub default_provider: Option<String>,
12
13    #[serde(default)]
14    pub default_model: Option<String>,
15
16    #[serde(default)]
17    pub default_thinking_level: Option<String>,
18
19    #[serde(default)]
20    pub tools: Vec<String>,
21
22    #[serde(default)]
23    pub exclude_tools: Vec<String>,
24
25    #[serde(default)]
26    pub theme: Option<String>,
27
28    #[serde(default)]
29    pub verbose: bool,
30
31    /// Hide thinking blocks (Ctrl+T toggle). Persisted to settings.json.
32    #[serde(default, rename = "hideThinkingBlock")]
33    pub hide_thinking: Option<bool>,
34
35    /// Collapse tool output (Ctrl+O toggle). Persisted to settings.json.
36    #[serde(default, rename = "collapseToolOutput")]
37    pub collapse_tool_output: Option<bool>,
38}
39
40impl Settings {
41    /// Load settings from the global agent config path and project-local path.
42    pub fn load(cwd: &std::path::Path) -> anyhow::Result<Self> {
43        let global_path = Self::global_path()?;
44        Self::load_from(global_path, cwd)
45    }
46
47    /// Load settings with an explicit global config path (for testing).
48    pub fn load_from(
49        global_path: std::path::PathBuf,
50        cwd: &std::path::Path,
51    ) -> anyhow::Result<Self> {
52        let global = Self::load_file(&global_path)?;
53        let project = Self::load_file(&cwd.join(".rab").join("settings.json")).unwrap_or_default();
54        Ok(Self::merge(global, project))
55    }
56
57    fn global_path() -> anyhow::Result<PathBuf> {
58        let dir = directories::BaseDirs::new().context("Could not determine home directory")?;
59        Ok(dir
60            .home_dir()
61            .join(".rab")
62            .join("agent")
63            .join("settings.json"))
64    }
65
66    fn load_file(path: &std::path::Path) -> anyhow::Result<Settings> {
67        if !path.exists() {
68            return Ok(Settings::default());
69        }
70        let content = std::fs::read_to_string(path)
71            .with_context(|| format!("Failed to read {}", path.display()))?;
72        serde_json::from_str(&content)
73            .with_context(|| format!("Failed to parse {}", path.display()))
74    }
75
76    /// Merge project settings over global. Project values take precedence when set.
77    fn merge(global: Settings, project: Settings) -> Self {
78        Self {
79            default_provider: project.default_provider.or(global.default_provider),
80            default_model: project.default_model.or(global.default_model),
81            default_thinking_level: project
82                .default_thinking_level
83                .or(global.default_thinking_level),
84            tools: if project.tools.is_empty() {
85                global.tools
86            } else {
87                project.tools
88            },
89            exclude_tools: if project.exclude_tools.is_empty() {
90                global.exclude_tools
91            } else {
92                project.exclude_tools
93            },
94            theme: project.theme.or(global.theme),
95            verbose: project.verbose || global.verbose,
96            hide_thinking: project.hide_thinking.or(global.hide_thinking),
97            collapse_tool_output: project.collapse_tool_output.or(global.collapse_tool_output),
98        }
99    }
100
101    /// Save settings to the global config path.
102    pub fn save(&self) -> anyhow::Result<()> {
103        let path = Self::global_path()?;
104        self.save_to(path)
105    }
106
107    /// Save settings to a specific path (for testing).
108    pub fn save_to(&self, path: std::path::PathBuf) -> anyhow::Result<()> {
109        if let Some(parent) = path.parent() {
110            std::fs::create_dir_all(parent)?;
111        }
112        let content = serde_json::to_string_pretty(self)
113            .with_context(|| format!("Failed to serialize settings to {}", path.display()))?;
114        std::fs::write(&path, &content)
115            .with_context(|| format!("Failed to write {}", path.display()))?;
116        Ok(())
117    }
118
119    /// Resolved model name (defaults to deepseek-v4-flask).
120    pub fn model(&self) -> &str {
121        self.default_model.as_deref().unwrap_or("deepseek-v4-flash")
122    }
123}
124
125/// Save a single field to the global settings file without overwriting other fields.
126/// Reads the existing JSON, updates only `key` with `value`, and writes back.
127pub fn save_field(key: &str, value: impl serde::Serialize) -> anyhow::Result<()> {
128    let path = Settings::global_path()?;
129    if let Some(parent) = path.parent() {
130        std::fs::create_dir_all(parent)?;
131    }
132
133    let mut data: serde_json::Value = if path.exists() {
134        let content = std::fs::read_to_string(&path)
135            .with_context(|| format!("Failed to read {}", path.display()))?;
136        serde_json::from_str(&content)
137            .with_context(|| format!("Failed to parse {}", path.display()))?
138    } else {
139        serde_json::Value::Object(serde_json::Map::new())
140    };
141
142    if let serde_json::Value::Object(ref mut map) = data {
143        let json_val = serde_json::to_value(value)
144            .with_context(|| format!("Failed to serialize field {}", key))?;
145        map.insert(key.to_string(), json_val);
146    }
147
148    let content = serde_json::to_string_pretty(&data)
149        .with_context(|| format!("Failed to serialize {}", path.display()))?;
150    std::fs::write(&path, &content)
151        .with_context(|| format!("Failed to write {}", path.display()))?;
152    Ok(())
153}