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 cdn_base_url: Option<String>,
134
135 #[serde(default)]
138 pub retry: Option<RetrySettings>,
139
140 #[serde(default)]
143 pub compaction: Option<CompactionSettingsJson>,
144
145 #[serde(default)]
153 pub branch_summary: Option<BranchSummarySettings>,
154
155 #[serde(default)]
158 pub steering_mode: Option<QueueModeSetting>,
159
160 #[serde(default)]
163 pub follow_up_mode: Option<QueueModeSetting>,
164 #[serde(default, alias = "skills")]
168 pub skill_dirs: Option<Vec<String>>,
169 #[serde(default, alias = "prompts")]
172 pub prompt_dirs: Option<Vec<String>>,
173 #[serde(default, alias = "extensions")]
176 pub extension_dirs: Option<Vec<String>>,
177 #[serde(default)]
180 pub keybindings: Option<HashMap<String, serde_json::Value>>,
181 #[serde(default)]
184 pub double_escape_action: Option<String>,
185 #[serde(default)]
188 pub default_tools: Option<Vec<String>>,
189 #[serde(default)]
191 pub hide_thinking_block: Option<bool>,
192 #[serde(default)]
195 pub enable_skill_commands: Option<bool>,
196 #[serde(default)]
199 pub quiet_startup: Option<bool>,
200 #[serde(default)]
203 pub last_changelog_version: Option<String>,
204 #[serde(default)]
206 pub last_rpi_changelog_version: Option<String>,
207 #[serde(default)]
209 pub collapse_changelog: Option<bool>,
210 #[serde(default)]
212 pub show_terminal_progress: Option<bool>,
213 #[serde(default)]
215 pub editor_padding_x: Option<usize>,
216 #[serde(default)]
218 pub autocomplete_max_visible: Option<usize>,
219 #[serde(default)]
222 pub fullscreen_copy_on_select: Option<bool>,
223 #[serde(default)]
227 pub output_pad: Option<usize>,
228 #[serde(default)]
231 pub show_cache_miss_notices: Option<bool>,
232 #[serde(default)]
236 pub show_images: Option<bool>,
237 #[serde(default)]
241 pub terminal: Option<TerminalSettings>,
242 #[serde(default, alias = "httpIdleTimeoutMs")]
246 pub http_idle_timeout: Option<serde_json::Value>,
247 #[serde(default)]
250 pub stream_idle_timeout: Option<u64>,
251 #[serde(default)]
255 pub thinking_budgets: Option<rpi_ai::ThinkingBudgets>,
256}
257
258#[derive(serde::Deserialize, Clone, Debug, Default)]
261#[serde(rename_all = "camelCase")]
262pub struct TerminalSettings {
263 #[serde(default)]
265 pub show_images: Option<bool>,
266 #[serde(default)]
268 pub show_terminal_progress: Option<bool>,
269 #[serde(default)]
271 pub clear_on_shrink: Option<bool>,
272}
273
274impl Settings {
275 pub fn http_idle_timeout_ms(&self) -> Option<i64> {
278 self.http_idle_timeout
279 .as_ref()
280 .and_then(rpi_ai::http::parse_http_idle_timeout_ms)
281 }
282
283 pub fn retry_policy(&self) -> rpi_harness::types::RetryPolicy {
286 let mut policy = rpi_harness::types::RetryPolicy::default();
287 let Some(retry) = &self.retry else {
288 return policy;
289 };
290 if let Some(enabled) = retry.enabled {
291 policy.enabled = enabled;
292 }
293 if let Some(max_retries) = retry.max_retries {
294 policy.max_retries = max_retries;
295 }
296 if let Some(base_delay_ms) = retry.base_delay_ms {
297 policy.base_delay_ms = base_delay_ms;
298 }
299 if let Some(max_agent_delay_ms) = retry.max_agent_delay_ms {
300 policy.max_agent_delay_ms = max_agent_delay_ms;
301 }
302 policy
303 }
304
305 pub fn compaction_settings(&self) -> rpi_harness::types::CompactionSettings {
307 let mut settings = rpi_harness::types::CompactionSettings::default();
308 let Some(compaction) = &self.compaction else {
309 return settings;
310 };
311 if let Some(enabled) = compaction.enabled {
312 settings.enabled = enabled;
313 }
314 if let Some(reserve_tokens) = compaction.reserve_tokens {
315 settings.reserve_tokens = reserve_tokens;
316 }
317 if let Some(keep_recent_tokens) = compaction.keep_recent_tokens {
318 settings.keep_recent_tokens = keep_recent_tokens;
319 }
320 settings
321 }
322
323 pub fn branch_summary_reserve_tokens(&self) -> Option<i64> {
329 self.branch_summary.as_ref().and_then(|s| s.reserve_tokens)
330 }
331
332 pub fn branch_summary_skipped(&self) -> bool {
337 self.branch_summary
338 .as_ref()
339 .and_then(|s| s.skip_prompt)
340 .unwrap_or(false)
341 }
342
343 pub fn steering_mode(&self) -> rpi_agent::QueueMode {
345 self.steering_mode
346 .map(Into::into)
347 .unwrap_or(rpi_agent::QueueMode::OneAtATime)
348 }
349
350 pub fn follow_up_mode(&self) -> rpi_agent::QueueMode {
352 self.follow_up_mode
353 .map(Into::into)
354 .unwrap_or(rpi_agent::QueueMode::OneAtATime)
355 }
356
357 pub fn show_images(&self) -> Option<bool> {
360 self.show_images
361 .or_else(|| self.terminal.as_ref().and_then(|t| t.show_images))
362 }
363
364 pub fn show_terminal_progress(&self) -> Option<bool> {
367 self.show_terminal_progress.or_else(|| {
368 self.terminal
369 .as_ref()
370 .and_then(|t| t.show_terminal_progress)
371 })
372 }
373}
374
375pub fn load_settings() -> Result<Settings, ConfigError> {
379 let path = config::settings_path()?;
380 match std::fs::read_to_string(&path) {
381 Ok(text) => parse_settings(&text).map_err(|e| ConfigError::Json {
382 path: path.clone(),
383 source: e,
384 }),
385 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Settings::default()),
386 Err(e) => Err(ConfigError::Read { path, source: e }),
387 }
388}
389
390pub fn load_project_settings(cwd: &Path) -> Vec<Settings> {
393 load_project_settings_with_paths(cwd)
394 .into_iter()
395 .map(|(_, settings)| settings)
396 .collect()
397}
398
399pub fn load_project_settings_with_paths(cwd: &Path) -> Vec<(PathBuf, Settings)> {
401 load_active_project_settings(cwd)
402 .ok()
403 .flatten()
404 .into_iter()
405 .collect()
406}
407
408pub fn load_active_project_settings(
411 cwd: &Path,
412) -> Result<Option<(PathBuf, Settings)>, ConfigError> {
413 let preferred = cwd.join(".rpi/settings.json");
414 match load_settings_file(&preferred) {
415 Ok(settings) => Ok(Some((preferred, settings))),
416 Err(ConfigError::Read { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => {
417 Ok(None)
418 }
419 Err(error) => Err(error),
420 }
421}
422
423pub fn load_effective_model_settings(
427 cwd: &Path,
428 project_trusted: bool,
429) -> Result<Settings, ConfigError> {
430 let mut effective = load_settings()?;
431 if project_trusted {
432 if let Some((_, project)) = load_active_project_settings(cwd)? {
433 if project.default_provider.is_some() {
434 effective.default_provider = project.default_provider;
435 }
436 if project.default_model.is_some() {
437 effective.default_model = project.default_model;
438 }
439 if project.default_thinking_level.is_some() {
440 effective.default_thinking_level = project.default_thinking_level;
441 }
442 if project.theme.is_some() {
443 effective.theme = project.theme;
444 }
445 }
446 }
447 Ok(effective)
448}
449
450pub fn load_project_settings_for_write(cwd: &Path) -> Result<Settings, ConfigError> {
452 Ok(load_active_project_settings(cwd)?
453 .map(|(_, settings)| settings)
454 .unwrap_or_default())
455}
456
457fn load_settings_file(path: &Path) -> Result<Settings, ConfigError> {
458 let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
459 path: path.to_path_buf(),
460 source,
461 })?;
462 parse_settings(&text).map_err(|source| ConfigError::Json {
463 path: path.to_path_buf(),
464 source,
465 })
466}
467
468pub fn resolve_configured_paths(base: &Path, values: &[String]) -> Vec<PathBuf> {
471 values
472 .iter()
473 .map(|value| value.trim())
474 .filter(|value| !value.is_empty())
475 .map(|value| {
476 let path = PathBuf::from(value);
477 if path.is_absolute() {
478 path
479 } else {
480 base.join(path)
481 }
482 })
483 .collect()
484}
485
486fn parse_settings(text: &str) -> Result<Settings, serde_json::Error> {
487 match serde_json::from_str(text) {
488 Ok(s) => Ok(s),
489 Err(first) => {
490 let stripped = strip_line_comments(text);
491 serde_json::from_str(&stripped).map_err(|_| first)
492 }
493 }
494}
495
496fn parse_settings_value(text: &str) -> Result<serde_json::Value, serde_json::Error> {
502 match serde_json::from_str::<serde_json::Value>(text) {
503 Ok(value) => Ok(value),
504 Err(first) => {
505 let stripped = strip_line_comments(text);
506 serde_json::from_str::<serde_json::Value>(&stripped).map_err(|_| first)
507 }
508 }
509}
510
511pub fn save_settings(settings: &Settings) -> Result<(), String> {
519 let path = config::settings_path().map_err(|e| e.to_string())?;
520 save_settings_to_path(settings, &path, None)
521}
522
523pub fn save_project_settings(cwd: &Path, settings: &Settings) -> Result<(), String> {
525 save_settings_to_path(settings, &cwd.join(".rpi/settings.json"), None)
526}
527
528fn save_settings_to_path(
529 settings: &Settings,
530 path: &Path,
531 fallback: Option<&Path>,
532) -> Result<(), String> {
533 let mut merged = match std::fs::read_to_string(path) {
535 Ok(text) => parse_settings_value(&text).map_err(|error| {
536 format!(
537 "cannot parse existing settings file {}: {error}",
538 path.display()
539 )
540 })?,
541 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
542 read_fallback_settings_value(path, fallback)?
543 .unwrap_or_else(|| serde_json::Value::Object(Default::default()))
544 }
545 Err(error) => {
546 return Err(format!(
547 "cannot read existing settings file {}: {error}",
548 path.display()
549 ));
550 }
551 };
552 let obj = merged
553 .as_object_mut()
554 .ok_or_else(|| format!("settings file {} is not an object", path.display()))?;
555 for (key, val) in [
556 ("defaultProvider", settings.default_provider.as_ref()),
557 ("defaultModel", settings.default_model.as_ref()),
558 (
559 "defaultThinkingLevel",
560 settings.default_thinking_level.as_ref(),
561 ),
562 ("theme", settings.theme.as_ref()),
563 ("cdnBaseUrl", settings.cdn_base_url.as_ref()),
564 (
565 "lastChangelogVersion",
566 settings.last_changelog_version.as_ref(),
567 ),
568 (
569 "lastRpiChangelogVersion",
570 settings.last_rpi_changelog_version.as_ref(),
571 ),
572 ] {
573 match val {
574 Some(v) => {
575 obj.insert(key.to_string(), serde_json::Value::String(v.clone()));
576 }
577 None => {
578 obj.remove(key);
579 }
580 }
581 }
582 match &settings.scoped_models {
583 Some(list) if !list.is_empty() => {
584 obj.insert(
585 "scopedModels".to_string(),
586 serde_json::Value::Array(
587 list.iter()
588 .map(|m| serde_json::Value::String(m.clone()))
589 .collect(),
590 ),
591 );
592 }
593 _ => {
594 obj.remove("scopedModels");
595 }
596 }
597 match &settings.keybindings {
598 Some(bindings) => {
599 obj.insert(
600 "keybindings".to_string(),
601 serde_json::to_value(bindings).map_err(|e| e.to_string())?,
602 );
603 }
604 None => {
605 obj.remove("keybindings");
606 }
607 }
608 match settings.double_escape_action.as_deref() {
609 Some(action) if !action.trim().is_empty() => {
610 obj.insert(
611 "doubleEscapeAction".to_string(),
612 serde_json::Value::String(action.to_string()),
613 );
614 }
615 _ => {
616 obj.remove("doubleEscapeAction");
617 }
618 }
619 for (key, value) in [
620 (
621 "hideThinkingBlock",
622 settings.hide_thinking_block.map(serde_json::Value::Bool),
623 ),
624 (
625 "enableSkillCommands",
626 settings.enable_skill_commands.map(serde_json::Value::Bool),
627 ),
628 (
629 "quietStartup",
630 settings.quiet_startup.map(serde_json::Value::Bool),
631 ),
632 (
633 "collapseChangelog",
634 settings.collapse_changelog.map(serde_json::Value::Bool),
635 ),
636 (
637 "showTerminalProgress",
638 settings.show_terminal_progress.map(serde_json::Value::Bool),
639 ),
640 (
641 "showImages",
642 settings.show_images.map(serde_json::Value::Bool),
643 ),
644 (
645 "showCacheMissNotices",
646 settings
647 .show_cache_miss_notices
648 .map(serde_json::Value::Bool),
649 ),
650 (
651 "fullscreenCopyOnSelect",
652 settings
653 .fullscreen_copy_on_select
654 .map(serde_json::Value::Bool),
655 ),
656 (
657 "editorPaddingX",
658 settings
659 .editor_padding_x
660 .map(|v| serde_json::Value::Number(v.into())),
661 ),
662 (
663 "outputPad",
664 settings
665 .output_pad
666 .map(|v| serde_json::Value::Number(v.into())),
667 ),
668 (
669 "autocompleteMaxVisible",
670 settings
671 .autocomplete_max_visible
672 .map(|v| serde_json::Value::Number(v.into())),
673 ),
674 ("httpIdleTimeout", settings.http_idle_timeout.clone()),
675 (
676 "streamIdleTimeout",
677 settings.stream_idle_timeout.map(serde_json::Value::from),
678 ),
679 ] {
680 match value {
681 Some(value) => {
682 obj.insert(key.to_string(), value);
683 }
684 None => {
685 obj.remove(key);
686 }
687 }
688 }
689 if let Some(parent) = path.parent() {
690 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
691 }
692 let text = serde_json::to_string_pretty(&merged).map_err(|e| e.to_string())?;
693 config::atomic_write(path, text.as_bytes()).map_err(|e| e.to_string())
694}
695
696fn read_fallback_settings_value(
700 target: &Path,
701 fallback: Option<&Path>,
702) -> Result<Option<serde_json::Value>, String> {
703 let Some(path) = fallback else {
704 return Ok(None);
705 };
706 if path == target {
707 return Ok(None);
708 }
709 match std::fs::read_to_string(path) {
710 Ok(text) => parse_settings_value(&text).map(Some).map_err(|error| {
711 format!(
712 "cannot parse fallback settings file {}: {error}",
713 path.display()
714 )
715 }),
716 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
717 Err(error) => Err(format!(
718 "cannot read fallback settings file {}: {error}",
719 path.display()
720 )),
721 }
722}
723
724#[cfg(test)]
725mod tests {
726 use super::*;
727 use crate::config::test_support::env_lock;
728
729 struct TempConfig {
731 _guard: std::sync::MutexGuard<'static, ()>,
732 _tmp: tempfile::TempDir,
733 prev: Option<std::ffi::OsString>,
734 }
735 impl TempConfig {
736 fn new() -> Self {
737 let guard = env_lock().lock().unwrap();
738 let prev = std::env::var_os(config::CONFIG_DIR_ENV);
739 let tmp = tempfile::TempDir::new().unwrap();
740 std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
741 Self {
742 _guard: guard,
743 _tmp: tmp,
744 prev,
745 }
746 }
747 }
748 impl Drop for TempConfig {
749 fn drop(&mut self) {
750 match self.prev.take() {
751 Some(v) => std::env::set_var(config::CONFIG_DIR_ENV, v),
752 None => std::env::remove_var(config::CONFIG_DIR_ENV),
753 }
754 }
755 }
756
757 #[test]
758 fn missing_settings_is_default() {
759 let _cfg = TempConfig::new();
760 let s = load_settings().unwrap();
761 assert!(s.default_provider.is_none());
762 assert!(s.default_model.is_none());
763 assert!(s.default_thinking_level.is_none());
764 assert!(s.theme.is_none());
765 assert!(s.skill_dirs.is_none());
766 assert!(s.prompt_dirs.is_none());
767 assert!(s.extension_dirs.is_none());
768 }
769
770 #[test]
771 fn reads_honored_fields_and_ignores_unknown() {
772 let _cfg = TempConfig::new();
773 let path = config::settings_path().unwrap();
774 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
775 std::fs::write(
777 &path,
778 r#"{
779 "lastChangelogVersion": "1.0.0",
780 "defaultProvider": "anthropic",
781 "defaultModel": "claude-sonnet-5",
782 "defaultThinkingLevel": "high",
783 "theme": "dark",
784 "hideThinkingBlock": true,
785 "quietStartup": true,
786 "showTerminalProgress": false,
787 "editorPaddingX": 3,
788 "autocompleteMaxVisible": 7,
789 "compaction": { "threshold": 100 }
790 }"#,
791 )
792 .unwrap();
793 let s = load_settings().unwrap();
794 assert_eq!(s.default_provider.as_deref(), Some("anthropic"));
795 assert_eq!(s.default_model.as_deref(), Some("claude-sonnet-5"));
796 assert_eq!(s.default_thinking_level.as_deref(), Some("high"));
797 assert_eq!(s.theme.as_deref(), Some("dark"));
798 assert_eq!(s.hide_thinking_block, Some(true));
799 assert_eq!(s.quiet_startup, Some(true));
800 assert_eq!(s.show_terminal_progress, Some(false));
801 assert_eq!(s.editor_padding_x, Some(3));
802 assert_eq!(s.autocomplete_max_visible, Some(7));
803 }
804
805 #[test]
806 fn project_settings_only_use_rpi() {
807 let tmp = tempfile::tempdir().unwrap();
808 std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
809 std::fs::write(
810 tmp.path().join(".rpi/settings.json"),
811 r#"{"skillDirs":["rpi-skills"],"extensions":["rpi-ext"]}"#,
812 )
813 .unwrap();
814
815 let settings = load_project_settings(tmp.path());
816 assert_eq!(settings.len(), 1);
817 assert_eq!(
818 settings[0].skill_dirs.as_deref(),
819 Some(["rpi-skills".to_string()].as_slice())
820 );
821 assert_eq!(
822 settings[0].extension_dirs.as_deref(),
823 Some(["rpi-ext".to_string()].as_slice())
824 );
825 }
826
827 #[test]
828 fn malformed_rpi_project_settings_are_not_read() {
829 let tmp = tempfile::tempdir().unwrap();
830 std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
831 std::fs::write(tmp.path().join(".rpi/settings.json"), "{ malformed").unwrap();
832
833 assert!(load_project_settings_with_paths(tmp.path()).is_empty());
834 assert!(load_active_project_settings(tmp.path()).is_err());
835 }
836
837 #[test]
838 fn trusted_project_model_defaults_override_global_defaults() {
839 let _cfg = TempConfig::new();
840 let global = config::settings_path().unwrap();
841 std::fs::create_dir_all(global.parent().unwrap()).unwrap();
842 std::fs::write(
843 global,
844 r#"{"defaultProvider":"global","defaultModel":"global-model","theme":"dark"}"#,
845 )
846 .unwrap();
847 let project = tempfile::tempdir().unwrap();
848 std::fs::create_dir_all(project.path().join(".rpi")).unwrap();
849 std::fs::write(
850 project.path().join(".rpi/settings.json"),
851 r#"{"defaultProvider":"project","defaultModel":"project-model"}"#,
852 )
853 .unwrap();
854
855 let trusted = load_effective_model_settings(project.path(), true).unwrap();
856 assert_eq!(trusted.default_provider.as_deref(), Some("project"));
857 assert_eq!(trusted.default_model.as_deref(), Some("project-model"));
858 assert_eq!(trusted.theme.as_deref(), Some("dark"));
859
860 let untrusted = load_effective_model_settings(project.path(), false).unwrap();
861 assert_eq!(untrusted.default_provider.as_deref(), Some("global"));
862 assert_eq!(untrusted.default_model.as_deref(), Some("global-model"));
863 }
864
865 #[test]
866 fn tolerates_line_comments() {
867 let _cfg = TempConfig::new();
868 let path = config::settings_path().unwrap();
869 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
870 std::fs::write(
871 &path,
872 "{\n // my default\n \"defaultModel\": \"glm-5\",\n \"theme\": \"light\"\n}\n",
873 )
874 .unwrap();
875 let s = load_settings().unwrap();
876 assert_eq!(s.default_model.as_deref(), Some("glm-5"));
877 assert_eq!(s.theme.as_deref(), Some("light"));
878 }
879
880 #[test]
881 fn malformed_is_error() {
882 let _cfg = TempConfig::new();
883 let path = config::settings_path().unwrap();
884 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
885 std::fs::write(&path, "{ not json").unwrap();
886 assert!(matches!(load_settings(), Err(ConfigError::Json { .. })));
887 }
888}
889
890#[cfg(test)]
891mod scoped_tests {
892 use super::*;
893 use crate::config::test_support::env_lock;
894
895 fn with_temp_env() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
896 let guard = env_lock().lock().unwrap();
897 let tmp = tempfile::TempDir::new().unwrap();
898 std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
899 (tmp, guard)
900 }
901
902 #[test]
913 fn session_dir_setting_selects_the_session_directory() {
914 let settings: Settings =
915 serde_json::from_str(r#"{"sessionDir": "/tmp/from-settings"}"#).unwrap();
916 assert_eq!(
917 settings.session_dir,
918 Some(std::path::PathBuf::from("/tmp/from-settings"))
919 );
920 assert_eq!(Settings::default().session_dir, None);
922 }
923
924 #[test]
926 fn shell_command_prefix_reaches_the_bash_tool() {
927 let settings: Settings =
928 serde_json::from_str(r#"{"shellCommandPrefix": "shopt -s expand_aliases"}"#).unwrap();
929 assert_eq!(
930 settings.shell_command_prefix.as_deref(),
931 Some("shopt -s expand_aliases")
932 );
933 let with_shell: Settings =
936 serde_json::from_str(r#"{"shellPath": "/usr/bin/zsh"}"#).unwrap();
937 assert_eq!(with_shell.shell_path.as_deref(), Some("/usr/bin/zsh"));
938 }
939
940 #[test]
942 fn http_proxy_setting_is_read() {
943 let settings: Settings =
944 serde_json::from_str(r#"{"httpProxy": "http://127.0.0.1:8888"}"#).unwrap();
945 assert_eq!(
946 settings.http_proxy.as_deref(),
947 Some("http://127.0.0.1:8888")
948 );
949 assert_eq!(Settings::default().http_proxy, None);
950 }
951
952 #[test]
953 fn cdn_base_url_setting_round_trips() {
954 let (_tmp, _guard) = with_temp_env();
955 let mut settings = Settings::default();
956 settings.cdn_base_url = Some("https://assets.bigfisharc.cn".into());
957 save_settings(&settings).unwrap();
958
959 let raw: serde_json::Value = serde_json::from_str(
960 &std::fs::read_to_string(config::settings_path().unwrap()).unwrap(),
961 )
962 .unwrap();
963 assert_eq!(raw["cdnBaseUrl"], "https://assets.bigfisharc.cn");
964 assert_eq!(
965 load_settings().unwrap().cdn_base_url.as_deref(),
966 Some("https://assets.bigfisharc.cn")
967 );
968
969 settings.cdn_base_url = None;
970 save_settings(&settings).unwrap();
971 assert!(load_settings().unwrap().cdn_base_url.is_none());
972 }
973 #[test]
974 fn harness_options_come_from_settings() {
975 let settings: Settings = serde_json::from_str(
976 r#"{
977 "retry": { "enabled": false, "maxRetries": 7, "baseDelayMs": 250, "maxAgentDelayMs": 9000 },
978 "compaction": { "enabled": false, "reserveTokens": 1234, "keepRecentTokens": 4321 },
979 "steeringMode": "all",
980 "followUpMode": "all"
981 }"#,
982 )
983 .expect("settings parse");
984
985 let retry = settings.retry_policy();
986 assert!(!retry.enabled);
987 assert_eq!(retry.max_retries, 7);
988 assert_eq!(retry.base_delay_ms, 250);
989 assert_eq!(retry.max_agent_delay_ms, 9000);
990
991 let compaction = settings.compaction_settings();
992 assert!(!compaction.enabled);
993 assert_eq!(compaction.reserve_tokens, 1234);
994 assert_eq!(compaction.keep_recent_tokens, 4321);
995
996 assert_eq!(settings.steering_mode(), rpi_agent::QueueMode::All);
997 assert_eq!(settings.follow_up_mode(), rpi_agent::QueueMode::All);
998 }
999
1000 #[test]
1003 fn absent_harness_settings_keep_the_harness_defaults() {
1004 let empty = Settings::default();
1005 let default_retry = rpi_harness::types::RetryPolicy::default();
1006 let retry = empty.retry_policy();
1007 assert_eq!(retry.enabled, default_retry.enabled);
1008 assert_eq!(retry.max_retries, default_retry.max_retries);
1009 assert_eq!(retry.base_delay_ms, default_retry.base_delay_ms);
1010 assert_eq!(retry.max_agent_delay_ms, default_retry.max_agent_delay_ms);
1011 let default_compaction = rpi_harness::types::CompactionSettings::default();
1012 let compaction = empty.compaction_settings();
1013 assert_eq!(compaction.enabled, default_compaction.enabled);
1014 assert_eq!(compaction.reserve_tokens, default_compaction.reserve_tokens);
1015 assert_eq!(
1016 compaction.keep_recent_tokens,
1017 default_compaction.keep_recent_tokens
1018 );
1019
1020 assert_eq!(empty.steering_mode(), rpi_agent::QueueMode::OneAtATime);
1022 assert_eq!(empty.follow_up_mode(), rpi_agent::QueueMode::OneAtATime);
1023
1024 let partial: Settings = serde_json::from_str(r#"{"retry":{"maxRetries":9}}"#).unwrap();
1026 let retry = partial.retry_policy();
1027 assert_eq!(retry.max_retries, 9);
1028 assert_eq!(retry.base_delay_ms, default_retry.base_delay_ms);
1029 }
1030
1031 #[test]
1037 fn native_settings_key_names_are_accepted() {
1038 let settings: Settings = serde_json::from_str(
1039 r#"{
1040 "enabledModels": ["a", "b"],
1041 "httpIdleTimeoutMs": 1500,
1042 "branchSummary": { "reserveTokens": 4242, "skipPrompt": true }
1043 }"#,
1044 )
1045 .expect("native key names parse");
1046
1047 assert_eq!(
1048 settings.scoped_models,
1049 Some(vec!["a".to_string(), "b".to_string()]),
1050 "native's `enabledModels` must map onto rpi's scoped models"
1051 );
1052 assert_eq!(
1053 settings.http_idle_timeout_ms(),
1054 Some(1500),
1055 "native's `httpIdleTimeoutMs` must be read"
1056 );
1057 assert_eq!(settings.branch_summary_reserve_tokens(), Some(4242));
1058 assert!(settings.branch_summary_skipped());
1059 }
1060 #[test]
1061 fn skill_command_discovery_setting_roundtrips_pi_key() {
1062 let (_tmp, _guard) = with_temp_env();
1063 let mut settings = Settings::default();
1064 for enabled in [Some(false), Some(true), None] {
1065 settings.enable_skill_commands = enabled;
1066 save_settings(&settings).unwrap();
1067 assert_eq!(load_settings().unwrap().enable_skill_commands, enabled);
1068 let raw: serde_json::Value = serde_json::from_str(
1069 &std::fs::read_to_string(config::settings_path().unwrap()).unwrap(),
1070 )
1071 .unwrap();
1072 assert_eq!(
1073 raw.get("enableSkillCommands"),
1074 enabled.map(serde_json::Value::Bool).as_ref()
1075 );
1076 }
1077 }
1078
1079 #[test]
1080 fn save_load_scoped_models_roundtrip() {
1081 let (_tmp, _guard) = with_temp_env();
1082 let mut s = Settings::default();
1083 s.scoped_models = Some(vec!["a".into(), "b".into()]);
1084 save_settings(&s).unwrap();
1085 let loaded = load_settings().unwrap();
1086 assert_eq!(
1087 loaded.scoped_models,
1088 Some(vec!["a".to_string(), "b".to_string()])
1089 );
1090 let mut s2 = load_settings().unwrap();
1092 s2.scoped_models = None;
1093 save_settings(&s2).unwrap();
1094 assert_eq!(load_settings().unwrap().scoped_models, None);
1095 }
1096
1097 #[test]
1098 fn changelog_settings_roundtrip_without_losing_existing_configuration() {
1099 let (_tmp, _guard) = with_temp_env();
1100 let path = config::settings_path().unwrap();
1101 std::fs::write(&path, r#"{"defaultModel":"existing-model","lastChangelogVersion":"0.3.16","collapseChangelog":true,"custom":{"retained":true}}"#).unwrap();
1102 let mut settings = load_settings().unwrap();
1103 assert_eq!(settings.last_changelog_version.as_deref(), Some("0.3.16"));
1104 assert_eq!(settings.collapse_changelog, Some(true));
1105 settings.last_changelog_version = Some("0.3.17".into());
1106 save_settings(&settings).unwrap();
1107 let loaded = load_settings().unwrap();
1108 assert_eq!(loaded.last_changelog_version.as_deref(), Some("0.3.17"));
1109 assert_eq!(loaded.default_model.as_deref(), Some("existing-model"));
1110 assert_eq!(loaded.collapse_changelog, Some(true));
1111 let value: serde_json::Value =
1112 serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
1113 assert_eq!(value["custom"]["retained"], true);
1114 }
1115
1116 #[test]
1117 fn rpi_changelog_marker_preserves_foreign_baselines_and_existing_settings() {
1118 let (_tmp, _guard) = with_temp_env();
1119 let path = config::settings_path().unwrap();
1120 std::fs::write(
1121 &path,
1122 r#"{"lastChangelogVersion":"0.85.1","theme":"dark","custom":{"retained":true}}"#,
1123 )
1124 .unwrap();
1125 let mut settings = load_settings().unwrap();
1126 settings.last_rpi_changelog_version = Some("0.3.19".into());
1127 save_settings(&settings).unwrap();
1128 let loaded = load_settings().unwrap();
1129 assert_eq!(loaded.last_rpi_changelog_version.as_deref(), Some("0.3.19"));
1130 assert_eq!(loaded.last_changelog_version.as_deref(), Some("0.85.1"));
1131 let value: serde_json::Value =
1132 serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
1133 assert_eq!(value["lastRpiChangelogVersion"], "0.3.19");
1134 assert_eq!(value["lastChangelogVersion"], "0.85.1");
1135 assert_eq!(value["theme"], "dark");
1136 assert_eq!(value["custom"]["retained"], true);
1137 }
1138
1139 #[test]
1140 fn save_uses_atomic_sibling_replacement() {
1141 let (_tmp, _guard) = with_temp_env();
1142 let path = config::settings_path().unwrap();
1143 std::fs::write(&path, r#"{"theme":"dark","piOnlyField":true}"#).unwrap();
1144
1145 let mut settings = load_settings().unwrap();
1146 settings.theme = Some("light".into());
1147 save_settings(&settings).unwrap();
1148
1149 let saved: serde_json::Value =
1150 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1151 assert_eq!(saved["theme"], "light");
1152 assert_eq!(saved["piOnlyField"], true);
1153 let temp = path.with_file_name(format!(
1156 ".{}.tmp",
1157 path.file_name().and_then(|name| name.to_str()).unwrap()
1158 ));
1159 assert!(!temp.exists(), "atomic staging file should not remain");
1160 }
1161
1162 #[test]
1163 fn save_preserves_unknown_fields_and_writes_the_tui_settings() {
1164 let (_tmp, _guard) = with_temp_env();
1169 let path = config::settings_path().unwrap();
1170 let mut settings = Settings::default();
1171 settings.show_images = Some(false);
1172 settings.show_cache_miss_notices = Some(true);
1173 settings.output_pad = Some(0);
1174 settings.fullscreen_copy_on_select = Some(false);
1175 settings.http_idle_timeout = Some(serde_json::json!(30_000));
1176 settings.show_terminal_progress = Some(false);
1177 save_settings(&settings).unwrap();
1178
1179 let raw: serde_json::Value =
1180 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1181 assert_eq!(raw["showImages"], false);
1182 assert_eq!(raw["showCacheMissNotices"], true);
1183 assert_eq!(raw["outputPad"], 0);
1184 assert_eq!(raw["fullscreenCopyOnSelect"], false);
1185 assert_eq!(raw["httpIdleTimeout"], 30_000);
1186 assert_eq!(raw["showTerminalProgress"], false);
1187
1188 let reloaded = load_settings().unwrap();
1190 assert_eq!(reloaded.show_images(), Some(false));
1191 assert_eq!(reloaded.show_cache_miss_notices, Some(true));
1192 assert_eq!(reloaded.output_pad, Some(0));
1193 assert_eq!(reloaded.fullscreen_copy_on_select, Some(false));
1194 assert_eq!(reloaded.http_idle_timeout_ms(), Some(30_000));
1195 assert_eq!(reloaded.show_terminal_progress(), Some(false));
1196 }
1197
1198 #[test]
1199 fn nested_terminal_block_is_accepted_as_a_fallback() {
1200 let (_tmp, _guard) = with_temp_env();
1203 let path = config::settings_path().unwrap();
1204 std::fs::write(
1205 &path,
1206 r#"{ "terminal": { "showImages": false, "showTerminalProgress": false } }"#,
1207 )
1208 .unwrap();
1209 let settings = load_settings().unwrap();
1210 assert_eq!(settings.show_images(), Some(false));
1211 assert_eq!(settings.show_terminal_progress(), Some(false));
1212
1213 std::fs::write(
1214 &path,
1215 r#"{
1216 "showImages": true,
1217 "terminal": { "showImages": false }
1218 }"#,
1219 )
1220 .unwrap();
1221 let settings = load_settings().unwrap();
1222 assert_eq!(settings.show_images(), Some(true));
1223 }
1224
1225 #[test]
1226 fn save_preserves_unknown_fields() {
1227 let (_tmp, _guard) = with_temp_env();
1228 let path = config::settings_path().unwrap();
1229 std::fs::write(
1230 &path,
1231 r#"{
1232 "piOnlyField": "keep-me",
1233 "theme": "dark"
1234 }"#,
1235 )
1236 .unwrap();
1237 let mut s = load_settings().unwrap();
1238 s.scoped_models = Some(vec!["m1".into()]);
1239 save_settings(&s).unwrap();
1240 let raw: serde_json::Value =
1241 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1242 assert_eq!(raw["piOnlyField"], "keep-me");
1243 assert_eq!(raw["scopedModels"][0], "m1");
1244 assert_eq!(raw["theme"], "dark");
1245 }
1246
1247 #[test]
1248 fn thinking_budgets_are_read_from_settings() {
1249 let settings: Settings =
1250 serde_json::from_str(r#"{"thinkingBudgets":{"medium":6000,"high":24576}}"#).unwrap();
1251 let budgets = settings.thinking_budgets.unwrap();
1252 assert_eq!(
1253 (budgets.medium, budgets.high, budgets.low),
1254 (Some(6000), Some(24576), None)
1255 );
1256 }
1257
1258 #[test]
1259 fn stream_idle_timeout_settings_round_trip_including_disabled() {
1260 let (_tmp, _guard) = with_temp_env();
1261 for seconds in [45, 0] {
1262 let mut settings = load_settings().unwrap();
1263 settings.stream_idle_timeout = Some(seconds);
1264 save_settings(&settings).unwrap();
1265 assert_eq!(load_settings().unwrap().stream_idle_timeout, Some(seconds));
1266 }
1267 }
1268
1269 #[test]
1270 fn save_preserves_unknown_fields_and_packages_with_line_comments() {
1271 let (_tmp, _guard) = with_temp_env();
1272 let path = config::settings_path().unwrap();
1273 let original = r#"{
1274 // upstream permits comments in settings files.
1275 "piOnlyField": { "keep": true },
1276 "theme": "dark"
1277 }
1278 "#;
1279 std::fs::write(&path, original).unwrap();
1280
1281 let mut settings = load_settings().unwrap();
1282 settings.scoped_models = Some(vec!["m1".into()]);
1283 save_settings(&settings).unwrap();
1284
1285 let raw: serde_json::Value =
1288 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1289 assert_eq!(raw["piOnlyField"]["keep"], true);
1290 assert_eq!(raw["theme"], "dark");
1291 assert_eq!(raw["scopedModels"][0], "m1");
1292 }
1293
1294 #[test]
1295 fn save_fails_closed_for_unparseable_existing_settings() {
1296 let (_tmp, _guard) = with_temp_env();
1297 let path = config::settings_path().unwrap();
1298 let original = b"{\n // keep this file intact\n \"piOnlyField\": \"keep-me\"\n";
1301 std::fs::write(&path, original).unwrap();
1302
1303 let mut settings = Settings::default();
1304 settings.theme = Some("light".into());
1305 let error =
1306 save_settings(&settings).expect_err("malformed settings must not be overwritten");
1307 assert!(error.contains("cannot parse existing settings file"));
1308 assert_eq!(std::fs::read(&path).unwrap(), original);
1309 }
1310}