1use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15use crate::config::{self, strip_line_comments, ConfigError};
16
17#[derive(serde::Deserialize, Default, Clone, Debug)]
19#[serde(rename_all = "camelCase")]
20pub struct Settings {
21 #[serde(default)]
24 pub default_provider: Option<String>,
25 #[serde(default)]
28 pub default_model: Option<String>,
29 #[serde(default)]
32 pub default_thinking_level: Option<String>,
33 #[serde(default)]
35 pub theme: Option<String>,
36 #[serde(default)]
39 pub scoped_models: Option<Vec<String>>,
40 #[serde(default)]
43 pub packages: Option<Vec<String>>,
44 #[serde(default, alias = "skills")]
48 pub skill_dirs: Option<Vec<String>>,
49 #[serde(default, alias = "prompts")]
52 pub prompt_dirs: Option<Vec<String>>,
53 #[serde(default, alias = "extensions")]
56 pub extension_dirs: Option<Vec<String>>,
57 #[serde(default)]
60 pub keybindings: Option<HashMap<String, serde_json::Value>>,
61 #[serde(default)]
64 pub double_escape_action: Option<String>,
65 #[serde(default)]
67 pub hide_thinking_block: Option<bool>,
68 #[serde(default)]
70 pub quiet_startup: Option<bool>,
71 #[serde(default)]
73 pub show_terminal_progress: Option<bool>,
74 #[serde(default)]
76 pub editor_padding_x: Option<usize>,
77 #[serde(default)]
79 pub autocomplete_max_visible: Option<usize>,
80}
81
82pub fn load_settings() -> Result<Settings, ConfigError> {
86 let path = config::settings_path()?;
87 match std::fs::read_to_string(&path) {
88 Ok(text) => parse_settings(&text).map_err(|e| ConfigError::Json {
89 path: path.clone(),
90 source: e,
91 }),
92 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
93 let legacy = if std::env::var_os(config::CONFIG_DIR_ENV).is_some() {
97 None
98 } else {
99 dirs::home_dir().map(|home| home.join(".pi/agent/settings.json"))
100 };
101 match legacy.filter(|candidate| candidate != &path) {
102 Some(legacy_path) => match std::fs::read_to_string(&legacy_path) {
103 Ok(text) => parse_settings(&text).map_err(|e| ConfigError::Json {
104 path: legacy_path,
105 source: e,
106 }),
107 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
108 Ok(Settings::default())
109 }
110 Err(error) => Err(ConfigError::Read {
111 path: legacy_path,
112 source: error,
113 }),
114 },
115 None => Ok(Settings::default()),
116 }
117 }
118 Err(e) => Err(ConfigError::Read { path, source: e }),
119 }
120}
121
122pub fn load_project_settings(cwd: &Path) -> Vec<Settings> {
129 load_project_settings_with_paths(cwd)
130 .into_iter()
131 .map(|(_, settings)| settings)
132 .collect()
133}
134
135pub fn load_project_settings_with_paths(cwd: &Path) -> Vec<(PathBuf, Settings)> {
139 [
140 cwd.join(".rpi").join("settings.json"),
141 cwd.join(".pi").join("settings.json"),
142 ]
143 .into_iter()
144 .filter_map(|path| {
145 load_settings_file(&path)
146 .ok()
147 .map(|settings| (path, settings))
148 })
149 .collect()
150}
151
152fn load_settings_file(path: &Path) -> Result<Settings, ConfigError> {
153 let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
154 path: path.to_path_buf(),
155 source,
156 })?;
157 parse_settings(&text).map_err(|source| ConfigError::Json {
158 path: path.to_path_buf(),
159 source,
160 })
161}
162
163pub fn resolve_configured_paths(base: &Path, values: &[String]) -> Vec<PathBuf> {
166 values
167 .iter()
168 .map(|value| value.trim())
169 .filter(|value| !value.is_empty())
170 .map(|value| {
171 let path = PathBuf::from(value);
172 if path.is_absolute() {
173 path
174 } else {
175 base.join(path)
176 }
177 })
178 .collect()
179}
180
181fn parse_settings(text: &str) -> Result<Settings, serde_json::Error> {
182 match serde_json::from_str(text) {
183 Ok(s) => Ok(s),
184 Err(first) => {
185 let stripped = strip_line_comments(text);
186 serde_json::from_str(&stripped).map_err(|_| first)
187 }
188 }
189}
190
191pub fn save_settings(settings: &Settings) -> Result<(), String> {
198 let path = config::settings_path().map_err(|e| e.to_string())?;
199 let mut merged = match std::fs::read_to_string(&path) {
201 Ok(text) => serde_json::from_str::<serde_json::Value>(&text)
202 .unwrap_or(serde_json::Value::Object(Default::default())),
203 Err(_) => serde_json::Value::Object(Default::default()),
204 };
205 let obj = merged
206 .as_object_mut()
207 .ok_or("settings file is not an object")?;
208 for (key, val) in [
209 ("defaultProvider", settings.default_provider.as_ref()),
210 ("defaultModel", settings.default_model.as_ref()),
211 (
212 "defaultThinkingLevel",
213 settings.default_thinking_level.as_ref(),
214 ),
215 ("theme", settings.theme.as_ref()),
216 ] {
217 match val {
218 Some(v) => {
219 obj.insert(key.to_string(), serde_json::Value::String(v.clone()));
220 }
221 None => {
222 obj.remove(key);
223 }
224 }
225 }
226 match &settings.scoped_models {
227 Some(list) if !list.is_empty() => {
228 obj.insert(
229 "scopedModels".to_string(),
230 serde_json::Value::Array(
231 list.iter()
232 .map(|m| serde_json::Value::String(m.clone()))
233 .collect(),
234 ),
235 );
236 }
237 _ => {
238 obj.remove("scopedModels");
239 }
240 }
241 match &settings.packages {
242 Some(list) if !list.is_empty() => {
243 obj.insert(
244 "packages".to_string(),
245 serde_json::Value::Array(
246 list.iter()
247 .map(|p| serde_json::Value::String(p.clone()))
248 .collect(),
249 ),
250 );
251 }
252 _ => {
253 obj.remove("packages");
254 }
255 }
256 for (key, values) in [
257 ("skillDirs", settings.skill_dirs.as_ref()),
258 ("promptDirs", settings.prompt_dirs.as_ref()),
259 ("extensionDirs", settings.extension_dirs.as_ref()),
260 ] {
261 match values {
262 Some(list) if !list.is_empty() => {
263 obj.insert(
264 key.to_string(),
265 serde_json::Value::Array(
266 list.iter()
267 .map(|path| serde_json::Value::String(path.clone()))
268 .collect(),
269 ),
270 );
271 }
272 _ => {
273 obj.remove(key);
274 }
275 }
276 }
277 match &settings.keybindings {
278 Some(bindings) => {
279 obj.insert(
280 "keybindings".to_string(),
281 serde_json::to_value(bindings).map_err(|e| e.to_string())?,
282 );
283 }
284 None => {
285 obj.remove("keybindings");
286 }
287 }
288 match settings.double_escape_action.as_deref() {
289 Some(action) if !action.trim().is_empty() => {
290 obj.insert(
291 "doubleEscapeAction".to_string(),
292 serde_json::Value::String(action.to_string()),
293 );
294 }
295 _ => {
296 obj.remove("doubleEscapeAction");
297 }
298 }
299 for (key, value) in [
300 (
301 "hideThinkingBlock",
302 settings.hide_thinking_block.map(serde_json::Value::Bool),
303 ),
304 (
305 "quietStartup",
306 settings.quiet_startup.map(serde_json::Value::Bool),
307 ),
308 (
309 "showTerminalProgress",
310 settings.show_terminal_progress.map(serde_json::Value::Bool),
311 ),
312 (
313 "editorPaddingX",
314 settings
315 .editor_padding_x
316 .map(|v| serde_json::Value::Number(v.into())),
317 ),
318 (
319 "autocompleteMaxVisible",
320 settings
321 .autocomplete_max_visible
322 .map(|v| serde_json::Value::Number(v.into())),
323 ),
324 ] {
325 match value {
326 Some(value) => {
327 obj.insert(key.to_string(), value);
328 }
329 None => {
330 obj.remove(key);
331 }
332 }
333 }
334 if let Some(parent) = path.parent() {
335 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
336 }
337 let text = serde_json::to_string_pretty(&merged).map_err(|e| e.to_string())?;
338 std::fs::write(&path, text).map_err(|e| e.to_string())
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use crate::config::test_support::env_lock;
345
346 struct TempConfig {
348 _guard: std::sync::MutexGuard<'static, ()>,
349 _tmp: tempfile::TempDir,
350 prev: Option<std::ffi::OsString>,
351 }
352 impl TempConfig {
353 fn new() -> Self {
354 let guard = env_lock().lock().unwrap();
355 let prev = std::env::var_os(config::CONFIG_DIR_ENV);
356 let tmp = tempfile::TempDir::new().unwrap();
357 std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
358 Self {
359 _guard: guard,
360 _tmp: tmp,
361 prev,
362 }
363 }
364 }
365 impl Drop for TempConfig {
366 fn drop(&mut self) {
367 match self.prev.take() {
368 Some(v) => std::env::set_var(config::CONFIG_DIR_ENV, v),
369 None => std::env::remove_var(config::CONFIG_DIR_ENV),
370 }
371 }
372 }
373
374 #[test]
375 fn missing_settings_is_default() {
376 let _cfg = TempConfig::new();
377 let s = load_settings().unwrap();
378 assert!(s.default_provider.is_none());
379 assert!(s.default_model.is_none());
380 assert!(s.default_thinking_level.is_none());
381 assert!(s.theme.is_none());
382 assert!(s.skill_dirs.is_none());
383 assert!(s.prompt_dirs.is_none());
384 assert!(s.extension_dirs.is_none());
385 }
386
387 #[test]
388 fn reads_honored_fields_and_ignores_unknown() {
389 let _cfg = TempConfig::new();
390 let path = config::settings_path().unwrap();
391 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
392 std::fs::write(
394 &path,
395 r#"{
396 "lastChangelogVersion": "1.0.0",
397 "defaultProvider": "anthropic",
398 "defaultModel": "claude-sonnet-5",
399 "defaultThinkingLevel": "high",
400 "theme": "dark",
401 "hideThinkingBlock": true,
402 "quietStartup": true,
403 "showTerminalProgress": false,
404 "editorPaddingX": 3,
405 "autocompleteMaxVisible": 7,
406 "compaction": { "threshold": 100 },
407 "packages": ["some-pkg"]
408 }"#,
409 )
410 .unwrap();
411 let s = load_settings().unwrap();
412 assert_eq!(s.default_provider.as_deref(), Some("anthropic"));
413 assert_eq!(s.default_model.as_deref(), Some("claude-sonnet-5"));
414 assert_eq!(s.default_thinking_level.as_deref(), Some("high"));
415 assert_eq!(s.theme.as_deref(), Some("dark"));
416 assert_eq!(s.packages, Some(vec!["some-pkg".to_string()]));
417 assert_eq!(s.hide_thinking_block, Some(true));
418 assert_eq!(s.quiet_startup, Some(true));
419 assert_eq!(s.show_terminal_progress, Some(false));
420 assert_eq!(s.editor_padding_x, Some(3));
421 assert_eq!(s.autocomplete_max_visible, Some(7));
422 }
423
424 #[test]
425 fn loads_project_settings_in_rpi_then_pi_order() {
426 let tmp = tempfile::tempdir().unwrap();
427 std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
428 std::fs::create_dir_all(tmp.path().join(".pi")).unwrap();
429 std::fs::write(
430 tmp.path().join(".rpi/settings.json"),
431 r#"{"skillDirs":["rpi-skills"],"extensions":["rpi-ext"]}"#,
432 )
433 .unwrap();
434 std::fs::write(
435 tmp.path().join(".pi/settings.json"),
436 r#"{"skills":["pi-skills"],"extensionDirs":["pi-ext"]}"#,
437 )
438 .unwrap();
439
440 let settings = load_project_settings(tmp.path());
441 assert_eq!(settings.len(), 2);
442 assert_eq!(
443 settings[0].skill_dirs.as_deref(),
444 Some(["rpi-skills".to_string()].as_slice())
445 );
446 assert_eq!(
447 settings[0].extension_dirs.as_deref(),
448 Some(["rpi-ext".to_string()].as_slice())
449 );
450 assert_eq!(
451 settings[1].skill_dirs.as_deref(),
452 Some(["pi-skills".to_string()].as_slice())
453 );
454 assert_eq!(
455 settings[1].extension_dirs.as_deref(),
456 Some(["pi-ext".to_string()].as_slice())
457 );
458 }
459
460 #[test]
461 fn tolerates_line_comments() {
462 let _cfg = TempConfig::new();
463 let path = config::settings_path().unwrap();
464 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
465 std::fs::write(
466 &path,
467 "{\n // my default\n \"defaultModel\": \"glm-5\",\n \"theme\": \"light\"\n}\n",
468 )
469 .unwrap();
470 let s = load_settings().unwrap();
471 assert_eq!(s.default_model.as_deref(), Some("glm-5"));
472 assert_eq!(s.theme.as_deref(), Some("light"));
473 }
474
475 #[test]
476 fn malformed_is_error() {
477 let _cfg = TempConfig::new();
478 let path = config::settings_path().unwrap();
479 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
480 std::fs::write(&path, "{ not json").unwrap();
481 assert!(matches!(load_settings(), Err(ConfigError::Json { .. })));
482 }
483}
484
485#[cfg(test)]
486mod scoped_tests {
487 use super::*;
488 use crate::config::test_support::env_lock;
489
490 fn with_temp_env() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
491 let guard = env_lock().lock().unwrap();
492 let tmp = tempfile::TempDir::new().unwrap();
493 std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
494 (tmp, guard)
495 }
496
497 #[test]
498 fn save_load_scoped_models_roundtrip() {
499 let (_tmp, _guard) = with_temp_env();
500 let mut s = Settings::default();
501 s.scoped_models = Some(vec!["a".into(), "b".into()]);
502 save_settings(&s).unwrap();
503 let loaded = load_settings().unwrap();
504 assert_eq!(
505 loaded.scoped_models,
506 Some(vec!["a".to_string(), "b".to_string()])
507 );
508 let mut s2 = load_settings().unwrap();
510 s2.scoped_models = None;
511 save_settings(&s2).unwrap();
512 assert_eq!(load_settings().unwrap().scoped_models, None);
513 }
514
515 #[test]
516 fn save_preserves_unknown_fields() {
517 let (_tmp, _guard) = with_temp_env();
518 let path = config::settings_path().unwrap();
519 std::fs::write(&path, r#"{"piOnlyField":"keep-me","theme":"dark"}"#).unwrap();
520 let mut s = load_settings().unwrap();
521 s.scoped_models = Some(vec!["m1".into()]);
522 save_settings(&s).unwrap();
523 let raw: serde_json::Value =
524 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
525 assert_eq!(raw["piOnlyField"], "keep-me");
526 assert_eq!(raw["scopedModels"][0], "m1");
527 assert_eq!(raw["theme"], "dark");
528 }
529}