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.as_object_mut().ok_or("settings file is not an object")?;
78 for (key, val) in [
79 ("defaultProvider", settings.default_provider.as_ref()),
80 ("defaultModel", settings.default_model.as_ref()),
81 ("defaultThinkingLevel", settings.default_thinking_level.as_ref()),
82 ("theme", settings.theme.as_ref()),
83 ] {
84 match val {
85 Some(v) => {
86 obj.insert(key.to_string(), serde_json::Value::String(v.clone()));
87 }
88 None => {
89 obj.remove(key);
90 }
91 }
92 }
93 match &settings.scoped_models {
94 Some(list) if !list.is_empty() => {
95 obj.insert(
96 "scopedModels".to_string(),
97 serde_json::Value::Array(
98 list.iter().map(|m| serde_json::Value::String(m.clone())).collect(),
99 ),
100 );
101 }
102 _ => {
103 obj.remove("scopedModels");
104 }
105 }
106 if let Some(parent) = path.parent() {
107 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
108 }
109 let text = serde_json::to_string_pretty(&merged).map_err(|e| e.to_string())?;
110 std::fs::write(&path, text).map_err(|e| e.to_string())
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116 use crate::config::test_support::env_lock;
117
118 struct TempConfig {
120 _guard: std::sync::MutexGuard<'static, ()>,
121 _tmp: tempfile::TempDir,
122 prev: Option<std::ffi::OsString>,
123 }
124 impl TempConfig {
125 fn new() -> Self {
126 let guard = env_lock().lock().unwrap();
127 let prev = std::env::var_os(config::CONFIG_DIR_ENV);
128 let tmp = tempfile::TempDir::new().unwrap();
129 std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
130 Self { _guard: guard, _tmp: tmp, prev }
131 }
132 }
133 impl Drop for TempConfig {
134 fn drop(&mut self) {
135 match self.prev.take() {
136 Some(v) => std::env::set_var(config::CONFIG_DIR_ENV, v),
137 None => std::env::remove_var(config::CONFIG_DIR_ENV),
138 }
139 }
140 }
141
142 #[test]
143 fn missing_settings_is_default() {
144 let _cfg = TempConfig::new();
145 let s = load_settings().unwrap();
146 assert!(s.default_provider.is_none());
147 assert!(s.default_model.is_none());
148 assert!(s.default_thinking_level.is_none());
149 assert!(s.theme.is_none());
150 }
151
152 #[test]
153 fn reads_honored_fields_and_ignores_unknown() {
154 let _cfg = TempConfig::new();
155 let path = config::settings_path().unwrap();
156 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
157 std::fs::write(
159 &path,
160 r#"{
161 "lastChangelogVersion": "1.0.0",
162 "defaultProvider": "anthropic",
163 "defaultModel": "claude-sonnet-5",
164 "defaultThinkingLevel": "high",
165 "theme": "dark",
166 "hideThinkingBlock": true,
167 "compaction": { "threshold": 100 },
168 "packages": ["some-pkg"]
169 }"#,
170 )
171 .unwrap();
172 let s = load_settings().unwrap();
173 assert_eq!(s.default_provider.as_deref(), Some("anthropic"));
174 assert_eq!(s.default_model.as_deref(), Some("claude-sonnet-5"));
175 assert_eq!(s.default_thinking_level.as_deref(), Some("high"));
176 assert_eq!(s.theme.as_deref(), Some("dark"));
177 }
178
179 #[test]
180 fn tolerates_line_comments() {
181 let _cfg = TempConfig::new();
182 let path = config::settings_path().unwrap();
183 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
184 std::fs::write(
185 &path,
186 "{\n // my default\n \"defaultModel\": \"glm-5\",\n \"theme\": \"light\"\n}\n",
187 )
188 .unwrap();
189 let s = load_settings().unwrap();
190 assert_eq!(s.default_model.as_deref(), Some("glm-5"));
191 assert_eq!(s.theme.as_deref(), Some("light"));
192 }
193
194 #[test]
195 fn malformed_is_error() {
196 let _cfg = TempConfig::new();
197 let path = config::settings_path().unwrap();
198 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
199 std::fs::write(&path, "{ not json").unwrap();
200 assert!(matches!(load_settings(), Err(ConfigError::Json { .. })));
201 }
202}
203
204#[cfg(test)]
205mod scoped_tests {
206 use super::*;
207 use crate::config::test_support::env_lock;
208
209 fn with_temp_env() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
210 let guard = env_lock().lock().unwrap();
211 let tmp = tempfile::TempDir::new().unwrap();
212 std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
213 (tmp, guard)
214 }
215
216 #[test]
217 fn save_load_scoped_models_roundtrip() {
218 let (_tmp, _guard) = with_temp_env();
219 let mut s = Settings::default();
220 s.scoped_models = Some(vec!["a".into(), "b".into()]);
221 save_settings(&s).unwrap();
222 let loaded = load_settings().unwrap();
223 assert_eq!(loaded.scoped_models, Some(vec!["a".to_string(), "b".to_string()]));
224 let mut s2 = load_settings().unwrap();
226 s2.scoped_models = None;
227 save_settings(&s2).unwrap();
228 assert_eq!(load_settings().unwrap().scoped_models, None);
229 }
230
231 #[test]
232 fn save_preserves_unknown_fields() {
233 let (_tmp, _guard) = with_temp_env();
234 let path = config::settings_path().unwrap();
235 std::fs::write(&path, r#"{"piOnlyField":"keep-me","theme":"dark"}"#).unwrap();
236 let mut s = load_settings().unwrap();
237 s.scoped_models = Some(vec!["m1".into()]);
238 save_settings(&s).unwrap();
239 let raw: serde_json::Value =
240 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
241 assert_eq!(raw["piOnlyField"], "keep-me");
242 assert_eq!(raw["scopedModels"][0], "m1");
243 assert_eq!(raw["theme"], "dark");
244 }
245}