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