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, PartialEq, Eq)]
22#[serde(untagged)]
23pub enum PackageSetting {
24 Source(String),
25 Filtered(PackageFilter),
26}
27
28impl PackageSetting {
29 pub fn source(&self) -> &str {
31 match self {
32 Self::Source(source) => source,
33 Self::Filtered(filter) => &filter.source,
34 }
35 }
36}
37
38impl From<String> for PackageSetting {
39 fn from(source: String) -> Self {
40 Self::Source(source)
41 }
42}
43
44impl From<&str> for PackageSetting {
45 fn from(source: &str) -> Self {
46 Self::Source(source.to_string())
47 }
48}
49
50#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq, Eq)]
55#[serde(rename_all = "camelCase")]
56pub struct PackageFilter {
57 pub source: String,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub autoload: Option<bool>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub extensions: Option<Vec<String>>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub skills: Option<Vec<String>>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub prompts: Option<Vec<String>>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub themes: Option<Vec<String>>,
68 #[serde(flatten)]
69 pub unknown: serde_json::Map<String, serde_json::Value>,
70}
71
72#[derive(serde::Deserialize, Default, Clone, Debug)]
74#[serde(rename_all = "camelCase")]
75pub struct Settings {
76 #[serde(default)]
79 pub default_provider: Option<String>,
80 #[serde(default)]
83 pub default_model: Option<String>,
84 #[serde(default)]
87 pub default_thinking_level: Option<String>,
88 #[serde(default)]
90 pub theme: Option<String>,
91 #[serde(default)]
94 pub scoped_models: Option<Vec<String>>,
95 #[serde(default)]
99 pub packages: Option<Vec<PackageSetting>>,
100 #[serde(default)]
103 pub npm_command: Option<Vec<String>>,
104 #[serde(default, alias = "skills")]
108 pub skill_dirs: Option<Vec<String>>,
109 #[serde(default, alias = "prompts")]
112 pub prompt_dirs: Option<Vec<String>>,
113 #[serde(default, alias = "extensions")]
116 pub extension_dirs: Option<Vec<String>>,
117 #[serde(default)]
120 pub keybindings: Option<HashMap<String, serde_json::Value>>,
121 #[serde(default)]
124 pub double_escape_action: Option<String>,
125 #[serde(default)]
128 pub default_tools: Option<Vec<String>>,
129 #[serde(default)]
131 pub hide_thinking_block: Option<bool>,
132 #[serde(default)]
135 pub quiet_startup: Option<bool>,
136 #[serde(default)]
138 pub show_terminal_progress: Option<bool>,
139 #[serde(default)]
141 pub editor_padding_x: Option<usize>,
142 #[serde(default)]
144 pub autocomplete_max_visible: Option<usize>,
145 #[serde(default)]
148 pub fullscreen_copy_on_select: Option<bool>,
149 #[serde(default)]
153 pub http_idle_timeout: Option<serde_json::Value>,
154}
155
156impl Settings {
157 pub fn http_idle_timeout_ms(&self) -> Option<i64> {
160 self.http_idle_timeout
161 .as_ref()
162 .and_then(rpi_ai::http::parse_http_idle_timeout_ms)
163 }
164}
165
166pub fn load_settings() -> Result<Settings, ConfigError> {
170 let path = config::settings_path()?;
171 match std::fs::read_to_string(&path) {
172 Ok(text) => parse_settings(&text).map_err(|e| ConfigError::Json {
173 path: path.clone(),
174 source: e,
175 }),
176 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
177 let legacy = if std::env::var_os(config::CONFIG_DIR_ENV).is_some() {
181 None
182 } else {
183 dirs::home_dir().map(|home| home.join(".pi/agent/settings.json"))
184 };
185 match legacy.filter(|candidate| candidate != &path) {
186 Some(legacy_path) => match std::fs::read_to_string(&legacy_path) {
187 Ok(text) => parse_settings(&text).map_err(|e| ConfigError::Json {
188 path: legacy_path,
189 source: e,
190 }),
191 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
192 Ok(Settings::default())
193 }
194 Err(error) => Err(ConfigError::Read {
195 path: legacy_path,
196 source: error,
197 }),
198 },
199 None => Ok(Settings::default()),
200 }
201 }
202 Err(e) => Err(ConfigError::Read { path, source: e }),
203 }
204}
205
206pub fn load_project_settings(cwd: &Path) -> Vec<Settings> {
212 load_project_settings_with_paths(cwd)
213 .into_iter()
214 .map(|(_, settings)| settings)
215 .collect()
216}
217
218pub fn load_project_settings_with_paths(cwd: &Path) -> Vec<(PathBuf, Settings)> {
220 load_active_project_settings(cwd)
221 .ok()
222 .flatten()
223 .into_iter()
224 .collect()
225}
226
227pub fn load_active_project_settings(
230 cwd: &Path,
231) -> Result<Option<(PathBuf, Settings)>, ConfigError> {
232 let preferred = cwd.join(".rpi/settings.json");
233 match load_settings_file(&preferred) {
234 Ok(settings) => Ok(Some((preferred, settings))),
235 Err(ConfigError::Read { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => {
236 let fallback = cwd.join(".pi/settings.json");
237 match load_settings_file(&fallback) {
238 Ok(settings) => Ok(Some((fallback, settings))),
239 Err(ConfigError::Read { source, .. })
240 if source.kind() == std::io::ErrorKind::NotFound =>
241 {
242 Ok(None)
243 }
244 Err(error) => Err(error),
245 }
246 }
247 Err(error) => Err(error),
248 }
249}
250
251pub fn load_effective_model_settings(
255 cwd: &Path,
256 project_trusted: bool,
257) -> Result<Settings, ConfigError> {
258 let mut effective = load_settings()?;
259 if project_trusted {
260 if let Some((_, project)) = load_active_project_settings(cwd)? {
261 if project.default_provider.is_some() {
262 effective.default_provider = project.default_provider;
263 }
264 if project.default_model.is_some() {
265 effective.default_model = project.default_model;
266 }
267 if project.default_thinking_level.is_some() {
268 effective.default_thinking_level = project.default_thinking_level;
269 }
270 if project.theme.is_some() {
271 effective.theme = project.theme;
272 }
273 }
274 }
275 Ok(effective)
276}
277
278pub fn load_project_settings_for_write(cwd: &Path) -> Result<Settings, ConfigError> {
283 Ok(load_active_project_settings(cwd)?
284 .map(|(_, settings)| settings)
285 .unwrap_or_default())
286}
287
288fn load_settings_file(path: &Path) -> Result<Settings, ConfigError> {
289 let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
290 path: path.to_path_buf(),
291 source,
292 })?;
293 parse_settings(&text).map_err(|source| ConfigError::Json {
294 path: path.to_path_buf(),
295 source,
296 })
297}
298
299pub fn resolve_configured_paths(base: &Path, values: &[String]) -> Vec<PathBuf> {
302 values
303 .iter()
304 .map(|value| value.trim())
305 .filter(|value| !value.is_empty())
306 .map(|value| {
307 let path = PathBuf::from(value);
308 if path.is_absolute() {
309 path
310 } else {
311 base.join(path)
312 }
313 })
314 .collect()
315}
316
317fn parse_settings(text: &str) -> Result<Settings, serde_json::Error> {
318 match serde_json::from_str(text) {
319 Ok(s) => Ok(s),
320 Err(first) => {
321 let stripped = strip_line_comments(text);
322 serde_json::from_str(&stripped).map_err(|_| first)
323 }
324 }
325}
326
327fn parse_settings_value(text: &str) -> Result<serde_json::Value, serde_json::Error> {
333 match serde_json::from_str::<serde_json::Value>(text) {
334 Ok(value) => Ok(value),
335 Err(first) => {
336 let stripped = strip_line_comments(text);
337 serde_json::from_str::<serde_json::Value>(&stripped).map_err(|_| first)
338 }
339 }
340}
341
342pub fn save_settings(settings: &Settings) -> Result<(), String> {
350 let path = config::settings_path().map_err(|e| e.to_string())?;
351 let fallback = if std::env::var_os(config::CONFIG_DIR_ENV).is_some() {
352 None
353 } else {
354 dirs::home_dir().map(|home| home.join(".pi/agent/settings.json"))
355 };
356 save_settings_to_path(settings, &path, fallback.as_deref())
357}
358
359pub fn save_project_settings(cwd: &Path, settings: &Settings) -> Result<(), String> {
362 save_settings_to_path(
363 settings,
364 &cwd.join(".rpi/settings.json"),
365 Some(&cwd.join(".pi/settings.json")),
366 )
367}
368
369fn save_settings_to_path(
370 settings: &Settings,
371 path: &Path,
372 fallback: Option<&Path>,
373) -> Result<(), String> {
374 let mut merged = match std::fs::read_to_string(path) {
376 Ok(text) => parse_settings_value(&text).map_err(|error| {
377 format!(
378 "cannot parse existing settings file {}: {error}",
379 path.display()
380 )
381 })?,
382 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
383 read_fallback_settings_value(path, fallback)?
384 .unwrap_or_else(|| serde_json::Value::Object(Default::default()))
385 }
386 Err(error) => {
387 return Err(format!(
388 "cannot read existing settings file {}: {error}",
389 path.display()
390 ));
391 }
392 };
393 let obj = merged
394 .as_object_mut()
395 .ok_or_else(|| format!("settings file {} is not an object", path.display()))?;
396 for (key, val) in [
397 ("defaultProvider", settings.default_provider.as_ref()),
398 ("defaultModel", settings.default_model.as_ref()),
399 (
400 "defaultThinkingLevel",
401 settings.default_thinking_level.as_ref(),
402 ),
403 ("theme", settings.theme.as_ref()),
404 ] {
405 match val {
406 Some(v) => {
407 obj.insert(key.to_string(), serde_json::Value::String(v.clone()));
408 }
409 None => {
410 obj.remove(key);
411 }
412 }
413 }
414 match &settings.scoped_models {
415 Some(list) if !list.is_empty() => {
416 obj.insert(
417 "scopedModels".to_string(),
418 serde_json::Value::Array(
419 list.iter()
420 .map(|m| serde_json::Value::String(m.clone()))
421 .collect(),
422 ),
423 );
424 }
425 _ => {
426 obj.remove("scopedModels");
427 }
428 }
429 match &settings.packages {
430 Some(list) if !list.is_empty() => {
431 obj.insert(
432 "packages".to_string(),
433 serde_json::to_value(list).map_err(|e| e.to_string())?,
434 );
435 }
436 _ => {
437 obj.remove("packages");
438 }
439 }
440 match &settings.npm_command {
441 Some(command) => {
442 obj.insert(
443 "npmCommand".to_string(),
444 serde_json::to_value(command).map_err(|e| e.to_string())?,
445 );
446 }
447 None => {
448 obj.remove("npmCommand");
449 }
450 }
451 for (key, values) in [
452 ("skillDirs", settings.skill_dirs.as_ref()),
453 ("promptDirs", settings.prompt_dirs.as_ref()),
454 ("extensionDirs", settings.extension_dirs.as_ref()),
455 ] {
456 match values {
457 Some(list) if !list.is_empty() => {
458 obj.insert(
459 key.to_string(),
460 serde_json::Value::Array(
461 list.iter()
462 .map(|path| serde_json::Value::String(path.clone()))
463 .collect(),
464 ),
465 );
466 }
467 _ => {
468 obj.remove(key);
469 }
470 }
471 }
472 match &settings.keybindings {
473 Some(bindings) => {
474 obj.insert(
475 "keybindings".to_string(),
476 serde_json::to_value(bindings).map_err(|e| e.to_string())?,
477 );
478 }
479 None => {
480 obj.remove("keybindings");
481 }
482 }
483 match settings.double_escape_action.as_deref() {
484 Some(action) if !action.trim().is_empty() => {
485 obj.insert(
486 "doubleEscapeAction".to_string(),
487 serde_json::Value::String(action.to_string()),
488 );
489 }
490 _ => {
491 obj.remove("doubleEscapeAction");
492 }
493 }
494 for (key, value) in [
495 (
496 "hideThinkingBlock",
497 settings.hide_thinking_block.map(serde_json::Value::Bool),
498 ),
499 (
500 "quietStartup",
501 settings.quiet_startup.map(serde_json::Value::Bool),
502 ),
503 (
504 "showTerminalProgress",
505 settings.show_terminal_progress.map(serde_json::Value::Bool),
506 ),
507 (
508 "editorPaddingX",
509 settings
510 .editor_padding_x
511 .map(|v| serde_json::Value::Number(v.into())),
512 ),
513 (
514 "autocompleteMaxVisible",
515 settings
516 .autocomplete_max_visible
517 .map(|v| serde_json::Value::Number(v.into())),
518 ),
519 ] {
520 match value {
521 Some(value) => {
522 obj.insert(key.to_string(), value);
523 }
524 None => {
525 obj.remove(key);
526 }
527 }
528 }
529 if let Some(parent) = path.parent() {
530 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
531 }
532 let text = serde_json::to_string_pretty(&merged).map_err(|e| e.to_string())?;
533 config::atomic_write(path, text.as_bytes()).map_err(|e| e.to_string())
534}
535
536fn read_fallback_settings_value(
540 target: &Path,
541 fallback: Option<&Path>,
542) -> Result<Option<serde_json::Value>, String> {
543 let Some(path) = fallback else {
544 return Ok(None);
545 };
546 if path == target {
547 return Ok(None);
548 }
549 match std::fs::read_to_string(&path) {
550 Ok(text) => parse_settings_value(&text).map(Some).map_err(|error| {
551 format!(
552 "cannot parse fallback settings file {}: {error}",
553 path.display()
554 )
555 }),
556 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
557 Err(error) => Err(format!(
558 "cannot read fallback settings file {}: {error}",
559 path.display()
560 )),
561 }
562}
563
564#[cfg(test)]
565mod tests {
566 use super::*;
567 use crate::config::test_support::env_lock;
568
569 struct TempConfig {
571 _guard: std::sync::MutexGuard<'static, ()>,
572 _tmp: tempfile::TempDir,
573 prev: Option<std::ffi::OsString>,
574 }
575 impl TempConfig {
576 fn new() -> Self {
577 let guard = env_lock().lock().unwrap();
578 let prev = std::env::var_os(config::CONFIG_DIR_ENV);
579 let tmp = tempfile::TempDir::new().unwrap();
580 std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
581 Self {
582 _guard: guard,
583 _tmp: tmp,
584 prev,
585 }
586 }
587 }
588 impl Drop for TempConfig {
589 fn drop(&mut self) {
590 match self.prev.take() {
591 Some(v) => std::env::set_var(config::CONFIG_DIR_ENV, v),
592 None => std::env::remove_var(config::CONFIG_DIR_ENV),
593 }
594 }
595 }
596
597 #[test]
598 fn missing_settings_is_default() {
599 let _cfg = TempConfig::new();
600 let s = load_settings().unwrap();
601 assert!(s.default_provider.is_none());
602 assert!(s.default_model.is_none());
603 assert!(s.default_thinking_level.is_none());
604 assert!(s.theme.is_none());
605 assert!(s.packages.is_none());
606 assert!(s.npm_command.is_none());
607 assert!(s.skill_dirs.is_none());
608 assert!(s.prompt_dirs.is_none());
609 assert!(s.extension_dirs.is_none());
610 }
611
612 #[test]
613 fn reads_honored_fields_and_ignores_unknown() {
614 let _cfg = TempConfig::new();
615 let path = config::settings_path().unwrap();
616 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
617 std::fs::write(
619 &path,
620 r#"{
621 "lastChangelogVersion": "1.0.0",
622 "defaultProvider": "anthropic",
623 "defaultModel": "claude-sonnet-5",
624 "defaultThinkingLevel": "high",
625 "theme": "dark",
626 "hideThinkingBlock": true,
627 "quietStartup": true,
628 "showTerminalProgress": false,
629 "editorPaddingX": 3,
630 "autocompleteMaxVisible": 7,
631 "compaction": { "threshold": 100 },
632 "npmCommand": ["mise", "exec", "node@20", "--", "npm"],
633 "packages": [
634 "some-pkg",
635 {
636 "source": "npm:filtered-pkg",
637 "autoload": false,
638 "extensions": ["dist/index.js"],
639 "skills": ["skills/review"],
640 "prompts": ["prompts/review.md"],
641 "themes": ["themes/dark.json"],
642 "futureFilter": { "enabled": true }
643 }
644 ]
645 }"#,
646 )
647 .unwrap();
648 let s = load_settings().unwrap();
649 assert_eq!(s.default_provider.as_deref(), Some("anthropic"));
650 assert_eq!(s.default_model.as_deref(), Some("claude-sonnet-5"));
651 assert_eq!(s.default_thinking_level.as_deref(), Some("high"));
652 assert_eq!(s.theme.as_deref(), Some("dark"));
653 assert_eq!(
654 s.npm_command.as_deref(),
655 Some(
656 ["mise", "exec", "node@20", "--", "npm"]
657 .map(String::from)
658 .as_slice()
659 )
660 );
661 let packages = s.packages.as_deref().unwrap();
662 assert_eq!(packages[0], PackageSetting::from("some-pkg"));
663 assert_eq!(packages[1].source(), "npm:filtered-pkg");
664 let PackageSetting::Filtered(filter) = &packages[1] else {
665 panic!("expected an object-form package setting");
666 };
667 assert_eq!(filter.autoload, Some(false));
668 assert_eq!(
669 filter.extensions.as_deref(),
670 Some(["dist/index.js".into()].as_slice())
671 );
672 assert_eq!(
673 filter.skills.as_deref(),
674 Some(["skills/review".into()].as_slice())
675 );
676 assert_eq!(
677 filter.prompts.as_deref(),
678 Some(["prompts/review.md".into()].as_slice())
679 );
680 assert_eq!(
681 filter.themes.as_deref(),
682 Some(["themes/dark.json".into()].as_slice())
683 );
684 assert_eq!(filter.unknown["futureFilter"]["enabled"], true);
685 assert_eq!(s.hide_thinking_block, Some(true));
686 assert_eq!(s.quiet_startup, Some(true));
687 assert_eq!(s.show_terminal_progress, Some(false));
688 assert_eq!(s.editor_padding_x, Some(3));
689 assert_eq!(s.autocomplete_max_visible, Some(7));
690 }
691
692 #[test]
693 fn rpi_project_settings_mask_native_pi_settings() {
694 let tmp = tempfile::tempdir().unwrap();
695 std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
696 std::fs::create_dir_all(tmp.path().join(".pi")).unwrap();
697 std::fs::write(
698 tmp.path().join(".rpi/settings.json"),
699 r#"{"skillDirs":["rpi-skills"],"extensions":["rpi-ext"]}"#,
700 )
701 .unwrap();
702 std::fs::write(
703 tmp.path().join(".pi/settings.json"),
704 r#"{"skills":["pi-skills"],"extensionDirs":["pi-ext"]}"#,
705 )
706 .unwrap();
707
708 let settings = load_project_settings(tmp.path());
709 assert_eq!(settings.len(), 1);
710 assert_eq!(
711 settings[0].skill_dirs.as_deref(),
712 Some(["rpi-skills".to_string()].as_slice())
713 );
714 assert_eq!(
715 settings[0].extension_dirs.as_deref(),
716 Some(["rpi-ext".to_string()].as_slice())
717 );
718 }
719
720 #[test]
721 fn native_pi_project_settings_are_a_fallback() {
722 let tmp = tempfile::tempdir().unwrap();
723 std::fs::create_dir_all(tmp.path().join(".pi")).unwrap();
724 std::fs::write(
725 tmp.path().join(".pi/settings.json"),
726 r#"{"defaultProvider":"native-provider","defaultModel":"native-model"}"#,
727 )
728 .unwrap();
729
730 let settings = load_project_settings_with_paths(tmp.path());
731 assert_eq!(settings.len(), 1);
732 assert!(settings[0].0.ends_with(".pi/settings.json"));
733 assert_eq!(
734 settings[0].1.default_provider.as_deref(),
735 Some("native-provider")
736 );
737 }
738
739 #[test]
740 fn malformed_rpi_project_settings_mask_native_pi_fallback() {
741 let tmp = tempfile::tempdir().unwrap();
742 std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
743 std::fs::create_dir_all(tmp.path().join(".pi")).unwrap();
744 std::fs::write(tmp.path().join(".rpi/settings.json"), "{ malformed").unwrap();
745 std::fs::write(
746 tmp.path().join(".pi/settings.json"),
747 r#"{"packages":["npm:must-not-load"]}"#,
748 )
749 .unwrap();
750
751 assert!(load_project_settings_with_paths(tmp.path()).is_empty());
752 assert!(load_active_project_settings(tmp.path()).is_err());
753 }
754
755 #[test]
756 fn trusted_project_model_defaults_override_global_defaults() {
757 let _cfg = TempConfig::new();
758 let global = config::settings_path().unwrap();
759 std::fs::create_dir_all(global.parent().unwrap()).unwrap();
760 std::fs::write(
761 global,
762 r#"{"defaultProvider":"global","defaultModel":"global-model","theme":"dark"}"#,
763 )
764 .unwrap();
765 let project = tempfile::tempdir().unwrap();
766 std::fs::create_dir_all(project.path().join(".rpi")).unwrap();
767 std::fs::write(
768 project.path().join(".rpi/settings.json"),
769 r#"{"defaultProvider":"project","defaultModel":"project-model"}"#,
770 )
771 .unwrap();
772
773 let trusted = load_effective_model_settings(project.path(), true).unwrap();
774 assert_eq!(trusted.default_provider.as_deref(), Some("project"));
775 assert_eq!(trusted.default_model.as_deref(), Some("project-model"));
776 assert_eq!(trusted.theme.as_deref(), Some("dark"));
777
778 let untrusted = load_effective_model_settings(project.path(), false).unwrap();
779 assert_eq!(untrusted.default_provider.as_deref(), Some("global"));
780 assert_eq!(untrusted.default_model.as_deref(), Some("global-model"));
781 }
782
783 #[test]
784 fn tolerates_line_comments() {
785 let _cfg = TempConfig::new();
786 let path = config::settings_path().unwrap();
787 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
788 std::fs::write(
789 &path,
790 "{\n // my default\n \"defaultModel\": \"glm-5\",\n \"theme\": \"light\"\n}\n",
791 )
792 .unwrap();
793 let s = load_settings().unwrap();
794 assert_eq!(s.default_model.as_deref(), Some("glm-5"));
795 assert_eq!(s.theme.as_deref(), Some("light"));
796 }
797
798 #[test]
799 fn malformed_is_error() {
800 let _cfg = TempConfig::new();
801 let path = config::settings_path().unwrap();
802 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
803 std::fs::write(&path, "{ not json").unwrap();
804 assert!(matches!(load_settings(), Err(ConfigError::Json { .. })));
805 }
806}
807
808#[cfg(test)]
809mod scoped_tests {
810 use super::*;
811 use crate::config::test_support::env_lock;
812
813 fn with_temp_env() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
814 let guard = env_lock().lock().unwrap();
815 let tmp = tempfile::TempDir::new().unwrap();
816 std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
817 (tmp, guard)
818 }
819
820 #[test]
821 fn save_load_scoped_models_roundtrip() {
822 let (_tmp, _guard) = with_temp_env();
823 let mut s = Settings::default();
824 s.scoped_models = Some(vec!["a".into(), "b".into()]);
825 save_settings(&s).unwrap();
826 let loaded = load_settings().unwrap();
827 assert_eq!(
828 loaded.scoped_models,
829 Some(vec!["a".to_string(), "b".to_string()])
830 );
831 let mut s2 = load_settings().unwrap();
833 s2.scoped_models = None;
834 save_settings(&s2).unwrap();
835 assert_eq!(load_settings().unwrap().scoped_models, None);
836 }
837
838 #[test]
839 fn save_uses_atomic_sibling_replacement() {
840 let (_tmp, _guard) = with_temp_env();
841 let path = config::settings_path().unwrap();
842 std::fs::write(&path, r#"{"theme":"dark","piOnlyField":true}"#).unwrap();
843
844 let mut settings = load_settings().unwrap();
845 settings.theme = Some("light".into());
846 save_settings(&settings).unwrap();
847
848 let saved: serde_json::Value =
849 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
850 assert_eq!(saved["theme"], "light");
851 assert_eq!(saved["piOnlyField"], true);
852 let temp = path.with_file_name(format!(
855 ".{}.tmp",
856 path.file_name().and_then(|name| name.to_str()).unwrap()
857 ));
858 assert!(!temp.exists(), "atomic staging file should not remain");
859 }
860
861 #[test]
862 fn save_preserves_unknown_fields() {
863 let (_tmp, _guard) = with_temp_env();
864 let path = config::settings_path().unwrap();
865 std::fs::write(
866 &path,
867 r#"{
868 "piOnlyField": "keep-me",
869 "theme": "dark",
870 "npmCommand": ["pnpm"],
871 "packages": [{
872 "source": "npm:future-package",
873 "autoload": false,
874 "futureFilter": { "enabled": true }
875 }]
876 }"#,
877 )
878 .unwrap();
879 let mut s = load_settings().unwrap();
880 assert_eq!(s.npm_command, Some(vec!["pnpm".to_string()]));
881 s.scoped_models = Some(vec!["m1".into()]);
882 save_settings(&s).unwrap();
883 let raw: serde_json::Value =
884 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
885 assert_eq!(raw["piOnlyField"], "keep-me");
886 assert_eq!(raw["scopedModels"][0], "m1");
887 assert_eq!(raw["theme"], "dark");
888 assert_eq!(raw["npmCommand"][0], "pnpm");
889 assert_eq!(raw["packages"][0]["source"], "npm:future-package");
890 assert_eq!(raw["packages"][0]["autoload"], false);
891 assert_eq!(raw["packages"][0]["futureFilter"]["enabled"], true);
892 }
893
894 #[test]
895 fn save_preserves_unknown_fields_and_packages_with_line_comments() {
896 let (_tmp, _guard) = with_temp_env();
897 let path = config::settings_path().unwrap();
898 let original = r#"{
899 // Native Pi permits comments in settings files.
900 "piOnlyField": { "keep": true },
901 "theme": "dark",
902 "packages": [
903 // Keep the package object and fields that rpi does not use.
904 {
905 "source": "npm:future-package",
906 "autoload": false,
907 "futureFilter": { "enabled": true }
908 }
909 ]
910 }
911 "#;
912 std::fs::write(&path, original).unwrap();
913
914 let mut settings = load_settings().unwrap();
915 settings.scoped_models = Some(vec!["m1".into()]);
916 save_settings(&settings).unwrap();
917
918 let raw: serde_json::Value =
921 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
922 assert_eq!(raw["piOnlyField"]["keep"], true);
923 assert_eq!(raw["theme"], "dark");
924 assert_eq!(raw["packages"][0]["source"], "npm:future-package");
925 assert_eq!(raw["packages"][0]["autoload"], false);
926 assert_eq!(raw["packages"][0]["futureFilter"]["enabled"], true);
927 assert_eq!(raw["scopedModels"][0], "m1");
928 }
929
930 #[test]
931 fn save_fails_closed_for_unparseable_existing_settings() {
932 let (_tmp, _guard) = with_temp_env();
933 let path = config::settings_path().unwrap();
934 let original = b"{\n // keep this file intact\n \"piOnlyField\": \"keep-me\"\n";
937 std::fs::write(&path, original).unwrap();
938
939 let mut settings = Settings::default();
940 settings.theme = Some("light".into());
941 let error =
942 save_settings(&settings).expect_err("malformed settings must not be overwritten");
943 assert!(error.contains("cannot parse existing settings file"));
944 assert_eq!(std::fs::read(&path).unwrap(), original);
945 }
946
947 #[test]
948 fn project_save_seeds_from_native_pi_without_losing_unknown_fields() {
949 let tmp = tempfile::tempdir().unwrap();
950 let legacy = tmp.path().join(".pi/settings.json");
951 std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
952 std::fs::write(
953 &legacy,
954 r#"{
955 // Preserve fields from native Pi on the first rpi save.
956 "piOnlyField": { "keep": true },
957 "packages": ["npm:existing"]
958 }"#,
959 )
960 .unwrap();
961
962 let mut settings = load_project_settings_for_write(tmp.path()).unwrap();
963 settings.theme = Some("dark".into());
964 save_project_settings(tmp.path(), &settings).unwrap();
965
966 let preferred = tmp.path().join(".rpi/settings.json");
967 let saved: serde_json::Value =
968 serde_json::from_str(&std::fs::read_to_string(preferred).unwrap()).unwrap();
969 assert_eq!(saved["piOnlyField"]["keep"], true);
970 assert_eq!(saved["packages"][0], "npm:existing");
971 assert_eq!(saved["theme"], "dark");
972 }
973
974 #[test]
975 fn project_save_fails_closed_for_malformed_native_fallback() {
976 let tmp = tempfile::tempdir().unwrap();
977 let legacy = tmp.path().join(".pi/settings.json");
978 std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
979 std::fs::write(&legacy, "{ malformed").unwrap();
980
981 assert!(load_project_settings_for_write(tmp.path()).is_err());
982 assert!(!tmp.path().join(".rpi/settings.json").exists());
983 }
984}