1use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15use crate::config::{self, strip_line_comments, ConfigError};
16
17#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default)]
20#[serde(rename_all = "camelCase")]
21pub struct RetrySettings {
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub enabled: Option<bool>,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub max_retries: Option<u32>,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub base_delay_ms: Option<u64>,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub max_agent_delay_ms: Option<u64>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub provider: Option<serde_json::Value>,
34}
35
36#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default)]
38#[serde(rename_all = "camelCase")]
39pub struct CompactionSettingsJson {
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub enabled: Option<bool>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub reserve_tokens: Option<i64>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub keep_recent_tokens: Option<i64>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub model_overrides: Option<serde_json::Value>,
50}
51
52#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default)]
54#[serde(rename_all = "camelCase")]
55pub struct BranchSummarySettings {
56 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub reserve_tokens: Option<i64>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub skip_prompt: Option<bool>,
63}
64
65#[derive(serde::Serialize, serde::Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
67#[serde(rename_all = "kebab-case")]
68pub enum QueueModeSetting {
69 All,
70 OneAtATime,
71}
72
73impl From<QueueModeSetting> for rpi_agent::QueueMode {
74 fn from(mode: QueueModeSetting) -> Self {
75 match mode {
76 QueueModeSetting::All => rpi_agent::QueueMode::All,
77 QueueModeSetting::OneAtATime => rpi_agent::QueueMode::OneAtATime,
78 }
79 }
80}
81
82#[derive(serde::Deserialize, Default, Clone, Debug)]
84#[serde(rename_all = "camelCase")]
85pub struct Settings {
86 #[serde(default)]
89 pub default_provider: Option<String>,
90 #[serde(default)]
93 pub default_model: Option<String>,
94 #[serde(default)]
97 pub default_thinking_level: Option<String>,
98 #[serde(default)]
100 pub theme: Option<String>,
101 #[serde(default, alias = "enabledModels")]
106 pub scoped_models: Option<Vec<String>>,
107
108 #[serde(default)]
111 pub session_dir: Option<PathBuf>,
112
113 #[serde(default)]
118 pub shell_path: Option<String>,
119
120 #[serde(default)]
123 pub shell_command_prefix: Option<String>,
124
125 #[serde(default)]
128 pub http_proxy: Option<String>,
129
130 #[serde(default)]
133 pub retry: Option<RetrySettings>,
134
135 #[serde(default)]
138 pub compaction: Option<CompactionSettingsJson>,
139
140 #[serde(default)]
148 pub branch_summary: Option<BranchSummarySettings>,
149
150 #[serde(default)]
153 pub steering_mode: Option<QueueModeSetting>,
154
155 #[serde(default)]
158 pub follow_up_mode: Option<QueueModeSetting>,
159 #[serde(default, alias = "skills")]
163 pub skill_dirs: Option<Vec<String>>,
164 #[serde(default, alias = "prompts")]
167 pub prompt_dirs: Option<Vec<String>>,
168 #[serde(default, alias = "extensions")]
171 pub extension_dirs: Option<Vec<String>>,
172 #[serde(default)]
175 pub keybindings: Option<HashMap<String, serde_json::Value>>,
176 #[serde(default)]
179 pub double_escape_action: Option<String>,
180 #[serde(default)]
183 pub default_tools: Option<Vec<String>>,
184 #[serde(default)]
186 pub hide_thinking_block: Option<bool>,
187 #[serde(default)]
190 pub quiet_startup: Option<bool>,
191 #[serde(default)]
194 pub last_changelog_version: Option<String>,
195 #[serde(default)]
197 pub last_rpi_changelog_version: Option<String>,
198 #[serde(default)]
200 pub collapse_changelog: Option<bool>,
201 #[serde(default)]
203 pub show_terminal_progress: Option<bool>,
204 #[serde(default)]
206 pub editor_padding_x: Option<usize>,
207 #[serde(default)]
209 pub autocomplete_max_visible: Option<usize>,
210 #[serde(default)]
213 pub fullscreen_copy_on_select: Option<bool>,
214 #[serde(default)]
218 pub output_pad: Option<usize>,
219 #[serde(default)]
222 pub show_cache_miss_notices: Option<bool>,
223 #[serde(default)]
227 pub show_images: Option<bool>,
228 #[serde(default)]
232 pub terminal: Option<TerminalSettings>,
233 #[serde(default, alias = "httpIdleTimeoutMs")]
237 pub http_idle_timeout: Option<serde_json::Value>,
238 #[serde(default)]
241 pub stream_idle_timeout: Option<u64>,
242 #[serde(default)]
246 pub thinking_budgets: Option<rpi_ai::ThinkingBudgets>,
247}
248
249#[derive(serde::Deserialize, Clone, Debug, Default)]
252#[serde(rename_all = "camelCase")]
253pub struct TerminalSettings {
254 #[serde(default)]
256 pub show_images: Option<bool>,
257 #[serde(default)]
259 pub show_terminal_progress: Option<bool>,
260 #[serde(default)]
262 pub clear_on_shrink: Option<bool>,
263}
264
265impl Settings {
266 pub fn http_idle_timeout_ms(&self) -> Option<i64> {
269 self.http_idle_timeout
270 .as_ref()
271 .and_then(rpi_ai::http::parse_http_idle_timeout_ms)
272 }
273
274 pub fn retry_policy(&self) -> rpi_harness::types::RetryPolicy {
277 let mut policy = rpi_harness::types::RetryPolicy::default();
278 let Some(retry) = &self.retry else {
279 return policy;
280 };
281 if let Some(enabled) = retry.enabled {
282 policy.enabled = enabled;
283 }
284 if let Some(max_retries) = retry.max_retries {
285 policy.max_retries = max_retries;
286 }
287 if let Some(base_delay_ms) = retry.base_delay_ms {
288 policy.base_delay_ms = base_delay_ms;
289 }
290 if let Some(max_agent_delay_ms) = retry.max_agent_delay_ms {
291 policy.max_agent_delay_ms = max_agent_delay_ms;
292 }
293 policy
294 }
295
296 pub fn compaction_settings(&self) -> rpi_harness::types::CompactionSettings {
298 let mut settings = rpi_harness::types::CompactionSettings::default();
299 let Some(compaction) = &self.compaction else {
300 return settings;
301 };
302 if let Some(enabled) = compaction.enabled {
303 settings.enabled = enabled;
304 }
305 if let Some(reserve_tokens) = compaction.reserve_tokens {
306 settings.reserve_tokens = reserve_tokens;
307 }
308 if let Some(keep_recent_tokens) = compaction.keep_recent_tokens {
309 settings.keep_recent_tokens = keep_recent_tokens;
310 }
311 settings
312 }
313
314 pub fn branch_summary_reserve_tokens(&self) -> Option<i64> {
320 self.branch_summary.as_ref().and_then(|s| s.reserve_tokens)
321 }
322
323 pub fn branch_summary_skipped(&self) -> bool {
328 self.branch_summary
329 .as_ref()
330 .and_then(|s| s.skip_prompt)
331 .unwrap_or(false)
332 }
333
334 pub fn steering_mode(&self) -> rpi_agent::QueueMode {
336 self.steering_mode
337 .map(Into::into)
338 .unwrap_or(rpi_agent::QueueMode::OneAtATime)
339 }
340
341 pub fn follow_up_mode(&self) -> rpi_agent::QueueMode {
343 self.follow_up_mode
344 .map(Into::into)
345 .unwrap_or(rpi_agent::QueueMode::OneAtATime)
346 }
347
348 pub fn show_images(&self) -> Option<bool> {
351 self.show_images
352 .or_else(|| self.terminal.as_ref().and_then(|t| t.show_images))
353 }
354
355 pub fn show_terminal_progress(&self) -> Option<bool> {
358 self.show_terminal_progress.or_else(|| {
359 self.terminal
360 .as_ref()
361 .and_then(|t| t.show_terminal_progress)
362 })
363 }
364}
365
366pub fn load_settings() -> Result<Settings, ConfigError> {
370 let path = config::settings_path()?;
371 match std::fs::read_to_string(&path) {
372 Ok(text) => parse_settings(&text).map_err(|e| ConfigError::Json {
373 path: path.clone(),
374 source: e,
375 }),
376 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Settings::default()),
377 Err(e) => Err(ConfigError::Read { path, source: e }),
378 }
379}
380
381pub fn load_project_settings(cwd: &Path) -> Vec<Settings> {
384 load_project_settings_with_paths(cwd)
385 .into_iter()
386 .map(|(_, settings)| settings)
387 .collect()
388}
389
390pub fn load_project_settings_with_paths(cwd: &Path) -> Vec<(PathBuf, Settings)> {
392 load_active_project_settings(cwd)
393 .ok()
394 .flatten()
395 .into_iter()
396 .collect()
397}
398
399pub fn load_active_project_settings(
402 cwd: &Path,
403) -> Result<Option<(PathBuf, Settings)>, ConfigError> {
404 let preferred = cwd.join(".rpi/settings.json");
405 match load_settings_file(&preferred) {
406 Ok(settings) => Ok(Some((preferred, settings))),
407 Err(ConfigError::Read { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => {
408 Ok(None)
409 }
410 Err(error) => Err(error),
411 }
412}
413
414pub fn load_effective_model_settings(
418 cwd: &Path,
419 project_trusted: bool,
420) -> Result<Settings, ConfigError> {
421 let mut effective = load_settings()?;
422 if project_trusted {
423 if let Some((_, project)) = load_active_project_settings(cwd)? {
424 if project.default_provider.is_some() {
425 effective.default_provider = project.default_provider;
426 }
427 if project.default_model.is_some() {
428 effective.default_model = project.default_model;
429 }
430 if project.default_thinking_level.is_some() {
431 effective.default_thinking_level = project.default_thinking_level;
432 }
433 if project.theme.is_some() {
434 effective.theme = project.theme;
435 }
436 }
437 }
438 Ok(effective)
439}
440
441pub fn load_project_settings_for_write(cwd: &Path) -> Result<Settings, ConfigError> {
443 Ok(load_active_project_settings(cwd)?
444 .map(|(_, settings)| settings)
445 .unwrap_or_default())
446}
447
448fn load_settings_file(path: &Path) -> Result<Settings, ConfigError> {
449 let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
450 path: path.to_path_buf(),
451 source,
452 })?;
453 parse_settings(&text).map_err(|source| ConfigError::Json {
454 path: path.to_path_buf(),
455 source,
456 })
457}
458
459pub fn resolve_configured_paths(base: &Path, values: &[String]) -> Vec<PathBuf> {
462 values
463 .iter()
464 .map(|value| value.trim())
465 .filter(|value| !value.is_empty())
466 .map(|value| {
467 let path = PathBuf::from(value);
468 if path.is_absolute() {
469 path
470 } else {
471 base.join(path)
472 }
473 })
474 .collect()
475}
476
477fn parse_settings(text: &str) -> Result<Settings, serde_json::Error> {
478 match serde_json::from_str(text) {
479 Ok(s) => Ok(s),
480 Err(first) => {
481 let stripped = strip_line_comments(text);
482 serde_json::from_str(&stripped).map_err(|_| first)
483 }
484 }
485}
486
487fn parse_settings_value(text: &str) -> Result<serde_json::Value, serde_json::Error> {
493 match serde_json::from_str::<serde_json::Value>(text) {
494 Ok(value) => Ok(value),
495 Err(first) => {
496 let stripped = strip_line_comments(text);
497 serde_json::from_str::<serde_json::Value>(&stripped).map_err(|_| first)
498 }
499 }
500}
501
502pub fn save_settings(settings: &Settings) -> Result<(), String> {
510 let path = config::settings_path().map_err(|e| e.to_string())?;
511 save_settings_to_path(settings, &path, None)
512}
513
514pub fn save_project_settings(cwd: &Path, settings: &Settings) -> Result<(), String> {
516 save_settings_to_path(settings, &cwd.join(".rpi/settings.json"), None)
517}
518
519fn save_settings_to_path(
520 settings: &Settings,
521 path: &Path,
522 fallback: Option<&Path>,
523) -> Result<(), String> {
524 let mut merged = match std::fs::read_to_string(path) {
526 Ok(text) => parse_settings_value(&text).map_err(|error| {
527 format!(
528 "cannot parse existing settings file {}: {error}",
529 path.display()
530 )
531 })?,
532 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
533 read_fallback_settings_value(path, fallback)?
534 .unwrap_or_else(|| serde_json::Value::Object(Default::default()))
535 }
536 Err(error) => {
537 return Err(format!(
538 "cannot read existing settings file {}: {error}",
539 path.display()
540 ));
541 }
542 };
543 let obj = merged
544 .as_object_mut()
545 .ok_or_else(|| format!("settings file {} is not an object", path.display()))?;
546 for (key, val) in [
547 ("defaultProvider", settings.default_provider.as_ref()),
548 ("defaultModel", settings.default_model.as_ref()),
549 (
550 "defaultThinkingLevel",
551 settings.default_thinking_level.as_ref(),
552 ),
553 ("theme", settings.theme.as_ref()),
554 (
555 "lastChangelogVersion",
556 settings.last_changelog_version.as_ref(),
557 ),
558 (
559 "lastRpiChangelogVersion",
560 settings.last_rpi_changelog_version.as_ref(),
561 ),
562 ] {
563 match val {
564 Some(v) => {
565 obj.insert(key.to_string(), serde_json::Value::String(v.clone()));
566 }
567 None => {
568 obj.remove(key);
569 }
570 }
571 }
572 match &settings.scoped_models {
573 Some(list) if !list.is_empty() => {
574 obj.insert(
575 "scopedModels".to_string(),
576 serde_json::Value::Array(
577 list.iter()
578 .map(|m| serde_json::Value::String(m.clone()))
579 .collect(),
580 ),
581 );
582 }
583 _ => {
584 obj.remove("scopedModels");
585 }
586 }
587 match &settings.keybindings {
588 Some(bindings) => {
589 obj.insert(
590 "keybindings".to_string(),
591 serde_json::to_value(bindings).map_err(|e| e.to_string())?,
592 );
593 }
594 None => {
595 obj.remove("keybindings");
596 }
597 }
598 match settings.double_escape_action.as_deref() {
599 Some(action) if !action.trim().is_empty() => {
600 obj.insert(
601 "doubleEscapeAction".to_string(),
602 serde_json::Value::String(action.to_string()),
603 );
604 }
605 _ => {
606 obj.remove("doubleEscapeAction");
607 }
608 }
609 for (key, value) in [
610 (
611 "hideThinkingBlock",
612 settings.hide_thinking_block.map(serde_json::Value::Bool),
613 ),
614 (
615 "quietStartup",
616 settings.quiet_startup.map(serde_json::Value::Bool),
617 ),
618 (
619 "collapseChangelog",
620 settings.collapse_changelog.map(serde_json::Value::Bool),
621 ),
622 (
623 "showTerminalProgress",
624 settings.show_terminal_progress.map(serde_json::Value::Bool),
625 ),
626 (
627 "showImages",
628 settings.show_images.map(serde_json::Value::Bool),
629 ),
630 (
631 "showCacheMissNotices",
632 settings
633 .show_cache_miss_notices
634 .map(serde_json::Value::Bool),
635 ),
636 (
637 "fullscreenCopyOnSelect",
638 settings
639 .fullscreen_copy_on_select
640 .map(serde_json::Value::Bool),
641 ),
642 (
643 "editorPaddingX",
644 settings
645 .editor_padding_x
646 .map(|v| serde_json::Value::Number(v.into())),
647 ),
648 (
649 "outputPad",
650 settings
651 .output_pad
652 .map(|v| serde_json::Value::Number(v.into())),
653 ),
654 (
655 "autocompleteMaxVisible",
656 settings
657 .autocomplete_max_visible
658 .map(|v| serde_json::Value::Number(v.into())),
659 ),
660 ("httpIdleTimeout", settings.http_idle_timeout.clone()),
661 (
662 "streamIdleTimeout",
663 settings.stream_idle_timeout.map(serde_json::Value::from),
664 ),
665 ] {
666 match value {
667 Some(value) => {
668 obj.insert(key.to_string(), value);
669 }
670 None => {
671 obj.remove(key);
672 }
673 }
674 }
675 if let Some(parent) = path.parent() {
676 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
677 }
678 let text = serde_json::to_string_pretty(&merged).map_err(|e| e.to_string())?;
679 config::atomic_write(path, text.as_bytes()).map_err(|e| e.to_string())
680}
681
682fn read_fallback_settings_value(
686 target: &Path,
687 fallback: Option<&Path>,
688) -> Result<Option<serde_json::Value>, String> {
689 let Some(path) = fallback else {
690 return Ok(None);
691 };
692 if path == target {
693 return Ok(None);
694 }
695 match std::fs::read_to_string(path) {
696 Ok(text) => parse_settings_value(&text).map(Some).map_err(|error| {
697 format!(
698 "cannot parse fallback settings file {}: {error}",
699 path.display()
700 )
701 }),
702 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
703 Err(error) => Err(format!(
704 "cannot read fallback settings file {}: {error}",
705 path.display()
706 )),
707 }
708}
709
710#[cfg(test)]
711mod tests {
712 use super::*;
713 use crate::config::test_support::env_lock;
714
715 struct TempConfig {
717 _guard: std::sync::MutexGuard<'static, ()>,
718 _tmp: tempfile::TempDir,
719 prev: Option<std::ffi::OsString>,
720 }
721 impl TempConfig {
722 fn new() -> Self {
723 let guard = env_lock().lock().unwrap();
724 let prev = std::env::var_os(config::CONFIG_DIR_ENV);
725 let tmp = tempfile::TempDir::new().unwrap();
726 std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
727 Self {
728 _guard: guard,
729 _tmp: tmp,
730 prev,
731 }
732 }
733 }
734 impl Drop for TempConfig {
735 fn drop(&mut self) {
736 match self.prev.take() {
737 Some(v) => std::env::set_var(config::CONFIG_DIR_ENV, v),
738 None => std::env::remove_var(config::CONFIG_DIR_ENV),
739 }
740 }
741 }
742
743 #[test]
744 fn missing_settings_is_default() {
745 let _cfg = TempConfig::new();
746 let s = load_settings().unwrap();
747 assert!(s.default_provider.is_none());
748 assert!(s.default_model.is_none());
749 assert!(s.default_thinking_level.is_none());
750 assert!(s.theme.is_none());
751 assert!(s.skill_dirs.is_none());
752 assert!(s.prompt_dirs.is_none());
753 assert!(s.extension_dirs.is_none());
754 }
755
756 #[test]
757 fn reads_honored_fields_and_ignores_unknown() {
758 let _cfg = TempConfig::new();
759 let path = config::settings_path().unwrap();
760 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
761 std::fs::write(
763 &path,
764 r#"{
765 "lastChangelogVersion": "1.0.0",
766 "defaultProvider": "anthropic",
767 "defaultModel": "claude-sonnet-5",
768 "defaultThinkingLevel": "high",
769 "theme": "dark",
770 "hideThinkingBlock": true,
771 "quietStartup": true,
772 "showTerminalProgress": false,
773 "editorPaddingX": 3,
774 "autocompleteMaxVisible": 7,
775 "compaction": { "threshold": 100 }
776 }"#,
777 )
778 .unwrap();
779 let s = load_settings().unwrap();
780 assert_eq!(s.default_provider.as_deref(), Some("anthropic"));
781 assert_eq!(s.default_model.as_deref(), Some("claude-sonnet-5"));
782 assert_eq!(s.default_thinking_level.as_deref(), Some("high"));
783 assert_eq!(s.theme.as_deref(), Some("dark"));
784 assert_eq!(s.hide_thinking_block, Some(true));
785 assert_eq!(s.quiet_startup, Some(true));
786 assert_eq!(s.show_terminal_progress, Some(false));
787 assert_eq!(s.editor_padding_x, Some(3));
788 assert_eq!(s.autocomplete_max_visible, Some(7));
789 }
790
791 #[test]
792 fn project_settings_only_use_rpi() {
793 let tmp = tempfile::tempdir().unwrap();
794 std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
795 std::fs::write(
796 tmp.path().join(".rpi/settings.json"),
797 r#"{"skillDirs":["rpi-skills"],"extensions":["rpi-ext"]}"#,
798 )
799 .unwrap();
800
801 let settings = load_project_settings(tmp.path());
802 assert_eq!(settings.len(), 1);
803 assert_eq!(
804 settings[0].skill_dirs.as_deref(),
805 Some(["rpi-skills".to_string()].as_slice())
806 );
807 assert_eq!(
808 settings[0].extension_dirs.as_deref(),
809 Some(["rpi-ext".to_string()].as_slice())
810 );
811 }
812
813 #[test]
814 fn malformed_rpi_project_settings_are_not_read() {
815 let tmp = tempfile::tempdir().unwrap();
816 std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
817 std::fs::write(tmp.path().join(".rpi/settings.json"), "{ malformed").unwrap();
818
819 assert!(load_project_settings_with_paths(tmp.path()).is_empty());
820 assert!(load_active_project_settings(tmp.path()).is_err());
821 }
822
823 #[test]
824 fn trusted_project_model_defaults_override_global_defaults() {
825 let _cfg = TempConfig::new();
826 let global = config::settings_path().unwrap();
827 std::fs::create_dir_all(global.parent().unwrap()).unwrap();
828 std::fs::write(
829 global,
830 r#"{"defaultProvider":"global","defaultModel":"global-model","theme":"dark"}"#,
831 )
832 .unwrap();
833 let project = tempfile::tempdir().unwrap();
834 std::fs::create_dir_all(project.path().join(".rpi")).unwrap();
835 std::fs::write(
836 project.path().join(".rpi/settings.json"),
837 r#"{"defaultProvider":"project","defaultModel":"project-model"}"#,
838 )
839 .unwrap();
840
841 let trusted = load_effective_model_settings(project.path(), true).unwrap();
842 assert_eq!(trusted.default_provider.as_deref(), Some("project"));
843 assert_eq!(trusted.default_model.as_deref(), Some("project-model"));
844 assert_eq!(trusted.theme.as_deref(), Some("dark"));
845
846 let untrusted = load_effective_model_settings(project.path(), false).unwrap();
847 assert_eq!(untrusted.default_provider.as_deref(), Some("global"));
848 assert_eq!(untrusted.default_model.as_deref(), Some("global-model"));
849 }
850
851 #[test]
852 fn tolerates_line_comments() {
853 let _cfg = TempConfig::new();
854 let path = config::settings_path().unwrap();
855 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
856 std::fs::write(
857 &path,
858 "{\n // my default\n \"defaultModel\": \"glm-5\",\n \"theme\": \"light\"\n}\n",
859 )
860 .unwrap();
861 let s = load_settings().unwrap();
862 assert_eq!(s.default_model.as_deref(), Some("glm-5"));
863 assert_eq!(s.theme.as_deref(), Some("light"));
864 }
865
866 #[test]
867 fn malformed_is_error() {
868 let _cfg = TempConfig::new();
869 let path = config::settings_path().unwrap();
870 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
871 std::fs::write(&path, "{ not json").unwrap();
872 assert!(matches!(load_settings(), Err(ConfigError::Json { .. })));
873 }
874}
875
876#[cfg(test)]
877mod scoped_tests {
878 use super::*;
879 use crate::config::test_support::env_lock;
880
881 fn with_temp_env() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
882 let guard = env_lock().lock().unwrap();
883 let tmp = tempfile::TempDir::new().unwrap();
884 std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
885 (tmp, guard)
886 }
887
888 #[test]
899 fn session_dir_setting_selects_the_session_directory() {
900 let settings: Settings =
901 serde_json::from_str(r#"{"sessionDir": "/tmp/from-settings"}"#).unwrap();
902 assert_eq!(
903 settings.session_dir,
904 Some(std::path::PathBuf::from("/tmp/from-settings"))
905 );
906 assert_eq!(Settings::default().session_dir, None);
908 }
909
910 #[test]
912 fn shell_command_prefix_reaches_the_bash_tool() {
913 let settings: Settings =
914 serde_json::from_str(r#"{"shellCommandPrefix": "shopt -s expand_aliases"}"#).unwrap();
915 assert_eq!(
916 settings.shell_command_prefix.as_deref(),
917 Some("shopt -s expand_aliases")
918 );
919 let with_shell: Settings =
922 serde_json::from_str(r#"{"shellPath": "/usr/bin/zsh"}"#).unwrap();
923 assert_eq!(with_shell.shell_path.as_deref(), Some("/usr/bin/zsh"));
924 }
925
926 #[test]
928 fn http_proxy_setting_is_read() {
929 let settings: Settings =
930 serde_json::from_str(r#"{"httpProxy": "http://127.0.0.1:8888"}"#).unwrap();
931 assert_eq!(
932 settings.http_proxy.as_deref(),
933 Some("http://127.0.0.1:8888")
934 );
935 assert_eq!(Settings::default().http_proxy, None);
936 }
937 #[test]
938 fn harness_options_come_from_settings() {
939 let settings: Settings = serde_json::from_str(
940 r#"{
941 "retry": { "enabled": false, "maxRetries": 7, "baseDelayMs": 250, "maxAgentDelayMs": 9000 },
942 "compaction": { "enabled": false, "reserveTokens": 1234, "keepRecentTokens": 4321 },
943 "steeringMode": "all",
944 "followUpMode": "all"
945 }"#,
946 )
947 .expect("settings parse");
948
949 let retry = settings.retry_policy();
950 assert!(!retry.enabled);
951 assert_eq!(retry.max_retries, 7);
952 assert_eq!(retry.base_delay_ms, 250);
953 assert_eq!(retry.max_agent_delay_ms, 9000);
954
955 let compaction = settings.compaction_settings();
956 assert!(!compaction.enabled);
957 assert_eq!(compaction.reserve_tokens, 1234);
958 assert_eq!(compaction.keep_recent_tokens, 4321);
959
960 assert_eq!(settings.steering_mode(), rpi_agent::QueueMode::All);
961 assert_eq!(settings.follow_up_mode(), rpi_agent::QueueMode::All);
962 }
963
964 #[test]
967 fn absent_harness_settings_keep_the_harness_defaults() {
968 let empty = Settings::default();
969 let default_retry = rpi_harness::types::RetryPolicy::default();
970 let retry = empty.retry_policy();
971 assert_eq!(retry.enabled, default_retry.enabled);
972 assert_eq!(retry.max_retries, default_retry.max_retries);
973 assert_eq!(retry.base_delay_ms, default_retry.base_delay_ms);
974 assert_eq!(retry.max_agent_delay_ms, default_retry.max_agent_delay_ms);
975 let default_compaction = rpi_harness::types::CompactionSettings::default();
976 let compaction = empty.compaction_settings();
977 assert_eq!(compaction.enabled, default_compaction.enabled);
978 assert_eq!(compaction.reserve_tokens, default_compaction.reserve_tokens);
979 assert_eq!(
980 compaction.keep_recent_tokens,
981 default_compaction.keep_recent_tokens
982 );
983
984 assert_eq!(empty.steering_mode(), rpi_agent::QueueMode::OneAtATime);
986 assert_eq!(empty.follow_up_mode(), rpi_agent::QueueMode::OneAtATime);
987
988 let partial: Settings = serde_json::from_str(r#"{"retry":{"maxRetries":9}}"#).unwrap();
990 let retry = partial.retry_policy();
991 assert_eq!(retry.max_retries, 9);
992 assert_eq!(retry.base_delay_ms, default_retry.base_delay_ms);
993 }
994
995 #[test]
1001 fn native_settings_key_names_are_accepted() {
1002 let settings: Settings = serde_json::from_str(
1003 r#"{
1004 "enabledModels": ["a", "b"],
1005 "httpIdleTimeoutMs": 1500,
1006 "branchSummary": { "reserveTokens": 4242, "skipPrompt": true }
1007 }"#,
1008 )
1009 .expect("native key names parse");
1010
1011 assert_eq!(
1012 settings.scoped_models,
1013 Some(vec!["a".to_string(), "b".to_string()]),
1014 "native's `enabledModels` must map onto rpi's scoped models"
1015 );
1016 assert_eq!(
1017 settings.http_idle_timeout_ms(),
1018 Some(1500),
1019 "native's `httpIdleTimeoutMs` must be read"
1020 );
1021 assert_eq!(settings.branch_summary_reserve_tokens(), Some(4242));
1022 assert!(settings.branch_summary_skipped());
1023 }
1024 #[test]
1025 fn save_load_scoped_models_roundtrip() {
1026 let (_tmp, _guard) = with_temp_env();
1027 let mut s = Settings::default();
1028 s.scoped_models = Some(vec!["a".into(), "b".into()]);
1029 save_settings(&s).unwrap();
1030 let loaded = load_settings().unwrap();
1031 assert_eq!(
1032 loaded.scoped_models,
1033 Some(vec!["a".to_string(), "b".to_string()])
1034 );
1035 let mut s2 = load_settings().unwrap();
1037 s2.scoped_models = None;
1038 save_settings(&s2).unwrap();
1039 assert_eq!(load_settings().unwrap().scoped_models, None);
1040 }
1041
1042 #[test]
1043 fn changelog_settings_roundtrip_without_losing_existing_configuration() {
1044 let (_tmp, _guard) = with_temp_env();
1045 let path = config::settings_path().unwrap();
1046 std::fs::write(&path, r#"{"defaultModel":"existing-model","lastChangelogVersion":"0.3.16","collapseChangelog":true,"custom":{"retained":true}}"#).unwrap();
1047 let mut settings = load_settings().unwrap();
1048 assert_eq!(settings.last_changelog_version.as_deref(), Some("0.3.16"));
1049 assert_eq!(settings.collapse_changelog, Some(true));
1050 settings.last_changelog_version = Some("0.3.17".into());
1051 save_settings(&settings).unwrap();
1052 let loaded = load_settings().unwrap();
1053 assert_eq!(loaded.last_changelog_version.as_deref(), Some("0.3.17"));
1054 assert_eq!(loaded.default_model.as_deref(), Some("existing-model"));
1055 assert_eq!(loaded.collapse_changelog, Some(true));
1056 let value: serde_json::Value =
1057 serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
1058 assert_eq!(value["custom"]["retained"], true);
1059 }
1060
1061 #[test]
1062 fn rpi_changelog_marker_preserves_foreign_baselines_and_existing_settings() {
1063 let (_tmp, _guard) = with_temp_env();
1064 let path = config::settings_path().unwrap();
1065 std::fs::write(
1066 &path,
1067 r#"{"lastChangelogVersion":"0.85.1","theme":"dark","custom":{"retained":true}}"#,
1068 )
1069 .unwrap();
1070 let mut settings = load_settings().unwrap();
1071 settings.last_rpi_changelog_version = Some("0.3.19".into());
1072 save_settings(&settings).unwrap();
1073 let loaded = load_settings().unwrap();
1074 assert_eq!(loaded.last_rpi_changelog_version.as_deref(), Some("0.3.19"));
1075 assert_eq!(loaded.last_changelog_version.as_deref(), Some("0.85.1"));
1076 let value: serde_json::Value =
1077 serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
1078 assert_eq!(value["lastRpiChangelogVersion"], "0.3.19");
1079 assert_eq!(value["lastChangelogVersion"], "0.85.1");
1080 assert_eq!(value["theme"], "dark");
1081 assert_eq!(value["custom"]["retained"], true);
1082 }
1083
1084 #[test]
1085 fn save_uses_atomic_sibling_replacement() {
1086 let (_tmp, _guard) = with_temp_env();
1087 let path = config::settings_path().unwrap();
1088 std::fs::write(&path, r#"{"theme":"dark","piOnlyField":true}"#).unwrap();
1089
1090 let mut settings = load_settings().unwrap();
1091 settings.theme = Some("light".into());
1092 save_settings(&settings).unwrap();
1093
1094 let saved: serde_json::Value =
1095 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1096 assert_eq!(saved["theme"], "light");
1097 assert_eq!(saved["piOnlyField"], true);
1098 let temp = path.with_file_name(format!(
1101 ".{}.tmp",
1102 path.file_name().and_then(|name| name.to_str()).unwrap()
1103 ));
1104 assert!(!temp.exists(), "atomic staging file should not remain");
1105 }
1106
1107 #[test]
1108 fn save_preserves_unknown_fields_and_writes_the_tui_settings() {
1109 let (_tmp, _guard) = with_temp_env();
1114 let path = config::settings_path().unwrap();
1115 let mut settings = Settings::default();
1116 settings.show_images = Some(false);
1117 settings.show_cache_miss_notices = Some(true);
1118 settings.output_pad = Some(0);
1119 settings.fullscreen_copy_on_select = Some(false);
1120 settings.http_idle_timeout = Some(serde_json::json!(30_000));
1121 settings.show_terminal_progress = Some(false);
1122 save_settings(&settings).unwrap();
1123
1124 let raw: serde_json::Value =
1125 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1126 assert_eq!(raw["showImages"], false);
1127 assert_eq!(raw["showCacheMissNotices"], true);
1128 assert_eq!(raw["outputPad"], 0);
1129 assert_eq!(raw["fullscreenCopyOnSelect"], false);
1130 assert_eq!(raw["httpIdleTimeout"], 30_000);
1131 assert_eq!(raw["showTerminalProgress"], false);
1132
1133 let reloaded = load_settings().unwrap();
1135 assert_eq!(reloaded.show_images(), Some(false));
1136 assert_eq!(reloaded.show_cache_miss_notices, Some(true));
1137 assert_eq!(reloaded.output_pad, Some(0));
1138 assert_eq!(reloaded.fullscreen_copy_on_select, Some(false));
1139 assert_eq!(reloaded.http_idle_timeout_ms(), Some(30_000));
1140 assert_eq!(reloaded.show_terminal_progress(), Some(false));
1141 }
1142
1143 #[test]
1144 fn nested_terminal_block_is_accepted_as_a_fallback() {
1145 let (_tmp, _guard) = with_temp_env();
1148 let path = config::settings_path().unwrap();
1149 std::fs::write(
1150 &path,
1151 r#"{ "terminal": { "showImages": false, "showTerminalProgress": false } }"#,
1152 )
1153 .unwrap();
1154 let settings = load_settings().unwrap();
1155 assert_eq!(settings.show_images(), Some(false));
1156 assert_eq!(settings.show_terminal_progress(), Some(false));
1157
1158 std::fs::write(
1159 &path,
1160 r#"{
1161 "showImages": true,
1162 "terminal": { "showImages": false }
1163 }"#,
1164 )
1165 .unwrap();
1166 let settings = load_settings().unwrap();
1167 assert_eq!(settings.show_images(), Some(true));
1168 }
1169
1170 #[test]
1171 fn save_preserves_unknown_fields() {
1172 let (_tmp, _guard) = with_temp_env();
1173 let path = config::settings_path().unwrap();
1174 std::fs::write(
1175 &path,
1176 r#"{
1177 "piOnlyField": "keep-me",
1178 "theme": "dark"
1179 }"#,
1180 )
1181 .unwrap();
1182 let mut s = load_settings().unwrap();
1183 s.scoped_models = Some(vec!["m1".into()]);
1184 save_settings(&s).unwrap();
1185 let raw: serde_json::Value =
1186 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1187 assert_eq!(raw["piOnlyField"], "keep-me");
1188 assert_eq!(raw["scopedModels"][0], "m1");
1189 assert_eq!(raw["theme"], "dark");
1190 }
1191
1192 #[test]
1193 fn thinking_budgets_are_read_from_settings() {
1194 let settings: Settings =
1195 serde_json::from_str(r#"{"thinkingBudgets":{"medium":6000,"high":24576}}"#).unwrap();
1196 let budgets = settings.thinking_budgets.unwrap();
1197 assert_eq!(
1198 (budgets.medium, budgets.high, budgets.low),
1199 (Some(6000), Some(24576), None)
1200 );
1201 }
1202
1203 #[test]
1204 fn stream_idle_timeout_settings_round_trip_including_disabled() {
1205 let (_tmp, _guard) = with_temp_env();
1206 for seconds in [45, 0] {
1207 let mut settings = load_settings().unwrap();
1208 settings.stream_idle_timeout = Some(seconds);
1209 save_settings(&settings).unwrap();
1210 assert_eq!(load_settings().unwrap().stream_idle_timeout, Some(seconds));
1211 }
1212 }
1213
1214 #[test]
1215 fn save_preserves_unknown_fields_and_packages_with_line_comments() {
1216 let (_tmp, _guard) = with_temp_env();
1217 let path = config::settings_path().unwrap();
1218 let original = r#"{
1219 // upstream permits comments in settings files.
1220 "piOnlyField": { "keep": true },
1221 "theme": "dark"
1222 }
1223 "#;
1224 std::fs::write(&path, original).unwrap();
1225
1226 let mut settings = load_settings().unwrap();
1227 settings.scoped_models = Some(vec!["m1".into()]);
1228 save_settings(&settings).unwrap();
1229
1230 let raw: serde_json::Value =
1233 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1234 assert_eq!(raw["piOnlyField"]["keep"], true);
1235 assert_eq!(raw["theme"], "dark");
1236 assert_eq!(raw["scopedModels"][0], "m1");
1237 }
1238
1239 #[test]
1240 fn save_fails_closed_for_unparseable_existing_settings() {
1241 let (_tmp, _guard) = with_temp_env();
1242 let path = config::settings_path().unwrap();
1243 let original = b"{\n // keep this file intact\n \"piOnlyField\": \"keep-me\"\n";
1246 std::fs::write(&path, original).unwrap();
1247
1248 let mut settings = Settings::default();
1249 settings.theme = Some("light".into());
1250 let error =
1251 save_settings(&settings).expect_err("malformed settings must not be overwritten");
1252 assert!(error.contains("cannot parse existing settings file"));
1253 assert_eq!(std::fs::read(&path).unwrap(), original);
1254 }
1255}