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