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