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}
33
34pub fn load_settings() -> Result<Settings, ConfigError> {
38 let path = config::settings_path()?;
39 match std::fs::read_to_string(&path) {
40 Ok(text) => parse_settings(&text).map_err(|e| ConfigError::Json {
41 path: path.clone(),
42 source: e,
43 }),
44 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Settings::default()),
45 Err(e) => Err(ConfigError::Read { path, source: e }),
46 }
47}
48
49fn parse_settings(text: &str) -> Result<Settings, serde_json::Error> {
50 match serde_json::from_str(text) {
51 Ok(s) => Ok(s),
52 Err(first) => {
53 let stripped = strip_line_comments(text);
54 serde_json::from_str(&stripped).map_err(|_| first)
55 }
56 }
57}
58
59#[cfg(test)]
60mod tests {
61 use super::*;
62 use crate::config::test_support::env_lock;
63
64 struct TempConfig {
66 _guard: std::sync::MutexGuard<'static, ()>,
67 _tmp: tempfile::TempDir,
68 prev: Option<std::ffi::OsString>,
69 }
70 impl TempConfig {
71 fn new() -> Self {
72 let guard = env_lock().lock().unwrap();
73 let prev = std::env::var_os(config::CONFIG_DIR_ENV);
74 let tmp = tempfile::TempDir::new().unwrap();
75 std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
76 Self { _guard: guard, _tmp: tmp, prev }
77 }
78 }
79 impl Drop for TempConfig {
80 fn drop(&mut self) {
81 match self.prev.take() {
82 Some(v) => std::env::set_var(config::CONFIG_DIR_ENV, v),
83 None => std::env::remove_var(config::CONFIG_DIR_ENV),
84 }
85 }
86 }
87
88 #[test]
89 fn missing_settings_is_default() {
90 let _cfg = TempConfig::new();
91 let s = load_settings().unwrap();
92 assert!(s.default_provider.is_none());
93 assert!(s.default_model.is_none());
94 assert!(s.default_thinking_level.is_none());
95 assert!(s.theme.is_none());
96 }
97
98 #[test]
99 fn reads_honored_fields_and_ignores_unknown() {
100 let _cfg = TempConfig::new();
101 let path = config::settings_path().unwrap();
102 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
103 std::fs::write(
105 &path,
106 r#"{
107 "lastChangelogVersion": "1.0.0",
108 "defaultProvider": "anthropic",
109 "defaultModel": "claude-sonnet-5",
110 "defaultThinkingLevel": "high",
111 "theme": "dark",
112 "hideThinkingBlock": true,
113 "compaction": { "threshold": 100 },
114 "packages": ["some-pkg"]
115 }"#,
116 )
117 .unwrap();
118 let s = load_settings().unwrap();
119 assert_eq!(s.default_provider.as_deref(), Some("anthropic"));
120 assert_eq!(s.default_model.as_deref(), Some("claude-sonnet-5"));
121 assert_eq!(s.default_thinking_level.as_deref(), Some("high"));
122 assert_eq!(s.theme.as_deref(), Some("dark"));
123 }
124
125 #[test]
126 fn tolerates_line_comments() {
127 let _cfg = TempConfig::new();
128 let path = config::settings_path().unwrap();
129 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
130 std::fs::write(
131 &path,
132 "{\n // my default\n \"defaultModel\": \"glm-5\",\n \"theme\": \"light\"\n}\n",
133 )
134 .unwrap();
135 let s = load_settings().unwrap();
136 assert_eq!(s.default_model.as_deref(), Some("glm-5"));
137 assert_eq!(s.theme.as_deref(), Some("light"));
138 }
139
140 #[test]
141 fn malformed_is_error() {
142 let _cfg = TempConfig::new();
143 let path = config::settings_path().unwrap();
144 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
145 std::fs::write(&path, "{ not json").unwrap();
146 assert!(matches!(load_settings(), Err(ConfigError::Json { .. })));
147 }
148}