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