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