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::Serialize, serde::Deserialize, Clone, Debug, PartialEq, Eq)]
87#[serde(untagged)]
88pub enum PackageSetting {
89 Source(String),
90 Filtered(PackageFilter),
91}
92
93impl PackageSetting {
94 pub fn source(&self) -> &str {
96 match self {
97 Self::Source(source) => source,
98 Self::Filtered(filter) => &filter.source,
99 }
100 }
101}
102
103impl From<String> for PackageSetting {
104 fn from(source: String) -> Self {
105 Self::Source(source)
106 }
107}
108
109impl From<&str> for PackageSetting {
110 fn from(source: &str) -> Self {
111 Self::Source(source.to_string())
112 }
113}
114
115#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq, Eq)]
120#[serde(rename_all = "camelCase")]
121pub struct PackageFilter {
122 pub source: String,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub autoload: Option<bool>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub extensions: Option<Vec<String>>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub skills: Option<Vec<String>>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub prompts: Option<Vec<String>>,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub themes: Option<Vec<String>>,
133 #[serde(flatten)]
134 pub unknown: serde_json::Map<String, serde_json::Value>,
135}
136
137#[derive(serde::Deserialize, Default, Clone, Debug)]
139#[serde(rename_all = "camelCase")]
140pub struct Settings {
141 #[serde(default)]
144 pub default_provider: Option<String>,
145 #[serde(default)]
148 pub default_model: Option<String>,
149 #[serde(default)]
152 pub default_thinking_level: Option<String>,
153 #[serde(default)]
155 pub theme: Option<String>,
156 #[serde(default, alias = "enabledModels")]
161 pub scoped_models: Option<Vec<String>>,
162
163 #[serde(default)]
166 pub session_dir: Option<PathBuf>,
167
168 #[serde(default)]
173 pub shell_path: Option<String>,
174
175 #[serde(default)]
178 pub shell_command_prefix: Option<String>,
179
180 #[serde(default)]
183 pub http_proxy: Option<String>,
184
185 #[serde(default)]
188 pub retry: Option<RetrySettings>,
189
190 #[serde(default)]
193 pub compaction: Option<CompactionSettingsJson>,
194
195 #[serde(default)]
203 pub branch_summary: Option<BranchSummarySettings>,
204
205 #[serde(default)]
208 pub steering_mode: Option<QueueModeSetting>,
209
210 #[serde(default)]
213 pub follow_up_mode: Option<QueueModeSetting>,
214 #[serde(default)]
218 pub packages: Option<Vec<PackageSetting>>,
219 #[serde(default)]
222 pub npm_command: Option<Vec<String>>,
223 #[serde(default, alias = "skills")]
227 pub skill_dirs: Option<Vec<String>>,
228 #[serde(default, alias = "prompts")]
231 pub prompt_dirs: Option<Vec<String>>,
232 #[serde(default, alias = "extensions")]
235 pub extension_dirs: Option<Vec<String>>,
236 #[serde(default)]
239 pub keybindings: Option<HashMap<String, serde_json::Value>>,
240 #[serde(default)]
243 pub double_escape_action: Option<String>,
244 #[serde(default)]
247 pub default_tools: Option<Vec<String>>,
248 #[serde(default)]
250 pub hide_thinking_block: Option<bool>,
251 #[serde(default)]
254 pub quiet_startup: Option<bool>,
255 #[serde(default)]
257 pub show_terminal_progress: Option<bool>,
258 #[serde(default)]
260 pub editor_padding_x: Option<usize>,
261 #[serde(default)]
263 pub autocomplete_max_visible: Option<usize>,
264 #[serde(default)]
267 pub fullscreen_copy_on_select: Option<bool>,
268 #[serde(default)]
272 pub output_pad: Option<usize>,
273 #[serde(default)]
276 pub show_cache_miss_notices: Option<bool>,
277 #[serde(default)]
281 pub show_images: Option<bool>,
282 #[serde(default)]
286 pub terminal: Option<TerminalSettings>,
287 #[serde(default, alias = "httpIdleTimeoutMs")]
291 pub http_idle_timeout: Option<serde_json::Value>,
292}
293
294#[derive(serde::Deserialize, Clone, Debug, Default)]
297#[serde(rename_all = "camelCase")]
298pub struct TerminalSettings {
299 #[serde(default)]
301 pub show_images: Option<bool>,
302 #[serde(default)]
304 pub show_terminal_progress: Option<bool>,
305 #[serde(default)]
307 pub clear_on_shrink: Option<bool>,
308}
309
310impl Settings {
311 pub fn http_idle_timeout_ms(&self) -> Option<i64> {
314 self.http_idle_timeout
315 .as_ref()
316 .and_then(rpi_ai::http::parse_http_idle_timeout_ms)
317 }
318
319 pub fn retry_policy(&self) -> rpi_harness::types::RetryPolicy {
322 let mut policy = rpi_harness::types::RetryPolicy::default();
323 let Some(retry) = &self.retry else {
324 return policy;
325 };
326 if let Some(enabled) = retry.enabled {
327 policy.enabled = enabled;
328 }
329 if let Some(max_retries) = retry.max_retries {
330 policy.max_retries = max_retries;
331 }
332 if let Some(base_delay_ms) = retry.base_delay_ms {
333 policy.base_delay_ms = base_delay_ms;
334 }
335 if let Some(max_agent_delay_ms) = retry.max_agent_delay_ms {
336 policy.max_agent_delay_ms = max_agent_delay_ms;
337 }
338 policy
339 }
340
341 pub fn compaction_settings(&self) -> rpi_harness::types::CompactionSettings {
343 let mut settings = rpi_harness::types::CompactionSettings::default();
344 let Some(compaction) = &self.compaction else {
345 return settings;
346 };
347 if let Some(enabled) = compaction.enabled {
348 settings.enabled = enabled;
349 }
350 if let Some(reserve_tokens) = compaction.reserve_tokens {
351 settings.reserve_tokens = reserve_tokens;
352 }
353 if let Some(keep_recent_tokens) = compaction.keep_recent_tokens {
354 settings.keep_recent_tokens = keep_recent_tokens;
355 }
356 settings
357 }
358
359 pub fn branch_summary_reserve_tokens(&self) -> Option<i64> {
365 self.branch_summary.as_ref().and_then(|s| s.reserve_tokens)
366 }
367
368 pub fn branch_summary_skipped(&self) -> bool {
373 self.branch_summary
374 .as_ref()
375 .and_then(|s| s.skip_prompt)
376 .unwrap_or(false)
377 }
378
379 pub fn steering_mode(&self) -> rpi_agent::QueueMode {
381 self.steering_mode
382 .map(Into::into)
383 .unwrap_or(rpi_agent::QueueMode::OneAtATime)
384 }
385
386 pub fn follow_up_mode(&self) -> rpi_agent::QueueMode {
388 self.follow_up_mode
389 .map(Into::into)
390 .unwrap_or(rpi_agent::QueueMode::OneAtATime)
391 }
392
393 pub fn show_images(&self) -> Option<bool> {
396 self.show_images
397 .or_else(|| self.terminal.as_ref().and_then(|t| t.show_images))
398 }
399
400 pub fn show_terminal_progress(&self) -> Option<bool> {
403 self.show_terminal_progress.or_else(|| {
404 self.terminal
405 .as_ref()
406 .and_then(|t| t.show_terminal_progress)
407 })
408 }
409}
410
411pub fn load_settings() -> Result<Settings, ConfigError> {
415 let path = config::settings_path()?;
416 match std::fs::read_to_string(&path) {
417 Ok(text) => parse_settings(&text).map_err(|e| ConfigError::Json {
418 path: path.clone(),
419 source: e,
420 }),
421 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
422 let legacy = if std::env::var_os(config::CONFIG_DIR_ENV).is_some() {
426 None
427 } else {
428 dirs::home_dir().map(|home| home.join(".pi/agent/settings.json"))
429 };
430 match legacy.filter(|candidate| candidate != &path) {
431 Some(legacy_path) => match std::fs::read_to_string(&legacy_path) {
432 Ok(text) => parse_settings(&text).map_err(|e| ConfigError::Json {
433 path: legacy_path,
434 source: e,
435 }),
436 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
437 Ok(Settings::default())
438 }
439 Err(error) => Err(ConfigError::Read {
440 path: legacy_path,
441 source: error,
442 }),
443 },
444 None => Ok(Settings::default()),
445 }
446 }
447 Err(e) => Err(ConfigError::Read { path, source: e }),
448 }
449}
450
451pub fn load_project_settings(cwd: &Path) -> Vec<Settings> {
457 load_project_settings_with_paths(cwd)
458 .into_iter()
459 .map(|(_, settings)| settings)
460 .collect()
461}
462
463pub fn load_project_settings_with_paths(cwd: &Path) -> Vec<(PathBuf, Settings)> {
465 load_active_project_settings(cwd)
466 .ok()
467 .flatten()
468 .into_iter()
469 .collect()
470}
471
472pub fn load_active_project_settings(
475 cwd: &Path,
476) -> Result<Option<(PathBuf, Settings)>, ConfigError> {
477 let preferred = cwd.join(".rpi/settings.json");
478 match load_settings_file(&preferred) {
479 Ok(settings) => Ok(Some((preferred, settings))),
480 Err(ConfigError::Read { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => {
481 let fallback = cwd.join(".pi/settings.json");
482 match load_settings_file(&fallback) {
483 Ok(settings) => Ok(Some((fallback, settings))),
484 Err(ConfigError::Read { source, .. })
485 if source.kind() == std::io::ErrorKind::NotFound =>
486 {
487 Ok(None)
488 }
489 Err(error) => Err(error),
490 }
491 }
492 Err(error) => Err(error),
493 }
494}
495
496pub fn load_effective_model_settings(
500 cwd: &Path,
501 project_trusted: bool,
502) -> Result<Settings, ConfigError> {
503 let mut effective = load_settings()?;
504 if project_trusted {
505 if let Some((_, project)) = load_active_project_settings(cwd)? {
506 if project.default_provider.is_some() {
507 effective.default_provider = project.default_provider;
508 }
509 if project.default_model.is_some() {
510 effective.default_model = project.default_model;
511 }
512 if project.default_thinking_level.is_some() {
513 effective.default_thinking_level = project.default_thinking_level;
514 }
515 if project.theme.is_some() {
516 effective.theme = project.theme;
517 }
518 }
519 }
520 Ok(effective)
521}
522
523pub fn load_project_settings_for_write(cwd: &Path) -> Result<Settings, ConfigError> {
528 Ok(load_active_project_settings(cwd)?
529 .map(|(_, settings)| settings)
530 .unwrap_or_default())
531}
532
533fn load_settings_file(path: &Path) -> Result<Settings, ConfigError> {
534 let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
535 path: path.to_path_buf(),
536 source,
537 })?;
538 parse_settings(&text).map_err(|source| ConfigError::Json {
539 path: path.to_path_buf(),
540 source,
541 })
542}
543
544pub fn resolve_configured_paths(base: &Path, values: &[String]) -> Vec<PathBuf> {
547 values
548 .iter()
549 .map(|value| value.trim())
550 .filter(|value| !value.is_empty())
551 .map(|value| {
552 let path = PathBuf::from(value);
553 if path.is_absolute() {
554 path
555 } else {
556 base.join(path)
557 }
558 })
559 .collect()
560}
561
562fn parse_settings(text: &str) -> Result<Settings, serde_json::Error> {
563 match serde_json::from_str(text) {
564 Ok(s) => Ok(s),
565 Err(first) => {
566 let stripped = strip_line_comments(text);
567 serde_json::from_str(&stripped).map_err(|_| first)
568 }
569 }
570}
571
572fn parse_settings_value(text: &str) -> Result<serde_json::Value, serde_json::Error> {
578 match serde_json::from_str::<serde_json::Value>(text) {
579 Ok(value) => Ok(value),
580 Err(first) => {
581 let stripped = strip_line_comments(text);
582 serde_json::from_str::<serde_json::Value>(&stripped).map_err(|_| first)
583 }
584 }
585}
586
587pub fn save_settings(settings: &Settings) -> Result<(), String> {
595 let path = config::settings_path().map_err(|e| e.to_string())?;
596 let fallback = if std::env::var_os(config::CONFIG_DIR_ENV).is_some() {
597 None
598 } else {
599 dirs::home_dir().map(|home| home.join(".pi/agent/settings.json"))
600 };
601 save_settings_to_path(settings, &path, fallback.as_deref())
602}
603
604pub fn save_project_settings(cwd: &Path, settings: &Settings) -> Result<(), String> {
607 save_settings_to_path(
608 settings,
609 &cwd.join(".rpi/settings.json"),
610 Some(&cwd.join(".pi/settings.json")),
611 )
612}
613
614fn save_settings_to_path(
615 settings: &Settings,
616 path: &Path,
617 fallback: Option<&Path>,
618) -> Result<(), String> {
619 let mut merged = match std::fs::read_to_string(path) {
621 Ok(text) => parse_settings_value(&text).map_err(|error| {
622 format!(
623 "cannot parse existing settings file {}: {error}",
624 path.display()
625 )
626 })?,
627 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
628 read_fallback_settings_value(path, fallback)?
629 .unwrap_or_else(|| serde_json::Value::Object(Default::default()))
630 }
631 Err(error) => {
632 return Err(format!(
633 "cannot read existing settings file {}: {error}",
634 path.display()
635 ));
636 }
637 };
638 let obj = merged
639 .as_object_mut()
640 .ok_or_else(|| format!("settings file {} is not an object", path.display()))?;
641 for (key, val) in [
642 ("defaultProvider", settings.default_provider.as_ref()),
643 ("defaultModel", settings.default_model.as_ref()),
644 (
645 "defaultThinkingLevel",
646 settings.default_thinking_level.as_ref(),
647 ),
648 ("theme", settings.theme.as_ref()),
649 ] {
650 match val {
651 Some(v) => {
652 obj.insert(key.to_string(), serde_json::Value::String(v.clone()));
653 }
654 None => {
655 obj.remove(key);
656 }
657 }
658 }
659 match &settings.scoped_models {
660 Some(list) if !list.is_empty() => {
661 obj.insert(
662 "scopedModels".to_string(),
663 serde_json::Value::Array(
664 list.iter()
665 .map(|m| serde_json::Value::String(m.clone()))
666 .collect(),
667 ),
668 );
669 }
670 _ => {
671 obj.remove("scopedModels");
672 }
673 }
674 match &settings.packages {
675 Some(list) if !list.is_empty() => {
676 obj.insert(
677 "packages".to_string(),
678 serde_json::to_value(list).map_err(|e| e.to_string())?,
679 );
680 }
681 _ => {
682 obj.remove("packages");
683 }
684 }
685 match &settings.npm_command {
686 Some(command) => {
687 obj.insert(
688 "npmCommand".to_string(),
689 serde_json::to_value(command).map_err(|e| e.to_string())?,
690 );
691 }
692 None => {
693 obj.remove("npmCommand");
694 }
695 }
696 for (key, values) in [
697 ("skillDirs", settings.skill_dirs.as_ref()),
698 ("promptDirs", settings.prompt_dirs.as_ref()),
699 ("extensionDirs", settings.extension_dirs.as_ref()),
700 ] {
701 match values {
702 Some(list) if !list.is_empty() => {
703 obj.insert(
704 key.to_string(),
705 serde_json::Value::Array(
706 list.iter()
707 .map(|path| serde_json::Value::String(path.clone()))
708 .collect(),
709 ),
710 );
711 }
712 _ => {
713 obj.remove(key);
714 }
715 }
716 }
717 match &settings.keybindings {
718 Some(bindings) => {
719 obj.insert(
720 "keybindings".to_string(),
721 serde_json::to_value(bindings).map_err(|e| e.to_string())?,
722 );
723 }
724 None => {
725 obj.remove("keybindings");
726 }
727 }
728 match settings.double_escape_action.as_deref() {
729 Some(action) if !action.trim().is_empty() => {
730 obj.insert(
731 "doubleEscapeAction".to_string(),
732 serde_json::Value::String(action.to_string()),
733 );
734 }
735 _ => {
736 obj.remove("doubleEscapeAction");
737 }
738 }
739 for (key, value) in [
740 (
741 "hideThinkingBlock",
742 settings.hide_thinking_block.map(serde_json::Value::Bool),
743 ),
744 (
745 "quietStartup",
746 settings.quiet_startup.map(serde_json::Value::Bool),
747 ),
748 (
749 "showTerminalProgress",
750 settings.show_terminal_progress.map(serde_json::Value::Bool),
751 ),
752 (
753 "showImages",
754 settings.show_images.map(serde_json::Value::Bool),
755 ),
756 (
757 "showCacheMissNotices",
758 settings
759 .show_cache_miss_notices
760 .map(serde_json::Value::Bool),
761 ),
762 (
763 "fullscreenCopyOnSelect",
764 settings
765 .fullscreen_copy_on_select
766 .map(serde_json::Value::Bool),
767 ),
768 (
769 "editorPaddingX",
770 settings
771 .editor_padding_x
772 .map(|v| serde_json::Value::Number(v.into())),
773 ),
774 (
775 "outputPad",
776 settings
777 .output_pad
778 .map(|v| serde_json::Value::Number(v.into())),
779 ),
780 (
781 "autocompleteMaxVisible",
782 settings
783 .autocomplete_max_visible
784 .map(|v| serde_json::Value::Number(v.into())),
785 ),
786 ("httpIdleTimeout", settings.http_idle_timeout.clone()),
787 ] {
788 match value {
789 Some(value) => {
790 obj.insert(key.to_string(), value);
791 }
792 None => {
793 obj.remove(key);
794 }
795 }
796 }
797 if let Some(parent) = path.parent() {
798 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
799 }
800 let text = serde_json::to_string_pretty(&merged).map_err(|e| e.to_string())?;
801 config::atomic_write(path, text.as_bytes()).map_err(|e| e.to_string())
802}
803
804fn read_fallback_settings_value(
808 target: &Path,
809 fallback: Option<&Path>,
810) -> Result<Option<serde_json::Value>, String> {
811 let Some(path) = fallback else {
812 return Ok(None);
813 };
814 if path == target {
815 return Ok(None);
816 }
817 match std::fs::read_to_string(&path) {
818 Ok(text) => parse_settings_value(&text).map(Some).map_err(|error| {
819 format!(
820 "cannot parse fallback settings file {}: {error}",
821 path.display()
822 )
823 }),
824 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
825 Err(error) => Err(format!(
826 "cannot read fallback settings file {}: {error}",
827 path.display()
828 )),
829 }
830}
831
832#[cfg(test)]
833mod tests {
834 use super::*;
835 use crate::config::test_support::env_lock;
836
837 struct TempConfig {
839 _guard: std::sync::MutexGuard<'static, ()>,
840 _tmp: tempfile::TempDir,
841 prev: Option<std::ffi::OsString>,
842 }
843 impl TempConfig {
844 fn new() -> Self {
845 let guard = env_lock().lock().unwrap();
846 let prev = std::env::var_os(config::CONFIG_DIR_ENV);
847 let tmp = tempfile::TempDir::new().unwrap();
848 std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
849 Self {
850 _guard: guard,
851 _tmp: tmp,
852 prev,
853 }
854 }
855 }
856 impl Drop for TempConfig {
857 fn drop(&mut self) {
858 match self.prev.take() {
859 Some(v) => std::env::set_var(config::CONFIG_DIR_ENV, v),
860 None => std::env::remove_var(config::CONFIG_DIR_ENV),
861 }
862 }
863 }
864
865 #[test]
866 fn missing_settings_is_default() {
867 let _cfg = TempConfig::new();
868 let s = load_settings().unwrap();
869 assert!(s.default_provider.is_none());
870 assert!(s.default_model.is_none());
871 assert!(s.default_thinking_level.is_none());
872 assert!(s.theme.is_none());
873 assert!(s.packages.is_none());
874 assert!(s.npm_command.is_none());
875 assert!(s.skill_dirs.is_none());
876 assert!(s.prompt_dirs.is_none());
877 assert!(s.extension_dirs.is_none());
878 }
879
880 #[test]
881 fn reads_honored_fields_and_ignores_unknown() {
882 let _cfg = TempConfig::new();
883 let path = config::settings_path().unwrap();
884 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
885 std::fs::write(
887 &path,
888 r#"{
889 "lastChangelogVersion": "1.0.0",
890 "defaultProvider": "anthropic",
891 "defaultModel": "claude-sonnet-5",
892 "defaultThinkingLevel": "high",
893 "theme": "dark",
894 "hideThinkingBlock": true,
895 "quietStartup": true,
896 "showTerminalProgress": false,
897 "editorPaddingX": 3,
898 "autocompleteMaxVisible": 7,
899 "compaction": { "threshold": 100 },
900 "npmCommand": ["mise", "exec", "node@20", "--", "npm"],
901 "packages": [
902 "some-pkg",
903 {
904 "source": "npm:filtered-pkg",
905 "autoload": false,
906 "extensions": ["dist/index.js"],
907 "skills": ["skills/review"],
908 "prompts": ["prompts/review.md"],
909 "themes": ["themes/dark.json"],
910 "futureFilter": { "enabled": true }
911 }
912 ]
913 }"#,
914 )
915 .unwrap();
916 let s = load_settings().unwrap();
917 assert_eq!(s.default_provider.as_deref(), Some("anthropic"));
918 assert_eq!(s.default_model.as_deref(), Some("claude-sonnet-5"));
919 assert_eq!(s.default_thinking_level.as_deref(), Some("high"));
920 assert_eq!(s.theme.as_deref(), Some("dark"));
921 assert_eq!(
922 s.npm_command.as_deref(),
923 Some(
924 ["mise", "exec", "node@20", "--", "npm"]
925 .map(String::from)
926 .as_slice()
927 )
928 );
929 let packages = s.packages.as_deref().unwrap();
930 assert_eq!(packages[0], PackageSetting::from("some-pkg"));
931 assert_eq!(packages[1].source(), "npm:filtered-pkg");
932 let PackageSetting::Filtered(filter) = &packages[1] else {
933 panic!("expected an object-form package setting");
934 };
935 assert_eq!(filter.autoload, Some(false));
936 assert_eq!(
937 filter.extensions.as_deref(),
938 Some(["dist/index.js".into()].as_slice())
939 );
940 assert_eq!(
941 filter.skills.as_deref(),
942 Some(["skills/review".into()].as_slice())
943 );
944 assert_eq!(
945 filter.prompts.as_deref(),
946 Some(["prompts/review.md".into()].as_slice())
947 );
948 assert_eq!(
949 filter.themes.as_deref(),
950 Some(["themes/dark.json".into()].as_slice())
951 );
952 assert_eq!(filter.unknown["futureFilter"]["enabled"], true);
953 assert_eq!(s.hide_thinking_block, Some(true));
954 assert_eq!(s.quiet_startup, Some(true));
955 assert_eq!(s.show_terminal_progress, Some(false));
956 assert_eq!(s.editor_padding_x, Some(3));
957 assert_eq!(s.autocomplete_max_visible, Some(7));
958 }
959
960 #[test]
961 fn rpi_project_settings_mask_native_pi_settings() {
962 let tmp = tempfile::tempdir().unwrap();
963 std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
964 std::fs::create_dir_all(tmp.path().join(".pi")).unwrap();
965 std::fs::write(
966 tmp.path().join(".rpi/settings.json"),
967 r#"{"skillDirs":["rpi-skills"],"extensions":["rpi-ext"]}"#,
968 )
969 .unwrap();
970 std::fs::write(
971 tmp.path().join(".pi/settings.json"),
972 r#"{"skills":["pi-skills"],"extensionDirs":["pi-ext"]}"#,
973 )
974 .unwrap();
975
976 let settings = load_project_settings(tmp.path());
977 assert_eq!(settings.len(), 1);
978 assert_eq!(
979 settings[0].skill_dirs.as_deref(),
980 Some(["rpi-skills".to_string()].as_slice())
981 );
982 assert_eq!(
983 settings[0].extension_dirs.as_deref(),
984 Some(["rpi-ext".to_string()].as_slice())
985 );
986 }
987
988 #[test]
989 fn native_pi_project_settings_are_a_fallback() {
990 let tmp = tempfile::tempdir().unwrap();
991 std::fs::create_dir_all(tmp.path().join(".pi")).unwrap();
992 std::fs::write(
993 tmp.path().join(".pi/settings.json"),
994 r#"{"defaultProvider":"native-provider","defaultModel":"native-model"}"#,
995 )
996 .unwrap();
997
998 let settings = load_project_settings_with_paths(tmp.path());
999 assert_eq!(settings.len(), 1);
1000 assert!(settings[0].0.ends_with(".pi/settings.json"));
1001 assert_eq!(
1002 settings[0].1.default_provider.as_deref(),
1003 Some("native-provider")
1004 );
1005 }
1006
1007 #[test]
1008 fn malformed_rpi_project_settings_mask_native_pi_fallback() {
1009 let tmp = tempfile::tempdir().unwrap();
1010 std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
1011 std::fs::create_dir_all(tmp.path().join(".pi")).unwrap();
1012 std::fs::write(tmp.path().join(".rpi/settings.json"), "{ malformed").unwrap();
1013 std::fs::write(
1014 tmp.path().join(".pi/settings.json"),
1015 r#"{"packages":["npm:must-not-load"]}"#,
1016 )
1017 .unwrap();
1018
1019 assert!(load_project_settings_with_paths(tmp.path()).is_empty());
1020 assert!(load_active_project_settings(tmp.path()).is_err());
1021 }
1022
1023 #[test]
1024 fn trusted_project_model_defaults_override_global_defaults() {
1025 let _cfg = TempConfig::new();
1026 let global = config::settings_path().unwrap();
1027 std::fs::create_dir_all(global.parent().unwrap()).unwrap();
1028 std::fs::write(
1029 global,
1030 r#"{"defaultProvider":"global","defaultModel":"global-model","theme":"dark"}"#,
1031 )
1032 .unwrap();
1033 let project = tempfile::tempdir().unwrap();
1034 std::fs::create_dir_all(project.path().join(".rpi")).unwrap();
1035 std::fs::write(
1036 project.path().join(".rpi/settings.json"),
1037 r#"{"defaultProvider":"project","defaultModel":"project-model"}"#,
1038 )
1039 .unwrap();
1040
1041 let trusted = load_effective_model_settings(project.path(), true).unwrap();
1042 assert_eq!(trusted.default_provider.as_deref(), Some("project"));
1043 assert_eq!(trusted.default_model.as_deref(), Some("project-model"));
1044 assert_eq!(trusted.theme.as_deref(), Some("dark"));
1045
1046 let untrusted = load_effective_model_settings(project.path(), false).unwrap();
1047 assert_eq!(untrusted.default_provider.as_deref(), Some("global"));
1048 assert_eq!(untrusted.default_model.as_deref(), Some("global-model"));
1049 }
1050
1051 #[test]
1052 fn tolerates_line_comments() {
1053 let _cfg = TempConfig::new();
1054 let path = config::settings_path().unwrap();
1055 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1056 std::fs::write(
1057 &path,
1058 "{\n // my default\n \"defaultModel\": \"glm-5\",\n \"theme\": \"light\"\n}\n",
1059 )
1060 .unwrap();
1061 let s = load_settings().unwrap();
1062 assert_eq!(s.default_model.as_deref(), Some("glm-5"));
1063 assert_eq!(s.theme.as_deref(), Some("light"));
1064 }
1065
1066 #[test]
1067 fn malformed_is_error() {
1068 let _cfg = TempConfig::new();
1069 let path = config::settings_path().unwrap();
1070 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1071 std::fs::write(&path, "{ not json").unwrap();
1072 assert!(matches!(load_settings(), Err(ConfigError::Json { .. })));
1073 }
1074}
1075
1076#[cfg(test)]
1077mod scoped_tests {
1078 use super::*;
1079 use crate::config::test_support::env_lock;
1080
1081 fn with_temp_env() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
1082 let guard = env_lock().lock().unwrap();
1083 let tmp = tempfile::TempDir::new().unwrap();
1084 std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
1085 (tmp, guard)
1086 }
1087
1088 #[test]
1099 fn session_dir_setting_selects_the_session_directory() {
1100 let settings: Settings =
1101 serde_json::from_str(r#"{"sessionDir": "/tmp/from-settings"}"#).unwrap();
1102 assert_eq!(
1103 settings.session_dir,
1104 Some(std::path::PathBuf::from("/tmp/from-settings"))
1105 );
1106 assert_eq!(Settings::default().session_dir, None);
1108 }
1109
1110 #[test]
1112 fn shell_command_prefix_reaches_the_bash_tool() {
1113 let settings: Settings =
1114 serde_json::from_str(r#"{"shellCommandPrefix": "shopt -s expand_aliases"}"#).unwrap();
1115 assert_eq!(
1116 settings.shell_command_prefix.as_deref(),
1117 Some("shopt -s expand_aliases")
1118 );
1119 let with_shell: Settings =
1122 serde_json::from_str(r#"{"shellPath": "/usr/bin/zsh"}"#).unwrap();
1123 assert_eq!(with_shell.shell_path.as_deref(), Some("/usr/bin/zsh"));
1124 }
1125
1126 #[test]
1128 fn http_proxy_setting_is_read() {
1129 let settings: Settings =
1130 serde_json::from_str(r#"{"httpProxy": "http://127.0.0.1:8888"}"#).unwrap();
1131 assert_eq!(
1132 settings.http_proxy.as_deref(),
1133 Some("http://127.0.0.1:8888")
1134 );
1135 assert_eq!(Settings::default().http_proxy, None);
1136 }
1137 #[test]
1138 fn harness_options_come_from_settings() {
1139 let settings: Settings = serde_json::from_str(
1140 r#"{
1141 "retry": { "enabled": false, "maxRetries": 7, "baseDelayMs": 250, "maxAgentDelayMs": 9000 },
1142 "compaction": { "enabled": false, "reserveTokens": 1234, "keepRecentTokens": 4321 },
1143 "steeringMode": "all",
1144 "followUpMode": "all"
1145 }"#,
1146 )
1147 .expect("settings parse");
1148
1149 let retry = settings.retry_policy();
1150 assert!(!retry.enabled);
1151 assert_eq!(retry.max_retries, 7);
1152 assert_eq!(retry.base_delay_ms, 250);
1153 assert_eq!(retry.max_agent_delay_ms, 9000);
1154
1155 let compaction = settings.compaction_settings();
1156 assert!(!compaction.enabled);
1157 assert_eq!(compaction.reserve_tokens, 1234);
1158 assert_eq!(compaction.keep_recent_tokens, 4321);
1159
1160 assert_eq!(settings.steering_mode(), rpi_agent::QueueMode::All);
1161 assert_eq!(settings.follow_up_mode(), rpi_agent::QueueMode::All);
1162 }
1163
1164 #[test]
1167 fn absent_harness_settings_keep_the_harness_defaults() {
1168 let empty = Settings::default();
1169 let default_retry = rpi_harness::types::RetryPolicy::default();
1170 let retry = empty.retry_policy();
1171 assert_eq!(retry.enabled, default_retry.enabled);
1172 assert_eq!(retry.max_retries, default_retry.max_retries);
1173 assert_eq!(retry.base_delay_ms, default_retry.base_delay_ms);
1174 assert_eq!(retry.max_agent_delay_ms, default_retry.max_agent_delay_ms);
1175 let default_compaction = rpi_harness::types::CompactionSettings::default();
1176 let compaction = empty.compaction_settings();
1177 assert_eq!(compaction.enabled, default_compaction.enabled);
1178 assert_eq!(compaction.reserve_tokens, default_compaction.reserve_tokens);
1179 assert_eq!(
1180 compaction.keep_recent_tokens,
1181 default_compaction.keep_recent_tokens
1182 );
1183
1184 assert_eq!(empty.steering_mode(), rpi_agent::QueueMode::OneAtATime);
1186 assert_eq!(empty.follow_up_mode(), rpi_agent::QueueMode::OneAtATime);
1187
1188 let partial: Settings = serde_json::from_str(r#"{"retry":{"maxRetries":9}}"#).unwrap();
1190 let retry = partial.retry_policy();
1191 assert_eq!(retry.max_retries, 9);
1192 assert_eq!(retry.base_delay_ms, default_retry.base_delay_ms);
1193 }
1194
1195 #[test]
1201 fn native_settings_key_names_are_accepted() {
1202 let settings: Settings = serde_json::from_str(
1203 r#"{
1204 "enabledModels": ["a", "b"],
1205 "httpIdleTimeoutMs": 1500,
1206 "branchSummary": { "reserveTokens": 4242, "skipPrompt": true }
1207 }"#,
1208 )
1209 .expect("native key names parse");
1210
1211 assert_eq!(
1212 settings.scoped_models,
1213 Some(vec!["a".to_string(), "b".to_string()]),
1214 "native's `enabledModels` must map onto rpi's scoped models"
1215 );
1216 assert_eq!(
1217 settings.http_idle_timeout_ms(),
1218 Some(1500),
1219 "native's `httpIdleTimeoutMs` must be read"
1220 );
1221 assert_eq!(settings.branch_summary_reserve_tokens(), Some(4242));
1222 assert!(settings.branch_summary_skipped());
1223 }
1224 #[test]
1225 fn save_load_scoped_models_roundtrip() {
1226 let (_tmp, _guard) = with_temp_env();
1227 let mut s = Settings::default();
1228 s.scoped_models = Some(vec!["a".into(), "b".into()]);
1229 save_settings(&s).unwrap();
1230 let loaded = load_settings().unwrap();
1231 assert_eq!(
1232 loaded.scoped_models,
1233 Some(vec!["a".to_string(), "b".to_string()])
1234 );
1235 let mut s2 = load_settings().unwrap();
1237 s2.scoped_models = None;
1238 save_settings(&s2).unwrap();
1239 assert_eq!(load_settings().unwrap().scoped_models, None);
1240 }
1241
1242 #[test]
1243 fn save_uses_atomic_sibling_replacement() {
1244 let (_tmp, _guard) = with_temp_env();
1245 let path = config::settings_path().unwrap();
1246 std::fs::write(&path, r#"{"theme":"dark","piOnlyField":true}"#).unwrap();
1247
1248 let mut settings = load_settings().unwrap();
1249 settings.theme = Some("light".into());
1250 save_settings(&settings).unwrap();
1251
1252 let saved: serde_json::Value =
1253 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1254 assert_eq!(saved["theme"], "light");
1255 assert_eq!(saved["piOnlyField"], true);
1256 let temp = path.with_file_name(format!(
1259 ".{}.tmp",
1260 path.file_name().and_then(|name| name.to_str()).unwrap()
1261 ));
1262 assert!(!temp.exists(), "atomic staging file should not remain");
1263 }
1264
1265 #[test]
1266 fn save_preserves_unknown_fields_and_writes_the_tui_settings() {
1267 let (_tmp, _guard) = with_temp_env();
1272 let path = config::settings_path().unwrap();
1273 let mut settings = Settings::default();
1274 settings.show_images = Some(false);
1275 settings.show_cache_miss_notices = Some(true);
1276 settings.output_pad = Some(0);
1277 settings.fullscreen_copy_on_select = Some(false);
1278 settings.http_idle_timeout = Some(serde_json::json!(30_000));
1279 settings.show_terminal_progress = Some(false);
1280 save_settings(&settings).unwrap();
1281
1282 let raw: serde_json::Value =
1283 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1284 assert_eq!(raw["showImages"], false);
1285 assert_eq!(raw["showCacheMissNotices"], true);
1286 assert_eq!(raw["outputPad"], 0);
1287 assert_eq!(raw["fullscreenCopyOnSelect"], false);
1288 assert_eq!(raw["httpIdleTimeout"], 30_000);
1289 assert_eq!(raw["showTerminalProgress"], false);
1290
1291 let reloaded = load_settings().unwrap();
1293 assert_eq!(reloaded.show_images(), Some(false));
1294 assert_eq!(reloaded.show_cache_miss_notices, Some(true));
1295 assert_eq!(reloaded.output_pad, Some(0));
1296 assert_eq!(reloaded.fullscreen_copy_on_select, Some(false));
1297 assert_eq!(reloaded.http_idle_timeout_ms(), Some(30_000));
1298 assert_eq!(reloaded.show_terminal_progress(), Some(false));
1299 }
1300
1301 #[test]
1302 fn nested_terminal_block_is_accepted_as_a_fallback() {
1303 let (_tmp, _guard) = with_temp_env();
1306 let path = config::settings_path().unwrap();
1307 std::fs::write(
1308 &path,
1309 r#"{ "terminal": { "showImages": false, "showTerminalProgress": false } }"#,
1310 )
1311 .unwrap();
1312 let settings = load_settings().unwrap();
1313 assert_eq!(settings.show_images(), Some(false));
1314 assert_eq!(settings.show_terminal_progress(), Some(false));
1315
1316 std::fs::write(
1317 &path,
1318 r#"{
1319 "showImages": true,
1320 "terminal": { "showImages": false }
1321 }"#,
1322 )
1323 .unwrap();
1324 let settings = load_settings().unwrap();
1325 assert_eq!(settings.show_images(), Some(true));
1326 }
1327
1328 #[test]
1329 fn save_preserves_unknown_fields() {
1330 let (_tmp, _guard) = with_temp_env();
1331 let path = config::settings_path().unwrap();
1332 std::fs::write(
1333 &path,
1334 r#"{
1335 "piOnlyField": "keep-me",
1336 "theme": "dark",
1337 "npmCommand": ["pnpm"],
1338 "packages": [{
1339 "source": "npm:future-package",
1340 "autoload": false,
1341 "futureFilter": { "enabled": true }
1342 }]
1343 }"#,
1344 )
1345 .unwrap();
1346 let mut s = load_settings().unwrap();
1347 assert_eq!(s.npm_command, Some(vec!["pnpm".to_string()]));
1348 s.scoped_models = Some(vec!["m1".into()]);
1349 save_settings(&s).unwrap();
1350 let raw: serde_json::Value =
1351 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1352 assert_eq!(raw["piOnlyField"], "keep-me");
1353 assert_eq!(raw["scopedModels"][0], "m1");
1354 assert_eq!(raw["theme"], "dark");
1355 assert_eq!(raw["npmCommand"][0], "pnpm");
1356 assert_eq!(raw["packages"][0]["source"], "npm:future-package");
1357 assert_eq!(raw["packages"][0]["autoload"], false);
1358 assert_eq!(raw["packages"][0]["futureFilter"]["enabled"], true);
1359 }
1360
1361 #[test]
1362 fn save_preserves_unknown_fields_and_packages_with_line_comments() {
1363 let (_tmp, _guard) = with_temp_env();
1364 let path = config::settings_path().unwrap();
1365 let original = r#"{
1366 // Native Pi permits comments in settings files.
1367 "piOnlyField": { "keep": true },
1368 "theme": "dark",
1369 "packages": [
1370 // Keep the package object and fields that rpi does not use.
1371 {
1372 "source": "npm:future-package",
1373 "autoload": false,
1374 "futureFilter": { "enabled": true }
1375 }
1376 ]
1377 }
1378 "#;
1379 std::fs::write(&path, original).unwrap();
1380
1381 let mut settings = load_settings().unwrap();
1382 settings.scoped_models = Some(vec!["m1".into()]);
1383 save_settings(&settings).unwrap();
1384
1385 let raw: serde_json::Value =
1388 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1389 assert_eq!(raw["piOnlyField"]["keep"], true);
1390 assert_eq!(raw["theme"], "dark");
1391 assert_eq!(raw["packages"][0]["source"], "npm:future-package");
1392 assert_eq!(raw["packages"][0]["autoload"], false);
1393 assert_eq!(raw["packages"][0]["futureFilter"]["enabled"], true);
1394 assert_eq!(raw["scopedModels"][0], "m1");
1395 }
1396
1397 #[test]
1398 fn save_fails_closed_for_unparseable_existing_settings() {
1399 let (_tmp, _guard) = with_temp_env();
1400 let path = config::settings_path().unwrap();
1401 let original = b"{\n // keep this file intact\n \"piOnlyField\": \"keep-me\"\n";
1404 std::fs::write(&path, original).unwrap();
1405
1406 let mut settings = Settings::default();
1407 settings.theme = Some("light".into());
1408 let error =
1409 save_settings(&settings).expect_err("malformed settings must not be overwritten");
1410 assert!(error.contains("cannot parse existing settings file"));
1411 assert_eq!(std::fs::read(&path).unwrap(), original);
1412 }
1413
1414 #[test]
1415 fn project_save_seeds_from_native_pi_without_losing_unknown_fields() {
1416 let tmp = tempfile::tempdir().unwrap();
1417 let legacy = tmp.path().join(".pi/settings.json");
1418 std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
1419 std::fs::write(
1420 &legacy,
1421 r#"{
1422 // Preserve fields from native Pi on the first rpi save.
1423 "piOnlyField": { "keep": true },
1424 "packages": ["npm:existing"]
1425 }"#,
1426 )
1427 .unwrap();
1428
1429 let mut settings = load_project_settings_for_write(tmp.path()).unwrap();
1430 settings.theme = Some("dark".into());
1431 save_project_settings(tmp.path(), &settings).unwrap();
1432
1433 let preferred = tmp.path().join(".rpi/settings.json");
1434 let saved: serde_json::Value =
1435 serde_json::from_str(&std::fs::read_to_string(preferred).unwrap()).unwrap();
1436 assert_eq!(saved["piOnlyField"]["keep"], true);
1437 assert_eq!(saved["packages"][0], "npm:existing");
1438 assert_eq!(saved["theme"], "dark");
1439 }
1440
1441 #[test]
1442 fn project_save_fails_closed_for_malformed_native_fallback() {
1443 let tmp = tempfile::tempdir().unwrap();
1444 let legacy = tmp.path().join(".pi/settings.json");
1445 std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
1446 std::fs::write(&legacy, "{ malformed").unwrap();
1447
1448 assert!(load_project_settings_for_write(tmp.path()).is_err());
1449 assert!(!tmp.path().join(".rpi/settings.json").exists());
1450 }
1451}