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