1use crate::config::{self, strip_line_comments, ConfigError};
12
13#[derive(serde::Deserialize, Default, Clone, Debug)]
15#[serde(rename_all = "camelCase")]
16pub struct Settings {
17 #[serde(default)]
20 pub default_provider: Option<String>,
21 #[serde(default)]
24 pub default_model: Option<String>,
25 #[serde(default)]
28 pub default_thinking_level: Option<String>,
29 #[serde(default)]
31 pub theme: Option<String>,
32 #[serde(default)]
35 pub scoped_models: Option<Vec<String>>,
36}
37
38pub fn load_settings() -> Result<Settings, ConfigError> {
42 let path = config::settings_path()?;
43 match std::fs::read_to_string(&path) {
44 Ok(text) => parse_settings(&text).map_err(|e| ConfigError::Json {
45 path: path.clone(),
46 source: e,
47 }),
48 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Settings::default()),
49 Err(e) => Err(ConfigError::Read { path, source: e }),
50 }
51}
52
53fn parse_settings(text: &str) -> Result<Settings, serde_json::Error> {
54 match serde_json::from_str(text) {
55 Ok(s) => Ok(s),
56 Err(first) => {
57 let stripped = strip_line_comments(text);
58 serde_json::from_str(&stripped).map_err(|_| first)
59 }
60 }
61}
62
63pub fn save_settings(settings: &Settings) -> Result<(), String> {
70 let path = config::settings_path().map_err(|e| e.to_string())?;
71 let mut merged = match std::fs::read_to_string(&path) {
73 Ok(text) => serde_json::from_str::<serde_json::Value>(&text)
74 .unwrap_or(serde_json::Value::Object(Default::default())),
75 Err(_) => serde_json::Value::Object(Default::default()),
76 };
77 let obj = merged
78 .as_object_mut()
79 .ok_or("settings file is not an object")?;
80 for (key, val) in [
81 ("defaultProvider", settings.default_provider.as_ref()),
82 ("defaultModel", settings.default_model.as_ref()),
83 (
84 "defaultThinkingLevel",
85 settings.default_thinking_level.as_ref(),
86 ),
87 ("theme", settings.theme.as_ref()),
88 ] {
89 match val {
90 Some(v) => {
91 obj.insert(key.to_string(), serde_json::Value::String(v.clone()));
92 }
93 None => {
94 obj.remove(key);
95 }
96 }
97 }
98 match &settings.scoped_models {
99 Some(list) if !list.is_empty() => {
100 obj.insert(
101 "scopedModels".to_string(),
102 serde_json::Value::Array(
103 list.iter()
104 .map(|m| serde_json::Value::String(m.clone()))
105 .collect(),
106 ),
107 );
108 }
109 _ => {
110 obj.remove("scopedModels");
111 }
112 }
113 if let Some(parent) = path.parent() {
114 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
115 }
116 let text = serde_json::to_string_pretty(&merged).map_err(|e| e.to_string())?;
117 std::fs::write(&path, text).map_err(|e| e.to_string())
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123 use crate::config::test_support::env_lock;
124
125 struct TempConfig {
127 _guard: std::sync::MutexGuard<'static, ()>,
128 _tmp: tempfile::TempDir,
129 prev: Option<std::ffi::OsString>,
130 }
131 impl TempConfig {
132 fn new() -> Self {
133 let guard = env_lock().lock().unwrap();
134 let prev = std::env::var_os(config::CONFIG_DIR_ENV);
135 let tmp = tempfile::TempDir::new().unwrap();
136 std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
137 Self {
138 _guard: guard,
139 _tmp: tmp,
140 prev,
141 }
142 }
143 }
144 impl Drop for TempConfig {
145 fn drop(&mut self) {
146 match self.prev.take() {
147 Some(v) => std::env::set_var(config::CONFIG_DIR_ENV, v),
148 None => std::env::remove_var(config::CONFIG_DIR_ENV),
149 }
150 }
151 }
152
153 #[test]
154 fn missing_settings_is_default() {
155 let _cfg = TempConfig::new();
156 let s = load_settings().unwrap();
157 assert!(s.default_provider.is_none());
158 assert!(s.default_model.is_none());
159 assert!(s.default_thinking_level.is_none());
160 assert!(s.theme.is_none());
161 }
162
163 #[test]
164 fn reads_honored_fields_and_ignores_unknown() {
165 let _cfg = TempConfig::new();
166 let path = config::settings_path().unwrap();
167 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
168 std::fs::write(
170 &path,
171 r#"{
172 "lastChangelogVersion": "1.0.0",
173 "defaultProvider": "anthropic",
174 "defaultModel": "claude-sonnet-5",
175 "defaultThinkingLevel": "high",
176 "theme": "dark",
177 "hideThinkingBlock": true,
178 "compaction": { "threshold": 100 },
179 "packages": ["some-pkg"]
180 }"#,
181 )
182 .unwrap();
183 let s = load_settings().unwrap();
184 assert_eq!(s.default_provider.as_deref(), Some("anthropic"));
185 assert_eq!(s.default_model.as_deref(), Some("claude-sonnet-5"));
186 assert_eq!(s.default_thinking_level.as_deref(), Some("high"));
187 assert_eq!(s.theme.as_deref(), Some("dark"));
188 }
189
190 #[test]
191 fn tolerates_line_comments() {
192 let _cfg = TempConfig::new();
193 let path = config::settings_path().unwrap();
194 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
195 std::fs::write(
196 &path,
197 "{\n // my default\n \"defaultModel\": \"glm-5\",\n \"theme\": \"light\"\n}\n",
198 )
199 .unwrap();
200 let s = load_settings().unwrap();
201 assert_eq!(s.default_model.as_deref(), Some("glm-5"));
202 assert_eq!(s.theme.as_deref(), Some("light"));
203 }
204
205 #[test]
206 fn malformed_is_error() {
207 let _cfg = TempConfig::new();
208 let path = config::settings_path().unwrap();
209 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
210 std::fs::write(&path, "{ not json").unwrap();
211 assert!(matches!(load_settings(), Err(ConfigError::Json { .. })));
212 }
213}
214
215#[cfg(test)]
216mod scoped_tests {
217 use super::*;
218 use crate::config::test_support::env_lock;
219
220 fn with_temp_env() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
221 let guard = env_lock().lock().unwrap();
222 let tmp = tempfile::TempDir::new().unwrap();
223 std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
224 (tmp, guard)
225 }
226
227 #[test]
228 fn save_load_scoped_models_roundtrip() {
229 let (_tmp, _guard) = with_temp_env();
230 let mut s = Settings::default();
231 s.scoped_models = Some(vec!["a".into(), "b".into()]);
232 save_settings(&s).unwrap();
233 let loaded = load_settings().unwrap();
234 assert_eq!(
235 loaded.scoped_models,
236 Some(vec!["a".to_string(), "b".to_string()])
237 );
238 let mut s2 = load_settings().unwrap();
240 s2.scoped_models = None;
241 save_settings(&s2).unwrap();
242 assert_eq!(load_settings().unwrap().scoped_models, None);
243 }
244
245 #[test]
246 fn save_preserves_unknown_fields() {
247 let (_tmp, _guard) = with_temp_env();
248 let path = config::settings_path().unwrap();
249 std::fs::write(&path, r#"{"piOnlyField":"keep-me","theme":"dark"}"#).unwrap();
250 let mut s = load_settings().unwrap();
251 s.scoped_models = Some(vec!["m1".into()]);
252 save_settings(&s).unwrap();
253 let raw: serde_json::Value =
254 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
255 assert_eq!(raw["piOnlyField"], "keep-me");
256 assert_eq!(raw["scopedModels"][0], "m1");
257 assert_eq!(raw["theme"], "dark");
258 }
259}