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