1use crate::agent::QueueMode;
4use crate::error::{Error, Result};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::fs::File;
8use std::io::Write as _;
9use std::path::{Path, PathBuf};
10use std::sync::{Mutex, OnceLock};
11use std::time::Duration;
12use tempfile::NamedTempFile;
13
14#[derive(Debug, Clone, Default, Serialize, Deserialize)]
16#[serde(default)]
17pub struct Config {
18 pub theme: Option<String>,
20 #[serde(alias = "hideThinkingBlock")]
21 pub hide_thinking_block: Option<bool>,
22 #[serde(alias = "showHardwareCursor")]
23 pub show_hardware_cursor: Option<bool>,
24 #[serde(alias = "disableMouseCapture", alias = "noMouseCapture")]
38 pub disable_mouse_capture: Option<bool>,
39
40 #[serde(alias = "defaultProvider")]
42 pub default_provider: Option<String>,
43 #[serde(alias = "defaultModel")]
44 pub default_model: Option<String>,
45 #[serde(alias = "defaultThinkingLevel")]
46 pub default_thinking_level: Option<String>,
47 #[serde(alias = "enabledModels")]
48 pub enabled_models: Option<Vec<String>>,
49
50 #[serde(alias = "requestTimeoutSecs", alias = "requestTimeoutSeconds")]
61 pub request_timeout_secs: Option<u64>,
62
63 #[serde(alias = "steeringMode", alias = "queueMode")]
65 pub steering_mode: Option<String>,
66 #[serde(alias = "followUpMode")]
67 pub follow_up_mode: Option<String>,
68
69 #[serde(alias = "checkForUpdates")]
71 pub check_for_updates: Option<bool>,
72
73 #[serde(alias = "quietStartup")]
75 pub quiet_startup: Option<bool>,
76 #[serde(alias = "collapseChangelog")]
77 pub collapse_changelog: Option<bool>,
78 #[serde(alias = "lastChangelogVersion")]
79 pub last_changelog_version: Option<String>,
80 #[serde(alias = "doubleEscapeAction")]
81 pub double_escape_action: Option<String>,
82 #[serde(alias = "editorPaddingX")]
83 pub editor_padding_x: Option<u32>,
84 #[serde(alias = "autocompleteMaxVisible")]
85 pub autocomplete_max_visible: Option<u32>,
86 #[serde(alias = "sessionPickerInput")]
88 pub session_picker_input: Option<u32>,
89 #[serde(alias = "sessionStore", alias = "sessionBackend")]
91 pub session_store: Option<String>,
92 #[serde(alias = "sessionDurability")]
94 pub session_durability: Option<String>,
95
96 pub compaction: Option<CompactionSettings>,
98
99 #[serde(alias = "branchSummary")]
101 pub branch_summary: Option<BranchSummarySettings>,
102
103 pub retry: Option<RetrySettings>,
105
106 #[serde(alias = "shellPath")]
108 pub shell_path: Option<String>,
109 #[serde(alias = "shellCommandPrefix")]
110 pub shell_command_prefix: Option<String>,
111 #[serde(alias = "ghPath")]
113 pub gh_path: Option<String>,
114
115 pub images: Option<ImageSettings>,
117
118 pub markdown: Option<MarkdownSettings>,
120
121 pub terminal: Option<TerminalSettings>,
123
124 #[serde(alias = "thinkingBudgets")]
126 pub thinking_budgets: Option<ThinkingBudgets>,
127
128 pub packages: Option<Vec<PackageSource>>,
130 pub extensions: Option<Vec<String>>,
131 pub skills: Option<Vec<String>>,
132 pub prompts: Option<Vec<String>>,
133 pub themes: Option<Vec<String>>,
134 #[serde(alias = "enableSkillCommands")]
135 pub enable_skill_commands: Option<bool>,
136
137 #[serde(alias = "failClosedHooks")]
139 pub fail_closed_hooks: Option<bool>,
140
141 #[serde(alias = "extensionPolicy")]
143 pub extension_policy: Option<ExtensionPolicyConfig>,
144
145 #[serde(alias = "repairPolicy")]
147 pub repair_policy: Option<RepairPolicyConfig>,
148
149 #[serde(alias = "extensionRisk")]
151 pub extension_risk: Option<ExtensionRiskConfig>,
152}
153
154#[derive(Debug, Clone, Default, Serialize, Deserialize)]
170#[serde(default)]
171pub struct ExtensionPolicyConfig {
172 pub profile: Option<String>,
175 #[serde(alias = "defaultPermissive")]
177 pub default_permissive: Option<bool>,
178 #[serde(alias = "allowDangerous")]
180 pub allow_dangerous: Option<bool>,
181}
182
183#[derive(Debug, Clone, Default, Serialize, Deserialize)]
187#[serde(default)]
188pub struct RepairPolicyConfig {
189 pub mode: Option<String>,
191}
192
193#[derive(Debug, Clone, Default, Serialize, Deserialize)]
197#[serde(default)]
198pub struct ExtensionRiskConfig {
199 pub enabled: Option<bool>,
201 pub alpha: Option<f64>,
203 #[serde(alias = "windowSize")]
205 pub window_size: Option<u32>,
206 #[serde(alias = "ledgerLimit")]
208 pub ledger_limit: Option<u32>,
209 #[serde(alias = "decisionTimeoutMs")]
211 pub decision_timeout_ms: Option<u64>,
212 #[serde(alias = "failClosed")]
214 pub fail_closed: Option<bool>,
215 pub enforce: Option<bool>,
219}
220
221#[derive(Debug, Clone)]
223pub struct ResolvedExtensionPolicy {
224 pub requested_profile: String,
226 pub effective_profile: String,
228 pub profile_source: &'static str,
230 pub allow_dangerous: bool,
232 pub policy: crate::extensions::ExtensionPolicy,
234 pub dangerous_opt_in_audit: Option<crate::extensions::DangerousOptInAuditEntry>,
237}
238
239#[derive(Debug, Clone)]
241pub struct ResolvedRepairPolicy {
242 pub requested_mode: String,
244 pub effective_mode: crate::extensions::RepairPolicyMode,
246 pub source: &'static str,
248}
249
250#[derive(Debug, Clone)]
252pub struct ResolvedExtensionRisk {
253 pub source: &'static str,
255 pub settings: crate::extensions::RuntimeRiskConfig,
257}
258
259#[derive(Debug, Clone, Default, Serialize, Deserialize)]
260#[serde(default)]
261pub struct CompactionSettings {
262 pub enabled: Option<bool>,
263 #[serde(alias = "reserveTokens")]
264 pub reserve_tokens: Option<u32>,
265 #[serde(alias = "keepRecentTokens")]
266 pub keep_recent_tokens: Option<u32>,
267}
268
269#[derive(Debug, Clone, Default, Serialize, Deserialize)]
270#[serde(default)]
271pub struct BranchSummarySettings {
272 #[serde(alias = "reserveTokens")]
273 pub reserve_tokens: Option<u32>,
274}
275
276#[derive(Debug, Clone, Default, Serialize, Deserialize)]
277#[serde(default)]
278pub struct RetrySettings {
279 pub enabled: Option<bool>,
280 #[serde(alias = "maxRetries")]
281 pub max_retries: Option<u32>,
282 #[serde(alias = "baseDelayMs")]
283 pub base_delay_ms: Option<u32>,
284 #[serde(alias = "maxDelayMs")]
285 pub max_delay_ms: Option<u32>,
286}
287
288#[derive(Debug, Clone, Default, Serialize, Deserialize)]
289#[serde(default)]
290pub struct ImageSettings {
291 #[serde(alias = "autoResize")]
292 pub auto_resize: Option<bool>,
293 #[serde(alias = "blockImages")]
294 pub block_images: Option<bool>,
295}
296
297#[derive(Debug, Clone, Default, Serialize, Deserialize)]
298#[serde(default)]
299pub struct MarkdownSettings {
300 #[serde(
302 alias = "codeBlockIndent",
303 deserialize_with = "deserialize_code_block_indent_option"
304 )]
305 pub code_block_indent: Option<u8>,
306}
307
308#[derive(Debug, Clone, Default, Serialize, Deserialize)]
309#[serde(default)]
310pub struct TerminalSettings {
311 #[serde(alias = "showImages")]
312 pub show_images: Option<bool>,
313 #[serde(alias = "clearOnShrink")]
314 pub clear_on_shrink: Option<bool>,
315}
316
317#[derive(Debug, Clone, Default, Serialize, Deserialize)]
318#[serde(default)]
319pub struct ThinkingBudgets {
320 pub minimal: Option<u32>,
321 pub low: Option<u32>,
322 pub medium: Option<u32>,
323 pub high: Option<u32>,
324 pub xhigh: Option<u32>,
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize)]
328#[serde(untagged)]
329pub enum PackageSource {
330 String(String),
331 Detailed {
332 source: String,
333 #[serde(default)]
334 local: Option<bool>,
335 #[serde(default)]
336 kind: Option<String>,
337 },
338}
339
340#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
341pub enum SettingsScope {
342 Global,
343 Project,
344}
345
346const fn effective_profile_str(profile: crate::extensions::PolicyProfile) -> &'static str {
348 match profile {
349 crate::extensions::PolicyProfile::Safe => "safe",
350 crate::extensions::PolicyProfile::Standard => "balanced",
351 crate::extensions::PolicyProfile::Permissive => "permissive",
352 }
353}
354
355impl Config {
356 pub fn load() -> Result<Self> {
358 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
359 let config_path = Self::config_path_override_from_env(&cwd);
360 Self::load_with_roots(config_path.as_deref(), &Self::global_dir(), &cwd)
361 }
362
363 #[must_use]
365 pub(crate) fn resolve_config_override_path(path: &Path, cwd: &Path) -> PathBuf {
366 if path.is_absolute() {
367 path.to_path_buf()
368 } else {
369 cwd.join(path)
370 }
371 }
372
373 #[must_use]
375 pub fn config_path_override_from_env(cwd: &Path) -> Option<PathBuf> {
376 std::env::var_os("PI_CONFIG_PATH")
377 .map(PathBuf::from)
378 .map(|path| Self::resolve_config_override_path(&path, cwd))
379 }
380
381 pub fn global_dir() -> PathBuf {
383 global_dir_from_env(env_lookup)
384 }
385
386 pub fn project_dir() -> PathBuf {
388 PathBuf::from(".pi")
389 }
390
391 pub fn sessions_dir() -> PathBuf {
393 let global_dir = Self::global_dir();
394 sessions_dir_from_env(env_lookup, &global_dir)
395 }
396
397 pub fn package_dir() -> PathBuf {
399 let global_dir = Self::global_dir();
400 package_dir_from_env(env_lookup, &global_dir)
401 }
402
403 pub fn extension_index_path() -> PathBuf {
405 let global_dir = Self::global_dir();
406 extension_index_path_from_env(env_lookup, &global_dir)
407 }
408
409 pub fn auth_path() -> PathBuf {
411 Self::global_dir().join("auth.json")
412 }
413
414 pub fn permissions_path() -> PathBuf {
416 Self::global_dir().join("extension-permissions.json")
417 }
418
419 fn load_global() -> Result<Self> {
421 let path = Self::global_dir().join("settings.json");
422 Self::load_from_path(&path)
423 }
424
425 fn load_project() -> Result<Self> {
427 let path = Self::project_dir().join("settings.json");
428 Self::load_from_path(&path)
429 }
430
431 fn load_from_path(path: &std::path::Path) -> Result<Self> {
433 if !path.exists() {
434 return Ok(Self::default());
435 }
436
437 let content = std::fs::read_to_string(path)?;
438 if content.trim().is_empty() {
439 return Ok(Self::default());
440 }
441
442 let config: Self = serde_json::from_str(&content).map_err(|e| {
443 Error::config(format!(
444 "Failed to parse settings file {}: {e}",
445 path.display()
446 ))
447 })?;
448 Ok(config)
449 }
450
451 pub fn load_with_roots(
452 config_path: Option<&std::path::Path>,
453 global_dir: &std::path::Path,
454 cwd: &std::path::Path,
455 ) -> Result<Self> {
456 if let Some(path) = config_path {
457 let config = Self::load_from_path(&Self::resolve_config_override_path(path, cwd))?;
458 config.emit_queue_mode_diagnostics();
459 return Ok(config);
460 }
461
462 let global = Self::load_from_path(&global_dir.join("settings.json"))?;
463 let project = Self::load_from_path(&cwd.join(Self::project_dir()).join("settings.json"))?;
464 let merged = Self::merge(global, project);
465 merged.emit_queue_mode_diagnostics();
466 Ok(merged)
467 }
468
469 pub fn settings_path_with_roots(
470 scope: SettingsScope,
471 global_dir: &Path,
472 cwd: &Path,
473 ) -> PathBuf {
474 match scope {
475 SettingsScope::Global => global_dir.join("settings.json"),
476 SettingsScope::Project => cwd.join(Self::project_dir()).join("settings.json"),
477 }
478 }
479
480 pub fn patch_settings_with_roots(
481 scope: SettingsScope,
482 global_dir: &Path,
483 cwd: &Path,
484 patch: Value,
485 ) -> Result<PathBuf> {
486 let path = Self::settings_path_with_roots(scope, global_dir, cwd);
487 patch_settings_file(&path, patch)?;
488 Ok(path)
489 }
490
491 pub fn patch_settings_to_path(path: &Path, patch: Value) -> Result<PathBuf> {
492 patch_settings_file(path, patch)?;
493 Ok(path.to_path_buf())
494 }
495
496 pub fn merge(base: Self, other: Self) -> Self {
498 Self {
499 theme: other.theme.or(base.theme),
501 hide_thinking_block: other.hide_thinking_block.or(base.hide_thinking_block),
502 show_hardware_cursor: other.show_hardware_cursor.or(base.show_hardware_cursor),
503 disable_mouse_capture: other.disable_mouse_capture.or(base.disable_mouse_capture),
504
505 default_provider: other.default_provider.or(base.default_provider),
507 default_model: other.default_model.or(base.default_model),
508 default_thinking_level: other.default_thinking_level.or(base.default_thinking_level),
509 enabled_models: other.enabled_models.or(base.enabled_models),
510 request_timeout_secs: other.request_timeout_secs.or(base.request_timeout_secs),
511
512 steering_mode: other.steering_mode.or(base.steering_mode),
514 follow_up_mode: other.follow_up_mode.or(base.follow_up_mode),
515
516 check_for_updates: other.check_for_updates.or(base.check_for_updates),
518
519 quiet_startup: other.quiet_startup.or(base.quiet_startup),
521 collapse_changelog: other.collapse_changelog.or(base.collapse_changelog),
522 last_changelog_version: other.last_changelog_version.or(base.last_changelog_version),
523 double_escape_action: other.double_escape_action.or(base.double_escape_action),
524 editor_padding_x: other.editor_padding_x.or(base.editor_padding_x),
525 autocomplete_max_visible: other
526 .autocomplete_max_visible
527 .or(base.autocomplete_max_visible),
528 session_picker_input: other.session_picker_input.or(base.session_picker_input),
529 session_store: other.session_store.or(base.session_store),
530 session_durability: other.session_durability.or(base.session_durability),
531
532 compaction: merge_compaction(base.compaction, other.compaction),
534
535 branch_summary: merge_branch_summary(base.branch_summary, other.branch_summary),
537
538 retry: merge_retry(base.retry, other.retry),
540
541 shell_path: other.shell_path.or(base.shell_path),
543 shell_command_prefix: other.shell_command_prefix.or(base.shell_command_prefix),
544 gh_path: other.gh_path.or(base.gh_path),
545
546 images: merge_images(base.images, other.images),
548
549 markdown: merge_markdown(base.markdown, other.markdown),
551
552 terminal: merge_terminal(base.terminal, other.terminal),
554
555 thinking_budgets: merge_thinking_budgets(base.thinking_budgets, other.thinking_budgets),
557
558 packages: other.packages.or(base.packages),
560 extensions: other.extensions.or(base.extensions),
561 skills: other.skills.or(base.skills),
562 prompts: other.prompts.or(base.prompts),
563 themes: other.themes.or(base.themes),
564 enable_skill_commands: other.enable_skill_commands.or(base.enable_skill_commands),
565 fail_closed_hooks: other.fail_closed_hooks.or(base.fail_closed_hooks),
566
567 extension_policy: merge_extension_policy(base.extension_policy, other.extension_policy),
569
570 repair_policy: merge_repair_policy(base.repair_policy, other.repair_policy),
572
573 extension_risk: merge_extension_risk(base.extension_risk, other.extension_risk),
575 }
576 }
577
578 pub fn compaction_enabled(&self) -> bool {
581 self.compaction
582 .as_ref()
583 .and_then(|c| c.enabled)
584 .unwrap_or(true)
585 }
586
587 pub fn steering_queue_mode(&self) -> QueueMode {
588 parse_queue_mode_or_default(self.steering_mode.as_deref())
589 }
590
591 pub fn follow_up_queue_mode(&self) -> QueueMode {
592 parse_queue_mode_or_default(self.follow_up_mode.as_deref())
593 }
594
595 pub fn compaction_reserve_tokens(&self) -> u32 {
596 self.compaction
597 .as_ref()
598 .and_then(|c| c.reserve_tokens)
599 .unwrap_or(16384)
600 }
601
602 pub fn compaction_keep_recent_tokens(&self) -> u32 {
603 self.compaction
604 .as_ref()
605 .and_then(|c| c.keep_recent_tokens)
606 .unwrap_or(20000)
607 }
608
609 pub fn branch_summary_reserve_tokens(&self) -> u32 {
610 self.branch_summary
611 .as_ref()
612 .and_then(|b| b.reserve_tokens)
613 .unwrap_or_else(|| self.compaction_reserve_tokens())
614 }
615
616 pub fn retry_enabled(&self) -> bool {
617 self.retry.as_ref().and_then(|r| r.enabled).unwrap_or(true)
618 }
619
620 pub fn retry_max_retries(&self) -> u32 {
621 self.retry.as_ref().and_then(|r| r.max_retries).unwrap_or(3)
622 }
623
624 pub fn retry_base_delay_ms(&self) -> u32 {
625 self.retry
626 .as_ref()
627 .and_then(|r| r.base_delay_ms)
628 .unwrap_or(2000)
629 }
630
631 pub fn retry_max_delay_ms(&self) -> u32 {
632 self.retry
633 .as_ref()
634 .and_then(|r| r.max_delay_ms)
635 .unwrap_or(60000)
636 }
637
638 pub fn image_auto_resize(&self) -> bool {
639 self.images
640 .as_ref()
641 .and_then(|i| i.auto_resize)
642 .unwrap_or(true)
643 }
644
645 pub fn should_check_for_updates(&self) -> bool {
647 self.check_for_updates.unwrap_or(true)
648 }
649
650 pub fn image_block_images(&self) -> bool {
651 self.images
652 .as_ref()
653 .and_then(|i| i.block_images)
654 .unwrap_or(false)
655 }
656
657 pub fn terminal_show_images(&self) -> bool {
658 self.terminal
659 .as_ref()
660 .and_then(|t| t.show_images)
661 .unwrap_or(true)
662 }
663
664 pub fn terminal_clear_on_shrink(&self) -> bool {
665 self.terminal_clear_on_shrink_with_lookup(env_lookup)
666 }
667
668 fn terminal_clear_on_shrink_with_lookup<F>(&self, get_env: F) -> bool
669 where
670 F: Fn(&str) -> Option<String>,
671 {
672 if let Some(value) = self.terminal.as_ref().and_then(|t| t.clear_on_shrink) {
673 return value;
674 }
675 get_env("PI_CLEAR_ON_SHRINK").is_some_and(|value| value == "1")
676 }
677
678 pub fn thinking_budget(&self, level: &str) -> u32 {
679 let budgets = self.thinking_budgets.as_ref();
680 match level {
681 "minimal" => budgets.and_then(|b| b.minimal).unwrap_or(1024),
682 "low" => budgets.and_then(|b| b.low).unwrap_or(2048),
683 "medium" => budgets.and_then(|b| b.medium).unwrap_or(8192),
684 "high" => budgets.and_then(|b| b.high).unwrap_or(16384),
685 "xhigh" => budgets.and_then(|b| b.xhigh).unwrap_or(32768),
686 _ => 0,
687 }
688 }
689
690 pub fn markdown_code_block_indent(&self) -> u8 {
691 self.markdown
692 .as_ref()
693 .and_then(|m| m.code_block_indent)
694 .unwrap_or(2)
695 }
696
697 pub fn enable_skill_commands(&self) -> bool {
698 self.enable_skill_commands.unwrap_or(true)
699 }
700
701 pub fn fail_closed_hooks(&self) -> bool {
702 if let Some(value) = parse_env_bool("PI_EXTENSION_HOOKS_FAIL_CLOSED") {
703 return value;
704 }
705 self.fail_closed_hooks.unwrap_or(false)
706 }
707
708 pub fn resolve_extension_policy_with_metadata(
720 &self,
721 cli_override: Option<&str>,
722 ) -> ResolvedExtensionPolicy {
723 use crate::extensions::PolicyProfile;
724
725 let (requested_profile, profile_source) = cli_override.map_or_else(
727 || {
728 std::env::var("PI_EXTENSION_POLICY").map_or_else(
729 |_| {
730 self.extension_policy
731 .as_ref()
732 .and_then(|p| p.profile.clone())
733 .map_or_else(
734 || {
735 self.extension_policy
736 .as_ref()
737 .and_then(|p| p.default_permissive)
738 .map_or_else(
739 || ("permissive".to_string(), "default"),
740 |default_permissive| {
741 (
742 if default_permissive {
743 "permissive"
744 } else {
745 "safe"
746 }
747 .to_string(),
748 "config",
749 )
750 },
751 )
752 },
753 |value| (value, "config"),
754 )
755 },
756 |value| (value, "env"),
757 )
758 },
759 |value| (value.to_string(), "cli"),
760 );
761
762 let normalized_profile = requested_profile.to_ascii_lowercase();
763 let profile = if normalized_profile == "safe" {
764 PolicyProfile::Safe
765 } else if normalized_profile == "permissive" {
766 PolicyProfile::Permissive
767 } else if normalized_profile == "balanced" || normalized_profile == "standard" {
768 PolicyProfile::Standard
770 } else {
771 tracing::warn!(
773 requested = %normalized_profile,
774 fallback = "safe",
775 "Unknown extension policy profile; falling back to safe"
776 );
777 PolicyProfile::Safe
778 };
779
780 let mut policy = profile.to_policy();
781
782 let config_allows = self
784 .extension_policy
785 .as_ref()
786 .and_then(|p| p.allow_dangerous)
787 .unwrap_or(false);
788 let env_allows = std::env::var("PI_EXTENSION_ALLOW_DANGEROUS")
789 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
790 let allow_dangerous = config_allows || env_allows;
791
792 let dangerous_opt_in_audit = if allow_dangerous {
794 let source = if env_allows { "env" } else { "config" }.to_string();
795 let unblocked: Vec<String> = policy
796 .deny_caps
797 .iter()
798 .filter(|cap| *cap == "exec" || *cap == "env")
799 .cloned()
800 .collect();
801 if !unblocked.is_empty() {
802 tracing::warn!(
803 source = %source,
804 profile = %effective_profile_str(profile),
805 capabilities = ?unblocked,
806 "Dangerous capabilities explicitly unblocked via allow_dangerous"
807 );
808 }
809 Some(crate::extensions::DangerousOptInAuditEntry {
810 source,
811 profile: effective_profile_str(profile).to_string(),
812 capabilities_unblocked: unblocked,
813 })
814 } else {
815 None
816 };
817
818 if allow_dangerous {
819 policy.deny_caps.retain(|cap| cap != "exec" && cap != "env");
820 }
821
822 let effective_profile = effective_profile_str(profile);
823
824 ResolvedExtensionPolicy {
825 requested_profile,
826 effective_profile: effective_profile.to_string(),
827 profile_source,
828 allow_dangerous,
829 policy,
830 dangerous_opt_in_audit,
831 }
832 }
833
834 pub fn resolve_extension_policy(
835 &self,
836 cli_override: Option<&str>,
837 ) -> crate::extensions::ExtensionPolicy {
838 self.resolve_extension_policy_with_metadata(cli_override)
839 .policy
840 }
841
842 pub fn resolve_repair_policy_with_metadata(
850 &self,
851 cli_override: Option<&str>,
852 ) -> ResolvedRepairPolicy {
853 use crate::extensions::RepairPolicyMode;
854
855 let (requested_mode, source) = cli_override.map_or_else(
857 || {
858 std::env::var("PI_REPAIR_POLICY").map_or_else(
859 |_| {
860 self.repair_policy
861 .as_ref()
862 .and_then(|p| p.mode.clone())
863 .map_or_else(
864 || ("suggest".to_string(), "default"),
865 |value| (value, "config"),
866 )
867 },
868 |value| (value, "env"),
869 )
870 },
871 |value| (value.to_string(), "cli"),
872 );
873
874 let effective_mode = match requested_mode.trim().to_ascii_lowercase().as_str() {
875 "off" => RepairPolicyMode::Off,
876 "auto-safe" => RepairPolicyMode::AutoSafe,
877 "auto-strict" => RepairPolicyMode::AutoStrict,
878 _ => RepairPolicyMode::Suggest, };
880
881 ResolvedRepairPolicy {
882 requested_mode,
883 effective_mode,
884 source,
885 }
886 }
887
888 pub fn resolve_repair_policy(
889 &self,
890 cli_override: Option<&str>,
891 ) -> crate::extensions::RepairPolicyMode {
892 self.resolve_repair_policy_with_metadata(cli_override)
893 .effective_mode
894 }
895
896 pub fn resolve_extension_risk_with_metadata(&self) -> ResolvedExtensionRisk {
903 fn parse_env_f64(name: &str) -> Option<f64> {
904 std::env::var(name).ok().and_then(|v| v.trim().parse().ok())
905 }
906
907 const fn sanitize_alpha(alpha: f64) -> Option<f64> {
908 if alpha.is_finite() {
909 Some(alpha.clamp(1.0e-6, 0.5))
910 } else {
911 None
912 }
913 }
914
915 fn parse_env_u32(name: &str) -> Option<u32> {
916 std::env::var(name).ok().and_then(|v| v.trim().parse().ok())
917 }
918
919 fn parse_env_u64(name: &str) -> Option<u64> {
920 std::env::var(name).ok().and_then(|v| v.trim().parse().ok())
921 }
922
923 let mut settings = crate::extensions::RuntimeRiskConfig::default();
924 let mut source = "default";
925
926 if let Some(cfg) = self.extension_risk.as_ref() {
927 if let Some(enabled) = cfg.enabled {
928 settings.enabled = enabled;
929 source = "config";
930 }
931 if let Some(alpha) = cfg.alpha.and_then(sanitize_alpha) {
932 settings.alpha = alpha;
933 source = "config";
934 }
935 if let Some(window_size) = cfg.window_size {
936 settings.window_size = window_size.clamp(8, 4096) as usize;
937 source = "config";
938 }
939 if let Some(ledger_limit) = cfg.ledger_limit {
940 settings.ledger_limit = ledger_limit.clamp(32, 20_000) as usize;
941 source = "config";
942 }
943 if let Some(timeout_ms) = cfg.decision_timeout_ms {
944 settings.decision_timeout_ms = timeout_ms.clamp(1, 2_000);
945 source = "config";
946 }
947 if let Some(fail_closed) = cfg.fail_closed {
948 settings.fail_closed = fail_closed;
949 source = "config";
950 }
951 if let Some(enforce) = cfg.enforce {
952 settings.enforce = enforce;
953 source = "config";
954 }
955 }
956
957 if let Some(enabled) = parse_env_bool("PI_EXTENSION_RISK_ENABLED") {
958 settings.enabled = enabled;
959 source = "env";
960 }
961 if let Some(alpha) = parse_env_f64("PI_EXTENSION_RISK_ALPHA").and_then(sanitize_alpha) {
962 settings.alpha = alpha;
963 source = "env";
964 }
965 if let Some(window_size) = parse_env_u32("PI_EXTENSION_RISK_WINDOW") {
966 settings.window_size = window_size.clamp(8, 4096) as usize;
967 source = "env";
968 }
969 if let Some(ledger_limit) = parse_env_u32("PI_EXTENSION_RISK_LEDGER_LIMIT") {
970 settings.ledger_limit = ledger_limit.clamp(32, 20_000) as usize;
971 source = "env";
972 }
973 if let Some(timeout_ms) = parse_env_u64("PI_EXTENSION_RISK_DECISION_TIMEOUT_MS") {
974 settings.decision_timeout_ms = timeout_ms.clamp(1, 2_000);
975 source = "env";
976 }
977 if let Some(fail_closed) = parse_env_bool("PI_EXTENSION_RISK_FAIL_CLOSED") {
978 settings.fail_closed = fail_closed;
979 source = "env";
980 }
981 if let Some(enforce) = parse_env_bool("PI_EXTENSION_RISK_ENFORCE") {
982 settings.enforce = enforce;
983 source = "env";
984 }
985
986 ResolvedExtensionRisk { source, settings }
987 }
988
989 pub fn resolve_extension_risk(&self) -> crate::extensions::RuntimeRiskConfig {
990 self.resolve_extension_risk_with_metadata().settings
991 }
992
993 fn emit_queue_mode_diagnostics(&self) {
994 emit_queue_mode_diagnostic("steering_mode", self.steering_mode.as_deref());
995 emit_queue_mode_diagnostic("follow_up_mode", self.follow_up_mode.as_deref());
996 }
997}
998
999fn env_lookup(var: &str) -> Option<String> {
1000 std::env::var(var).ok()
1001}
1002
1003fn parse_env_bool(name: &str) -> Option<bool> {
1004 std::env::var(name).ok().and_then(|v| {
1005 let t = v.trim();
1006 if t.eq_ignore_ascii_case("1")
1007 || t.eq_ignore_ascii_case("true")
1008 || t.eq_ignore_ascii_case("yes")
1009 || t.eq_ignore_ascii_case("on")
1010 {
1011 Some(true)
1012 } else if t.eq_ignore_ascii_case("0")
1013 || t.eq_ignore_ascii_case("false")
1014 || t.eq_ignore_ascii_case("no")
1015 || t.eq_ignore_ascii_case("off")
1016 {
1017 Some(false)
1018 } else {
1019 None
1020 }
1021 })
1022}
1023
1024fn global_dir_from_env<F>(get_env: F) -> PathBuf
1025where
1026 F: Fn(&str) -> Option<String>,
1027{
1028 get_env("PI_CODING_AGENT_DIR").map_or_else(
1029 || {
1030 dirs::home_dir()
1031 .unwrap_or_else(|| PathBuf::from("."))
1032 .join(".pi")
1033 .join("agent")
1034 },
1035 PathBuf::from,
1036 )
1037}
1038
1039fn sessions_dir_from_env<F>(get_env: F, global_dir: &Path) -> PathBuf
1040where
1041 F: Fn(&str) -> Option<String>,
1042{
1043 get_env("PI_SESSIONS_DIR").map_or_else(|| global_dir.join("sessions"), PathBuf::from)
1044}
1045
1046fn package_dir_from_env<F>(get_env: F, global_dir: &Path) -> PathBuf
1047where
1048 F: Fn(&str) -> Option<String>,
1049{
1050 get_env("PI_PACKAGE_DIR").map_or_else(|| global_dir.join("packages"), PathBuf::from)
1051}
1052
1053fn extension_index_path_from_env<F>(get_env: F, global_dir: &Path) -> PathBuf
1054where
1055 F: Fn(&str) -> Option<String>,
1056{
1057 get_env("PI_EXTENSION_INDEX_PATH")
1058 .map_or_else(|| global_dir.join("extension-index.json"), PathBuf::from)
1059}
1060
1061pub(crate) fn parse_queue_mode(mode: Option<&str>) -> Option<QueueMode> {
1062 match mode.map(|s| s.trim().to_ascii_lowercase()).as_deref() {
1063 Some("all") => Some(QueueMode::All),
1064 Some("one-at-a-time") => Some(QueueMode::OneAtATime),
1065 _ => None,
1066 }
1067}
1068
1069pub(crate) fn parse_queue_mode_or_default(mode: Option<&str>) -> QueueMode {
1070 parse_queue_mode(mode).unwrap_or(QueueMode::OneAtATime)
1071}
1072
1073fn emit_queue_mode_diagnostic(setting: &'static str, mode: Option<&str>) {
1074 let Some(mode) = mode else {
1075 return;
1076 };
1077
1078 let trimmed = mode.trim();
1079 if parse_queue_mode(Some(trimmed)).is_some() {
1080 return;
1081 }
1082
1083 tracing::warn!(
1084 setting,
1085 value = trimmed,
1086 "Unknown queue mode; falling back to one-at-a-time"
1087 );
1088}
1089
1090fn merge_compaction(
1091 base: Option<CompactionSettings>,
1092 other: Option<CompactionSettings>,
1093) -> Option<CompactionSettings> {
1094 match (base, other) {
1095 (Some(base), Some(other)) => Some(CompactionSettings {
1096 enabled: other.enabled.or(base.enabled),
1097 reserve_tokens: other.reserve_tokens.or(base.reserve_tokens),
1098 keep_recent_tokens: other.keep_recent_tokens.or(base.keep_recent_tokens),
1099 }),
1100 (None, Some(other)) => Some(other),
1101 (Some(base), None) => Some(base),
1102 (None, None) => None,
1103 }
1104}
1105
1106fn merge_branch_summary(
1107 base: Option<BranchSummarySettings>,
1108 other: Option<BranchSummarySettings>,
1109) -> Option<BranchSummarySettings> {
1110 match (base, other) {
1111 (Some(base), Some(other)) => Some(BranchSummarySettings {
1112 reserve_tokens: other.reserve_tokens.or(base.reserve_tokens),
1113 }),
1114 (None, Some(other)) => Some(other),
1115 (Some(base), None) => Some(base),
1116 (None, None) => None,
1117 }
1118}
1119
1120fn merge_retry(base: Option<RetrySettings>, other: Option<RetrySettings>) -> Option<RetrySettings> {
1121 match (base, other) {
1122 (Some(base), Some(other)) => Some(RetrySettings {
1123 enabled: other.enabled.or(base.enabled),
1124 max_retries: other.max_retries.or(base.max_retries),
1125 base_delay_ms: other.base_delay_ms.or(base.base_delay_ms),
1126 max_delay_ms: other.max_delay_ms.or(base.max_delay_ms),
1127 }),
1128 (None, Some(other)) => Some(other),
1129 (Some(base), None) => Some(base),
1130 (None, None) => None,
1131 }
1132}
1133
1134fn merge_markdown(
1135 base: Option<MarkdownSettings>,
1136 other: Option<MarkdownSettings>,
1137) -> Option<MarkdownSettings> {
1138 match (base, other) {
1139 (Some(base), Some(other)) => Some(MarkdownSettings {
1140 code_block_indent: other.code_block_indent.or(base.code_block_indent),
1141 }),
1142 (None, Some(other)) => Some(other),
1143 (Some(base), None) => Some(base),
1144 (None, None) => None,
1145 }
1146}
1147
1148fn deserialize_code_block_indent_option<'de, D>(
1149 deserializer: D,
1150) -> std::result::Result<Option<u8>, D::Error>
1151where
1152 D: serde::Deserializer<'de>,
1153{
1154 let value = Option::<serde_json::Value>::deserialize(deserializer)?;
1155 match value {
1156 None | Some(serde_json::Value::Null) => Ok(None),
1157 Some(serde_json::Value::Number(number)) => number
1158 .as_u64()
1159 .and_then(|value| u8::try_from(value).ok())
1160 .map(Some)
1161 .ok_or_else(|| serde::de::Error::custom("markdown.codeBlockIndent must fit in u8")),
1162 Some(serde_json::Value::String(indent)) => u8::try_from(indent.chars().count())
1163 .map(Some)
1164 .map_err(|_| serde::de::Error::custom("markdown.codeBlockIndent string is too long")),
1165 Some(_) => Err(serde::de::Error::custom(
1166 "markdown.codeBlockIndent must be a string or integer",
1167 )),
1168 }
1169}
1170
1171fn merge_images(
1172 base: Option<ImageSettings>,
1173 other: Option<ImageSettings>,
1174) -> Option<ImageSettings> {
1175 match (base, other) {
1176 (Some(base), Some(other)) => Some(ImageSettings {
1177 auto_resize: other.auto_resize.or(base.auto_resize),
1178 block_images: other.block_images.or(base.block_images),
1179 }),
1180 (None, Some(other)) => Some(other),
1181 (Some(base), None) => Some(base),
1182 (None, None) => None,
1183 }
1184}
1185
1186fn merge_terminal(
1187 base: Option<TerminalSettings>,
1188 other: Option<TerminalSettings>,
1189) -> Option<TerminalSettings> {
1190 match (base, other) {
1191 (Some(base), Some(other)) => Some(TerminalSettings {
1192 show_images: other.show_images.or(base.show_images),
1193 clear_on_shrink: other.clear_on_shrink.or(base.clear_on_shrink),
1194 }),
1195 (None, Some(other)) => Some(other),
1196 (Some(base), None) => Some(base),
1197 (None, None) => None,
1198 }
1199}
1200
1201fn merge_thinking_budgets(
1202 base: Option<ThinkingBudgets>,
1203 other: Option<ThinkingBudgets>,
1204) -> Option<ThinkingBudgets> {
1205 match (base, other) {
1206 (Some(base), Some(other)) => Some(ThinkingBudgets {
1207 minimal: other.minimal.or(base.minimal),
1208 low: other.low.or(base.low),
1209 medium: other.medium.or(base.medium),
1210 high: other.high.or(base.high),
1211 xhigh: other.xhigh.or(base.xhigh),
1212 }),
1213 (None, Some(other)) => Some(other),
1214 (Some(base), None) => Some(base),
1215 (None, None) => None,
1216 }
1217}
1218
1219fn merge_extension_policy(
1220 base: Option<ExtensionPolicyConfig>,
1221 other: Option<ExtensionPolicyConfig>,
1222) -> Option<ExtensionPolicyConfig> {
1223 match (base, other) {
1224 (Some(base), Some(other)) => Some(ExtensionPolicyConfig {
1225 profile: other.profile.or(base.profile),
1226 default_permissive: other.default_permissive.or(base.default_permissive),
1227 allow_dangerous: other.allow_dangerous.or(base.allow_dangerous),
1228 }),
1229 (None, Some(other)) => Some(other),
1230 (Some(base), None) => Some(base),
1231 (None, None) => None,
1232 }
1233}
1234
1235fn merge_repair_policy(
1236 base: Option<RepairPolicyConfig>,
1237 other: Option<RepairPolicyConfig>,
1238) -> Option<RepairPolicyConfig> {
1239 match (base, other) {
1240 (Some(base), Some(other)) => Some(RepairPolicyConfig {
1241 mode: other.mode.or(base.mode),
1242 }),
1243 (None, Some(other)) => Some(other),
1244 (Some(base), None) => Some(base),
1245 (None, None) => None,
1246 }
1247}
1248
1249fn merge_extension_risk(
1250 base: Option<ExtensionRiskConfig>,
1251 other: Option<ExtensionRiskConfig>,
1252) -> Option<ExtensionRiskConfig> {
1253 match (base, other) {
1254 (Some(base), Some(other)) => Some(ExtensionRiskConfig {
1255 enabled: other.enabled.or(base.enabled),
1256 alpha: other.alpha.or(base.alpha),
1257 window_size: other.window_size.or(base.window_size),
1258 ledger_limit: other.ledger_limit.or(base.ledger_limit),
1259 decision_timeout_ms: other.decision_timeout_ms.or(base.decision_timeout_ms),
1260 fail_closed: other.fail_closed.or(base.fail_closed),
1261 enforce: other.enforce.or(base.enforce),
1262 }),
1263 (None, Some(other)) => Some(other),
1264 (Some(base), None) => Some(base),
1265 (None, None) => None,
1266 }
1267}
1268
1269fn load_settings_json_object(path: &Path) -> Result<Value> {
1270 if !path.exists() {
1271 return Ok(Value::Object(serde_json::Map::new()));
1272 }
1273
1274 let content = std::fs::read_to_string(path)?;
1275 if content.trim().is_empty() {
1276 return Ok(Value::Object(serde_json::Map::new()));
1277 }
1278 let value: Value = serde_json::from_str(&content)?;
1279 if !value.is_object() {
1280 return Err(Error::config(format!(
1281 "Settings file is not a JSON object: {}",
1282 path.display()
1283 )));
1284 }
1285 Ok(value)
1286}
1287
1288fn deep_merge_settings_value(dst: &mut Value, patch: Value) -> Result<()> {
1289 let Value::Object(patch) = patch else {
1290 return Err(Error::validation("Settings patch must be a JSON object"));
1291 };
1292
1293 let dst_obj = dst.as_object_mut().ok_or_else(|| {
1294 Error::config("Internal error: settings root unexpectedly not a JSON object")
1295 })?;
1296
1297 for (key, value) in patch {
1298 if value.is_null() {
1299 dst_obj.remove(&key);
1300 continue;
1301 }
1302
1303 match (dst_obj.get_mut(&key), value) {
1304 (Some(Value::Object(dst_child)), Value::Object(patch_child)) => {
1305 let mut child = Value::Object(std::mem::take(dst_child));
1306 deep_merge_settings_value(&mut child, Value::Object(patch_child))?;
1307 dst_obj.insert(key, child);
1308 }
1309 (_, other) => {
1310 dst_obj.insert(key, other);
1311 }
1312 }
1313 }
1314 Ok(())
1315}
1316
1317fn write_settings_json_atomic(path: &Path, value: &Value) -> Result<()> {
1318 let parent = path.parent().unwrap_or_else(|| Path::new("."));
1319 if !parent.as_os_str().is_empty() {
1320 std::fs::create_dir_all(parent)?;
1321 }
1322
1323 let mut contents = serde_json::to_string_pretty(value)?;
1324 contents.push('\n');
1325
1326 let mut tmp = NamedTempFile::new_in(parent)?;
1327
1328 #[cfg(unix)]
1329 {
1330 use std::os::unix::fs::PermissionsExt as _;
1331 let perms = std::fs::Permissions::from_mode(0o600);
1332 tmp.as_file().set_permissions(perms)?;
1333 }
1334
1335 tmp.write_all(contents.as_bytes())?;
1336 tmp.as_file().sync_all()?;
1337
1338 tmp.persist(path).map_err(|err| {
1339 Error::config(format!(
1340 "Failed to persist settings file to {}: {}",
1341 path.display(),
1342 err.error
1343 ))
1344 })?;
1345 sync_settings_parent_dir(path)?;
1346
1347 Ok(())
1348}
1349
1350fn patch_settings_file(path: &Path, patch: Value) -> Result<Value> {
1351 let _process_guard = settings_persist_lock()
1352 .lock()
1353 .unwrap_or_else(std::sync::PoisonError::into_inner);
1354 let _file_guard = crate::file_lock::DirLock::acquire_for(path, Duration::from_secs(30))
1358 .map_err(|e| Error::config(format!("settings lock: {e}")))?;
1359 let mut settings = load_settings_json_object(path)?;
1360 deep_merge_settings_value(&mut settings, patch)?;
1361 write_settings_json_atomic(path, &settings)?;
1362 Ok(settings)
1363}
1364
1365fn settings_persist_lock() -> &'static Mutex<()> {
1366 static PERSIST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1367 PERSIST_LOCK.get_or_init(|| Mutex::new(()))
1368}
1369
1370#[cfg(unix)]
1371fn sync_settings_parent_dir(path: &Path) -> std::io::Result<()> {
1372 let Some(parent) = path.parent() else {
1373 return Ok(());
1374 };
1375 if parent.as_os_str().is_empty() {
1376 return Ok(());
1377 }
1378 File::open(parent)?.sync_all()
1379}
1380
1381#[cfg(not(unix))]
1382fn sync_settings_parent_dir(_path: &Path) -> std::io::Result<()> {
1383 Ok(())
1384}
1385
1386#[cfg(test)]
1387mod tests {
1388 use super::{
1389 BranchSummarySettings, CompactionSettings, Config, ExtensionPolicyConfig,
1390 ExtensionRiskConfig, ImageSettings, RepairPolicyConfig, RetrySettings, SettingsScope,
1391 TerminalSettings, ThinkingBudgets, deep_merge_settings_value,
1392 extension_index_path_from_env, global_dir_from_env, merge_branch_summary, merge_compaction,
1393 merge_extension_policy, merge_extension_risk, merge_images, merge_repair_policy,
1394 merge_retry, merge_terminal, merge_thinking_budgets, package_dir_from_env,
1395 sessions_dir_from_env,
1396 };
1397 use crate::agent::QueueMode;
1398 use proptest::prelude::*;
1399 use proptest::string::string_regex;
1400 use serde_json::{Value, json};
1401 use std::collections::HashMap;
1402 use std::path::PathBuf;
1403 use std::sync::{Arc, Barrier};
1404 use tempfile::TempDir;
1405
1406 fn write_file(path: &std::path::Path, contents: &str) {
1407 if let Some(parent) = path.parent() {
1408 std::fs::create_dir_all(parent).expect("create parent dir");
1409 }
1410 std::fs::write(path, contents).expect("write file");
1411 }
1412
1413 #[test]
1414 fn load_returns_defaults_when_missing() {
1415 let temp = TempDir::new().expect("create tempdir");
1416 let cwd = temp.path().join("cwd");
1417 let global_dir = temp.path().join("global");
1418
1419 let config = Config::load_with_roots(None, &global_dir, &cwd).expect("load config");
1420 assert!(config.theme.is_none());
1421 assert!(config.default_provider.is_none());
1422 assert!(config.default_model.is_none());
1423 }
1424
1425 #[test]
1426 fn load_respects_pi_config_path_override() {
1427 let temp = TempDir::new().expect("create tempdir");
1428 let cwd = temp.path().join("cwd");
1429 let global_dir = temp.path().join("global");
1430 write_file(
1431 &global_dir.join("settings.json"),
1432 r#"{ "theme": "global", "default_provider": "anthropic" }"#,
1433 );
1434 write_file(
1435 &cwd.join(".pi/settings.json"),
1436 r#"{ "theme": "project", "default_provider": "google" }"#,
1437 );
1438
1439 let override_path = temp.path().join("override.json");
1440 write_file(
1441 &override_path,
1442 r#"{ "theme": "override", "default_provider": "openai" }"#,
1443 );
1444
1445 let config =
1446 Config::load_with_roots(Some(&override_path), &global_dir, &cwd).expect("load config");
1447 assert_eq!(config.theme.as_deref(), Some("override"));
1448 assert_eq!(config.default_provider.as_deref(), Some("openai"));
1449 }
1450
1451 #[test]
1452 fn resolve_config_override_path_anchors_relative_paths_to_supplied_cwd() {
1453 let cwd = PathBuf::from("/tmp/pi-agent");
1454 let relative = PathBuf::from("config/override.json");
1455 let absolute = PathBuf::from("/etc/pi/settings.json");
1456
1457 assert_eq!(
1458 Config::resolve_config_override_path(&relative, &cwd),
1459 cwd.join("config/override.json")
1460 );
1461 assert_eq!(
1462 Config::resolve_config_override_path(&absolute, &cwd),
1463 absolute
1464 );
1465 }
1466
1467 #[test]
1468 fn load_with_roots_resolves_relative_override_against_supplied_cwd() {
1469 let temp = TempDir::new().expect("create tempdir");
1470 let unrelated = temp.path().join("unrelated");
1471 std::fs::create_dir_all(&unrelated).expect("create unrelated dir");
1472
1473 let cwd = temp.path().join("cwd");
1474 let global_dir = temp.path().join("global");
1475 let override_dir = cwd.join("config");
1476 std::fs::create_dir_all(&override_dir).expect("create override dir");
1477 write_file(
1478 &override_dir.join("override.json"),
1479 r#"{ "theme": "override", "default_provider": "openai" }"#,
1480 );
1481
1482 let config = Config::load_with_roots(
1483 Some(std::path::Path::new("config/override.json")),
1484 &global_dir,
1485 &cwd,
1486 )
1487 .expect("load config");
1488
1489 assert_eq!(config.theme.as_deref(), Some("override"));
1490 assert_eq!(config.default_provider.as_deref(), Some("openai"));
1491 }
1492
1493 #[test]
1494 fn load_merges_project_over_global() {
1495 let temp = TempDir::new().expect("create tempdir");
1496 let cwd = temp.path().join("cwd");
1497 let global_dir = temp.path().join("global");
1498 write_file(
1499 &global_dir.join("settings.json"),
1500 r#"{ "default_provider": "anthropic", "default_model": "global", "theme": "global" }"#,
1501 );
1502 write_file(
1503 &cwd.join(".pi/settings.json"),
1504 r#"{ "default_model": "project" }"#,
1505 );
1506
1507 let config = Config::load_with_roots(None, &global_dir, &cwd).expect("load config");
1508 assert_eq!(config.default_provider.as_deref(), Some("anthropic"));
1509 assert_eq!(config.default_model.as_deref(), Some("project"));
1510 assert_eq!(config.theme.as_deref(), Some("global"));
1511 }
1512
1513 #[test]
1514 fn load_merges_nested_structs_instead_of_overriding() {
1515 let temp = TempDir::new().expect("create tempdir");
1516 let cwd = temp.path().join("cwd");
1517 let global_dir = temp.path().join("global");
1518 write_file(
1519 &global_dir.join("settings.json"),
1520 r#"{ "compaction": { "enabled": true, "reserve_tokens": 1234, "keep_recent_tokens": 5678 } }"#,
1521 );
1522 write_file(
1523 &cwd.join(".pi/settings.json"),
1524 r#"{ "compaction": { "enabled": false } }"#,
1525 );
1526
1527 let config = Config::load_with_roots(None, &global_dir, &cwd).expect("load config");
1528 assert!(!config.compaction_enabled());
1529 assert_eq!(config.compaction_reserve_tokens(), 1234);
1530 assert_eq!(config.compaction_keep_recent_tokens(), 5678);
1531 }
1532
1533 #[test]
1534 fn load_parses_retry_images_terminal_and_shell_fields() {
1535 let temp = TempDir::new().expect("create tempdir");
1536 let cwd = temp.path().join("cwd");
1537 let global_dir = temp.path().join("global");
1538 write_file(
1539 &global_dir.join("settings.json"),
1540 r#"{
1541 "compaction": { "enabled": false, "reserve_tokens": 4444, "keep_recent_tokens": 5555 },
1542 "retry": { "enabled": false, "max_retries": 9, "base_delay_ms": 101, "max_delay_ms": 202 },
1543 "images": { "auto_resize": false, "block_images": true },
1544 "terminal": { "show_images": false, "clear_on_shrink": true },
1545 "shell_path": "/bin/zsh",
1546 "shell_command_prefix": "set -euo pipefail"
1547 }"#,
1548 );
1549
1550 let config = Config::load_with_roots(None, &global_dir, &cwd).expect("load config");
1551 assert!(!config.compaction_enabled());
1552 assert_eq!(config.compaction_reserve_tokens(), 4444);
1553 assert_eq!(config.compaction_keep_recent_tokens(), 5555);
1554 assert!(!config.retry_enabled());
1555 assert_eq!(config.retry_max_retries(), 9);
1556 assert_eq!(config.retry_base_delay_ms(), 101);
1557 assert_eq!(config.retry_max_delay_ms(), 202);
1558 assert!(!config.image_auto_resize());
1559 assert!(!config.terminal_show_images());
1560 assert!(config.terminal_clear_on_shrink());
1561 assert_eq!(config.shell_path.as_deref(), Some("/bin/zsh"));
1562 assert_eq!(
1563 config.shell_command_prefix.as_deref(),
1564 Some("set -euo pipefail")
1565 );
1566 }
1567
1568 #[test]
1569 fn accessors_use_expected_defaults() {
1570 let config = Config::default();
1571 assert!(config.compaction_enabled());
1572 assert_eq!(config.compaction_reserve_tokens(), 16384);
1573 assert_eq!(config.compaction_keep_recent_tokens(), 20000);
1574 assert!(config.retry_enabled());
1575 assert_eq!(config.retry_max_retries(), 3);
1576 assert_eq!(config.retry_base_delay_ms(), 2000);
1577 assert_eq!(config.retry_max_delay_ms(), 60000);
1578 assert!(config.image_auto_resize());
1579 assert!(config.terminal_show_images());
1580 assert!(!config.terminal_clear_on_shrink());
1581 assert!(config.shell_path.is_none());
1582 assert!(config.shell_command_prefix.is_none());
1583 }
1584
1585 #[test]
1586 fn directory_helpers_honor_environment_overrides() {
1587 let env = HashMap::from([
1588 ("PI_CODING_AGENT_DIR".to_string(), "env-root".to_string()),
1589 ("PI_SESSIONS_DIR".to_string(), "env-sessions".to_string()),
1590 ("PI_PACKAGE_DIR".to_string(), "env-packages".to_string()),
1591 (
1592 "PI_EXTENSION_INDEX_PATH".to_string(),
1593 "env-extension-index.json".to_string(),
1594 ),
1595 ]);
1596
1597 let global = global_dir_from_env(|key| env.get(key).cloned());
1598 let sessions = sessions_dir_from_env(|key| env.get(key).cloned(), &global);
1599 let package = package_dir_from_env(|key| env.get(key).cloned(), &global);
1600 let extension_index = extension_index_path_from_env(|key| env.get(key).cloned(), &global);
1601
1602 assert_eq!(global, PathBuf::from("env-root"));
1603 assert_eq!(sessions, PathBuf::from("env-sessions"));
1604 assert_eq!(package, PathBuf::from("env-packages"));
1605 assert_eq!(extension_index, PathBuf::from("env-extension-index.json"));
1606 }
1607
1608 #[test]
1609 fn directory_helpers_fall_back_to_global_subdirs_when_unset() {
1610 let env = HashMap::from([("PI_CODING_AGENT_DIR".to_string(), "root-dir".to_string())]);
1611 let global = global_dir_from_env(|key| env.get(key).cloned());
1612 let sessions = sessions_dir_from_env(|key| env.get(key).cloned(), &global);
1613 let package = package_dir_from_env(|key| env.get(key).cloned(), &global);
1614 let extension_index = extension_index_path_from_env(|key| env.get(key).cloned(), &global);
1615
1616 assert_eq!(global, PathBuf::from("root-dir"));
1617 assert_eq!(sessions, PathBuf::from("root-dir").join("sessions"));
1618 assert_eq!(package, PathBuf::from("root-dir").join("packages"));
1619 assert_eq!(
1620 extension_index,
1621 PathBuf::from("root-dir").join("extension-index.json")
1622 );
1623 }
1624
1625 #[test]
1626 fn patch_settings_deep_merges_and_preserves_other_fields() {
1627 let temp = TempDir::new().expect("create tempdir");
1628 let cwd = temp.path().join("cwd");
1629 let global_dir = temp.path().join("global");
1630 let settings_path =
1631 Config::settings_path_with_roots(SettingsScope::Project, &global_dir, &cwd);
1632
1633 write_file(
1634 &settings_path,
1635 r#"{ "theme": "dark", "compaction": { "reserve_tokens": 111 } }"#,
1636 );
1637
1638 let updated = Config::patch_settings_with_roots(
1639 SettingsScope::Project,
1640 &global_dir,
1641 &cwd,
1642 json!({ "compaction": { "enabled": false } }),
1643 )
1644 .expect("patch settings");
1645
1646 assert_eq!(updated, settings_path);
1647
1648 let stored: serde_json::Value =
1649 serde_json::from_str(&std::fs::read_to_string(&settings_path).expect("read"))
1650 .expect("parse");
1651 assert_eq!(stored["theme"], json!("dark"));
1652 assert_eq!(stored["compaction"]["reserve_tokens"], json!(111));
1653 assert_eq!(stored["compaction"]["enabled"], json!(false));
1654 }
1655
1656 #[test]
1657 fn patch_settings_serializes_concurrent_updates() {
1658 let temp = TempDir::new().expect("create tempdir");
1659 let cwd = temp.path().join("cwd");
1660 let global_dir = temp.path().join("global");
1661 let settings_path =
1662 Config::settings_path_with_roots(SettingsScope::Project, &global_dir, &cwd);
1663
1664 write_file(&settings_path, r#"{ "theme": "dark" }"#);
1665
1666 let barrier = Arc::new(Barrier::new(12));
1667 let mut handles = Vec::new();
1668
1669 for idx in 0..12 {
1670 let barrier = Arc::clone(&barrier);
1671 let cwd = cwd.clone();
1672 let global_dir = global_dir.clone();
1673 handles.push(std::thread::spawn(move || {
1674 let mut patch = serde_json::Map::new();
1675 patch.insert(format!("concurrent_{idx}"), json!(idx));
1676 barrier.wait();
1677 Config::patch_settings_with_roots(
1678 SettingsScope::Project,
1679 &global_dir,
1680 &cwd,
1681 Value::Object(patch),
1682 )
1683 .expect("patch settings")
1684 }));
1685 }
1686
1687 for handle in handles {
1688 handle.join().expect("join patch thread");
1689 }
1690
1691 let stored: Value =
1692 serde_json::from_str(&std::fs::read_to_string(&settings_path).expect("read settings"))
1693 .expect("parse settings");
1694 assert_eq!(stored["theme"], json!("dark"));
1695 for idx in 0..12 {
1696 let key = format!("concurrent_{idx}");
1697 let expected = json!(idx);
1698 assert_eq!(stored.get(&key), Some(&expected));
1699 }
1700 }
1701
1702 #[test]
1703 fn patch_settings_writes_with_restrictive_permissions() {
1704 let temp = TempDir::new().expect("create tempdir");
1705 let cwd = temp.path().join("cwd");
1706 let global_dir = temp.path().join("global");
1707 Config::patch_settings_with_roots(
1708 SettingsScope::Project,
1709 &global_dir,
1710 &cwd,
1711 json!({ "default_provider": "anthropic" }),
1712 )
1713 .expect("patch settings");
1714
1715 #[cfg(unix)]
1716 {
1717 use std::os::unix::fs::PermissionsExt as _;
1718 let settings_path =
1719 Config::settings_path_with_roots(SettingsScope::Project, &global_dir, &cwd);
1720 let mode = std::fs::metadata(&settings_path)
1721 .expect("metadata")
1722 .permissions()
1723 .mode()
1724 & 0o777;
1725 assert_eq!(mode, 0o600);
1726 }
1727 }
1728
1729 #[test]
1730 fn patch_settings_to_path_updates_explicit_file() {
1731 let temp = TempDir::new().expect("create tempdir");
1732 let path = temp.path().join("override").join("settings.json");
1733
1734 let updated =
1735 Config::patch_settings_to_path(&path, json!({ "default_provider": "anthropic" }))
1736 .expect("patch settings");
1737
1738 assert_eq!(updated, path);
1739
1740 let stored: serde_json::Value =
1741 serde_json::from_str(&std::fs::read_to_string(&path).expect("read")).expect("parse");
1742 assert_eq!(stored["default_provider"], json!("anthropic"));
1743 }
1744
1745 #[test]
1746 fn patch_settings_applies_theme_and_queue_modes() {
1747 let temp = TempDir::new().expect("create tempdir");
1748 let cwd = temp.path().join("cwd");
1749 let global_dir = temp.path().join("global");
1750
1751 Config::patch_settings_with_roots(
1752 SettingsScope::Project,
1753 &global_dir,
1754 &cwd,
1755 json!({
1756 "theme": "solarized",
1757 "steeringMode": "all",
1758 "followUpMode": "one-at-a-time",
1759 "editor_padding_x": 4,
1760 "show_hardware_cursor": true,
1761 }),
1762 )
1763 .expect("patch settings");
1764
1765 let config = Config::load_with_roots(None, &global_dir, &cwd).expect("load config");
1766 assert_eq!(config.theme.as_deref(), Some("solarized"));
1767 assert_eq!(config.steering_queue_mode(), QueueMode::All);
1768 assert_eq!(config.follow_up_queue_mode(), QueueMode::OneAtATime);
1769 assert_eq!(config.editor_padding_x, Some(4));
1770 assert_eq!(config.show_hardware_cursor, Some(true));
1771 }
1772
1773 #[test]
1774 fn load_with_invalid_pi_config_path_json_returns_error() {
1775 let temp = TempDir::new().expect("create tempdir");
1776 let cwd = temp.path().join("cwd");
1777 let global_dir = temp.path().join("global");
1778
1779 let override_path = temp.path().join("override.json");
1780 write_file(&override_path, "not json");
1781
1782 let result = Config::load_with_roots(Some(&override_path), &global_dir, &cwd);
1783 assert!(result.is_err());
1784 }
1785
1786 #[test]
1787 fn load_with_missing_pi_config_path_file_falls_back_to_defaults() {
1788 let temp = TempDir::new().expect("create tempdir");
1789 let cwd = temp.path().join("cwd");
1790 let global_dir = temp.path().join("global");
1791
1792 let missing_path = temp.path().join("missing.json");
1793 let config =
1794 Config::load_with_roots(Some(&missing_path), &global_dir, &cwd).expect("load config");
1795 assert!(config.theme.is_none());
1796 assert!(config.default_provider.is_none());
1797 assert!(config.default_model.is_none());
1798 }
1799
1800 #[test]
1801 fn queue_mode_accessors_parse_values_and_aliases() {
1802 let temp = TempDir::new().expect("create tempdir");
1803 let cwd = temp.path().join("cwd");
1804 let global_dir = temp.path().join("global");
1805 write_file(
1806 &global_dir.join("settings.json"),
1807 r#"{ "steeringMode": "all", "followUpMode": "one-at-a-time" }"#,
1808 );
1809
1810 let config = Config::load_with_roots(None, &global_dir, &cwd).expect("load config");
1811 assert_eq!(config.steering_queue_mode(), QueueMode::All);
1812 assert_eq!(config.follow_up_queue_mode(), QueueMode::OneAtATime);
1813 }
1814
1815 #[test]
1816 fn queue_mode_accessors_default_on_unknown() {
1817 let temp = TempDir::new().expect("create tempdir");
1818 let cwd = temp.path().join("cwd");
1819 let global_dir = temp.path().join("global");
1820 write_file(
1821 &global_dir.join("settings.json"),
1822 r#"{ "steering_mode": "not-a-real-mode" }"#,
1823 );
1824
1825 let config = Config::load_with_roots(None, &global_dir, &cwd).expect("load config");
1826 assert_eq!(config.steering_queue_mode(), QueueMode::OneAtATime);
1827 assert_eq!(config.follow_up_queue_mode(), QueueMode::OneAtATime);
1828 }
1829
1830 #[test]
1833 fn thinking_budget_returns_defaults_when_unset() {
1834 let config = Config::default();
1835 assert_eq!(config.thinking_budget("minimal"), 1024);
1836 assert_eq!(config.thinking_budget("low"), 2048);
1837 assert_eq!(config.thinking_budget("medium"), 8192);
1838 assert_eq!(config.thinking_budget("high"), 16384);
1839 assert_eq!(config.thinking_budget("xhigh"), 32768);
1840 assert_eq!(config.thinking_budget("unknown-level"), 0);
1841 }
1842
1843 #[test]
1844 fn thinking_budget_uses_custom_values() {
1845 let config = Config {
1846 thinking_budgets: Some(super::ThinkingBudgets {
1847 minimal: Some(100),
1848 low: Some(200),
1849 medium: Some(300),
1850 high: Some(400),
1851 xhigh: Some(500),
1852 }),
1853 ..Config::default()
1854 };
1855 assert_eq!(config.thinking_budget("minimal"), 100);
1856 assert_eq!(config.thinking_budget("low"), 200);
1857 assert_eq!(config.thinking_budget("medium"), 300);
1858 assert_eq!(config.thinking_budget("high"), 400);
1859 assert_eq!(config.thinking_budget("xhigh"), 500);
1860 }
1861
1862 #[test]
1865 fn enable_skill_commands_defaults_to_true() {
1866 let config = Config::default();
1867 assert!(config.enable_skill_commands());
1868 }
1869
1870 #[test]
1871 fn enable_skill_commands_can_be_disabled() {
1872 let config = Config {
1873 enable_skill_commands: Some(false),
1874 ..Config::default()
1875 };
1876 assert!(!config.enable_skill_commands());
1877 }
1878
1879 #[test]
1882 fn branch_summary_reserve_tokens_falls_back_to_compaction() {
1883 let config = Config {
1884 compaction: Some(super::CompactionSettings {
1885 reserve_tokens: Some(9999),
1886 ..Default::default()
1887 }),
1888 ..Config::default()
1889 };
1890 assert_eq!(config.branch_summary_reserve_tokens(), 9999);
1891 }
1892
1893 #[test]
1894 fn branch_summary_reserve_tokens_uses_own_value() {
1895 let config = Config {
1896 compaction: Some(super::CompactionSettings {
1897 reserve_tokens: Some(9999),
1898 ..Default::default()
1899 }),
1900 branch_summary: Some(super::BranchSummarySettings {
1901 reserve_tokens: Some(1111),
1902 }),
1903 ..Config::default()
1904 };
1905 assert_eq!(config.branch_summary_reserve_tokens(), 1111);
1906 }
1907
1908 #[test]
1911 fn deep_merge_null_value_removes_key() {
1912 let temp = TempDir::new().expect("create tempdir");
1913 let cwd = temp.path().join("cwd");
1914 let global_dir = temp.path().join("global");
1915 let settings_path =
1916 Config::settings_path_with_roots(SettingsScope::Project, &global_dir, &cwd);
1917
1918 write_file(
1919 &settings_path,
1920 r#"{ "theme": "dark", "default_provider": "anthropic" }"#,
1921 );
1922
1923 Config::patch_settings_with_roots(
1924 SettingsScope::Project,
1925 &global_dir,
1926 &cwd,
1927 json!({ "theme": null }),
1928 )
1929 .expect("patch");
1930
1931 let stored: serde_json::Value =
1932 serde_json::from_str(&std::fs::read_to_string(&settings_path).expect("read"))
1933 .expect("parse");
1934 assert!(stored.get("theme").is_none());
1935 assert_eq!(stored["default_provider"], json!("anthropic"));
1936 }
1937
1938 #[test]
1941 fn parse_queue_mode_parses_known_values() {
1942 assert_eq!(super::parse_queue_mode(Some("all")), Some(QueueMode::All));
1943 assert_eq!(
1944 super::parse_queue_mode(Some("one-at-a-time")),
1945 Some(QueueMode::OneAtATime)
1946 );
1947 assert_eq!(super::parse_queue_mode(Some("unknown")), None);
1948 assert_eq!(super::parse_queue_mode(None), None);
1949 }
1950
1951 #[test]
1954 fn package_source_serde_string_variant() {
1955 let parsed: super::PackageSource =
1956 serde_json::from_value(json!("npm:my-ext@1.0")).expect("parse");
1957 assert!(matches!(parsed, super::PackageSource::String(s) if s == "npm:my-ext@1.0"));
1958 }
1959
1960 #[test]
1961 fn package_source_serde_detailed_variant() {
1962 let parsed: super::PackageSource = serde_json::from_value(json!({
1963 "source": "git:org/repo",
1964 "local": true,
1965 "kind": "extension"
1966 }))
1967 .expect("parse");
1968 assert!(matches!(
1969 parsed,
1970 super::PackageSource::Detailed { source, local: Some(true), kind: Some(_) } if source == "git:org/repo"
1971 ));
1972 }
1973
1974 #[test]
1977 fn settings_path_global_and_project_differ() {
1978 let global_path = Config::settings_path_with_roots(
1979 SettingsScope::Global,
1980 std::path::Path::new("/global"),
1981 std::path::Path::new("/project"),
1982 );
1983 let project_path = Config::settings_path_with_roots(
1984 SettingsScope::Project,
1985 std::path::Path::new("/global"),
1986 std::path::Path::new("/project"),
1987 );
1988 assert_ne!(global_path, project_path);
1989 assert!(global_path.starts_with("/global"));
1990 assert!(project_path.starts_with("/project"));
1991 }
1992
1993 #[test]
1996 fn settings_scope_equality() {
1997 assert_eq!(SettingsScope::Global, SettingsScope::Global);
1998 assert_eq!(SettingsScope::Project, SettingsScope::Project);
1999 assert_ne!(SettingsScope::Global, SettingsScope::Project);
2000 }
2001
2002 #[test]
2005 fn camel_case_aliases_are_parsed() {
2006 let temp = TempDir::new().expect("create tempdir");
2007 let cwd = temp.path().join("cwd");
2008 let global_dir = temp.path().join("global");
2009 write_file(
2010 &global_dir.join("settings.json"),
2011 r#"{
2012 "hideThinkingBlock": true,
2013 "showHardwareCursor": true,
2014 "quietStartup": true,
2015 "collapseChangelog": true,
2016 "doubleEscapeAction": "quit",
2017 "editorPaddingX": 5,
2018 "autocompleteMaxVisible": 15,
2019 "sessionPickerInput": 2,
2020 "sessionDurability": "throughput"
2021 }"#,
2022 );
2023
2024 let config = Config::load_with_roots(None, &global_dir, &cwd).expect("load config");
2025 assert_eq!(config.hide_thinking_block, Some(true));
2026 assert_eq!(config.show_hardware_cursor, Some(true));
2027 assert_eq!(config.quiet_startup, Some(true));
2028 assert_eq!(config.collapse_changelog, Some(true));
2029 assert_eq!(config.double_escape_action.as_deref(), Some("quit"));
2030 assert_eq!(config.editor_padding_x, Some(5));
2031 assert_eq!(config.autocomplete_max_visible, Some(15));
2032 assert_eq!(config.session_picker_input, Some(2));
2033 assert_eq!(config.session_durability.as_deref(), Some("throughput"));
2034 }
2035
2036 #[test]
2037 fn camel_case_nested_aliases_are_parsed() {
2038 let temp = TempDir::new().expect("create tempdir");
2039 let cwd = temp.path().join("cwd");
2040 let global_dir = temp.path().join("global");
2041 write_file(
2042 &global_dir.join("settings.json"),
2043 r#"{
2044 "queueMode": "all",
2045 "compaction": { "enabled": false, "reserveTokens": 1234, "keepRecentTokens": 5678 },
2046 "branchSummary": { "reserveTokens": 2222 },
2047 "retry": { "enabled": false, "maxRetries": 9, "baseDelayMs": 101, "maxDelayMs": 202 },
2048 "images": { "autoResize": false, "blockImages": true },
2049 "terminal": { "showImages": false, "clearOnShrink": true }
2050 }"#,
2051 );
2052
2053 let config = Config::load_with_roots(None, &global_dir, &cwd).expect("load config");
2054 assert_eq!(config.steering_mode.as_deref(), Some("all"));
2055 assert_eq!(config.steering_queue_mode(), QueueMode::All);
2056 assert!(!config.compaction_enabled());
2057 assert_eq!(config.compaction_reserve_tokens(), 1234);
2058 assert_eq!(config.compaction_keep_recent_tokens(), 5678);
2059 assert_eq!(config.branch_summary_reserve_tokens(), 2222);
2060 assert!(!config.retry_enabled());
2061 assert_eq!(config.retry_max_retries(), 9);
2062 assert_eq!(config.retry_base_delay_ms(), 101);
2063 assert_eq!(config.retry_max_delay_ms(), 202);
2064 assert!(!config.image_auto_resize());
2065 assert!(!config.terminal_show_images());
2066 assert!(config.terminal_clear_on_shrink());
2067 }
2068
2069 #[test]
2070 fn terminal_clear_on_shrink_uses_env_when_unset() {
2071 let config = Config::default();
2072 assert!(config.terminal_clear_on_shrink_with_lookup(|name| {
2073 if name == "PI_CLEAR_ON_SHRINK" {
2074 Some("1".to_string())
2075 } else {
2076 None
2077 }
2078 }));
2079 assert!(!config.terminal_clear_on_shrink_with_lookup(|_| None));
2080 }
2081
2082 #[test]
2083 fn terminal_clear_on_shrink_settings_take_precedence_over_env() {
2084 let config = Config {
2085 terminal: Some(TerminalSettings {
2086 clear_on_shrink: Some(false),
2087 ..TerminalSettings::default()
2088 }),
2089 ..Config::default()
2090 };
2091 assert!(!config.terminal_clear_on_shrink_with_lookup(|name| {
2092 if name == "PI_CLEAR_ON_SHRINK" {
2093 Some("1".to_string())
2094 } else {
2095 None
2096 }
2097 }));
2098 }
2099
2100 #[test]
2103 fn config_serde_roundtrip() {
2104 let config = Config {
2105 theme: Some("dark".to_string()),
2106 default_provider: Some("anthropic".to_string()),
2107 compaction: Some(super::CompactionSettings {
2108 enabled: Some(true),
2109 reserve_tokens: Some(1000),
2110 keep_recent_tokens: Some(2000),
2111 }),
2112 ..Config::default()
2113 };
2114 let json = serde_json::to_string(&config).expect("serialize");
2115 let deserialized: Config = serde_json::from_str(&json).expect("deserialize");
2116 assert_eq!(deserialized.theme.as_deref(), Some("dark"));
2117 assert_eq!(deserialized.default_provider.as_deref(), Some("anthropic"));
2118 assert!(deserialized.compaction_enabled());
2119 }
2120
2121 #[test]
2124 fn load_handles_empty_file_as_default() {
2125 let temp = TempDir::new().expect("create tempdir");
2126 let path = temp.path().join("empty.json");
2127 write_file(&path, "");
2128
2129 let config = Config::load_from_path(&path).expect("load config");
2130 assert!(config.theme.is_none());
2132 }
2133
2134 #[test]
2135 fn merge_thinking_budgets_combines_values() {
2136 let temp = TempDir::new().expect("create tempdir");
2137 let cwd = temp.path().join("cwd");
2138 let global_dir = temp.path().join("global");
2139 write_file(
2140 &global_dir.join("settings.json"),
2141 r#"{ "thinking_budgets": { "minimal": 100, "low": 200 } }"#,
2142 );
2143 write_file(
2144 &cwd.join(".pi/settings.json"),
2145 r#"{ "thinking_budgets": { "minimal": 999 } }"#,
2146 );
2147
2148 let config = Config::load_with_roots(None, &global_dir, &cwd).expect("load");
2149 assert_eq!(config.thinking_budget("minimal"), 999);
2150 assert_eq!(config.thinking_budget("low"), 200);
2151 }
2152
2153 #[test]
2154 fn merge_extension_risk_combines_global_and_project_values() {
2155 let temp = TempDir::new().expect("create tempdir");
2156 let cwd = temp.path().join("cwd");
2157 let global_dir = temp.path().join("global");
2158 write_file(
2159 &global_dir.join("settings.json"),
2160 r#"{
2161 "extensionRisk": {
2162 "enabled": true,
2163 "alpha": 0.2,
2164 "windowSize": 128,
2165 "ledgerLimit": 500,
2166 "decisionTimeoutMs": 100,
2167 "failClosed": false
2168 }
2169 }"#,
2170 );
2171 write_file(
2172 &cwd.join(".pi/settings.json"),
2173 r#"{
2174 "extensionRisk": {
2175 "alpha": 0.05,
2176 "windowSize": 256,
2177 "failClosed": true
2178 }
2179 }"#,
2180 );
2181
2182 let config = Config::load_with_roots(None, &global_dir, &cwd).expect("load");
2183 let risk = config.extension_risk.expect("merged extension risk");
2184 assert_eq!(risk.enabled, Some(true));
2185 assert_eq!(risk.alpha, Some(0.05));
2186 assert_eq!(risk.window_size, Some(256));
2187 assert_eq!(risk.ledger_limit, Some(500));
2188 assert_eq!(risk.decision_timeout_ms, Some(100));
2189 assert_eq!(risk.fail_closed, Some(true));
2190 }
2191
2192 #[test]
2193 fn merge_extension_risk_empty_project_object_keeps_global_values() {
2194 let temp = TempDir::new().expect("create tempdir");
2195 let cwd = temp.path().join("cwd");
2196 let global_dir = temp.path().join("global");
2197 write_file(
2198 &global_dir.join("settings.json"),
2199 r#"{
2200 "extensionRisk": {
2201 "enabled": true,
2202 "alpha": 0.1,
2203 "windowSize": 64,
2204 "ledgerLimit": 200,
2205 "decisionTimeoutMs": 75,
2206 "failClosed": true
2207 }
2208 }"#,
2209 );
2210 write_file(&cwd.join(".pi/settings.json"), r#"{ "extensionRisk": {} }"#);
2211
2212 let config = Config::load_with_roots(None, &global_dir, &cwd).expect("load");
2213 let risk = config.extension_risk.expect("merged extension risk");
2214 assert_eq!(risk.enabled, Some(true));
2215 assert_eq!(risk.alpha, Some(0.1));
2216 assert_eq!(risk.window_size, Some(64));
2217 assert_eq!(risk.ledger_limit, Some(200));
2218 assert_eq!(risk.decision_timeout_ms, Some(75));
2219 assert_eq!(risk.fail_closed, Some(true));
2220 }
2221
2222 #[test]
2223 fn extension_risk_defaults_fail_closed() {
2224 let config = Config::default();
2225 let resolved = config.resolve_extension_risk_with_metadata();
2226 assert_eq!(resolved.source, "default");
2227 assert!(resolved.settings.fail_closed);
2228 }
2229
2230 #[test]
2231 fn extension_risk_config_can_disable_fail_closed_explicitly() {
2232 let config = Config {
2233 extension_risk: Some(ExtensionRiskConfig {
2234 enabled: Some(true),
2235 fail_closed: Some(false),
2236 ..ExtensionRiskConfig::default()
2237 }),
2238 ..Config::default()
2239 };
2240 let resolved = config.resolve_extension_risk_with_metadata();
2241 assert_eq!(resolved.source, "config");
2242 assert!(!resolved.settings.fail_closed);
2243 }
2244
2245 #[test]
2250 fn extension_policy_defaults_to_permissive_behavior() {
2251 let config = Config::default();
2252 let policy = config.resolve_extension_policy(None);
2253 assert_eq!(
2254 policy.mode,
2255 crate::extensions::ExtensionPolicyMode::Permissive
2256 );
2257 assert!(policy.deny_caps.is_empty());
2258 }
2259
2260 #[test]
2261 fn extension_policy_metadata_reports_cli_source() {
2262 let config = Config::default();
2263 let resolved = config.resolve_extension_policy_with_metadata(Some("safe"));
2264 assert_eq!(resolved.profile_source, "cli");
2265 assert_eq!(resolved.requested_profile, "safe");
2266 assert_eq!(resolved.effective_profile, "safe");
2267 assert_eq!(
2268 resolved.policy.mode,
2269 crate::extensions::ExtensionPolicyMode::Strict
2270 );
2271 }
2272
2273 #[test]
2274 fn extension_policy_metadata_unknown_profile_falls_back_to_safe() {
2275 let config = Config::default();
2276 let resolved = config.resolve_extension_policy_with_metadata(Some("unknown-value"));
2277 assert_eq!(resolved.requested_profile, "unknown-value");
2278 assert_eq!(resolved.effective_profile, "safe");
2279 assert_eq!(
2280 resolved.policy.mode,
2281 crate::extensions::ExtensionPolicyMode::Strict
2282 );
2283 }
2284
2285 #[test]
2286 fn extension_policy_metadata_balanced_profile_maps_to_prompt_mode() {
2287 let config = Config::default();
2288 let resolved = config.resolve_extension_policy_with_metadata(Some("balanced"));
2289 assert_eq!(resolved.requested_profile, "balanced");
2290 assert_eq!(resolved.effective_profile, "balanced");
2291 assert_eq!(
2292 resolved.policy.mode,
2293 crate::extensions::ExtensionPolicyMode::Prompt
2294 );
2295 }
2296
2297 #[test]
2298 fn extension_policy_metadata_legacy_standard_alias_maps_to_balanced() {
2299 let config = Config::default();
2300 let resolved = config.resolve_extension_policy_with_metadata(Some("standard"));
2301 assert_eq!(resolved.requested_profile, "standard");
2302 assert_eq!(resolved.effective_profile, "balanced");
2303 assert_eq!(
2304 resolved.policy.mode,
2305 crate::extensions::ExtensionPolicyMode::Prompt
2306 );
2307 }
2308
2309 #[test]
2310 fn extension_policy_default_permissive_toggle_false_restores_safe_behavior() {
2311 let config = Config {
2312 extension_policy: Some(ExtensionPolicyConfig {
2313 profile: None,
2314 default_permissive: Some(false),
2315 allow_dangerous: None,
2316 }),
2317 ..Default::default()
2318 };
2319 let resolved = config.resolve_extension_policy_with_metadata(None);
2320 assert_eq!(resolved.profile_source, "config");
2321 assert_eq!(resolved.requested_profile, "safe");
2322 assert_eq!(resolved.effective_profile, "safe");
2323 assert_eq!(
2324 resolved.policy.mode,
2325 crate::extensions::ExtensionPolicyMode::Strict
2326 );
2327 }
2328
2329 #[test]
2330 fn extension_policy_cli_override_safe() {
2331 let config = Config::default();
2332 let policy = config.resolve_extension_policy(Some("safe"));
2333 assert_eq!(policy.mode, crate::extensions::ExtensionPolicyMode::Strict);
2334 assert!(policy.deny_caps.contains(&"exec".to_string()));
2335 }
2336
2337 #[test]
2338 fn extension_policy_cli_override_permissive() {
2339 let config = Config::default();
2340 let policy = config.resolve_extension_policy(Some("permissive"));
2341 assert_eq!(
2342 policy.mode,
2343 crate::extensions::ExtensionPolicyMode::Permissive
2344 );
2345 }
2346
2347 #[test]
2348 fn extension_policy_from_settings_json() {
2349 let temp = TempDir::new().expect("create tempdir");
2350 let cwd = temp.path().join("cwd");
2351 let global_dir = temp.path().join("global");
2352 write_file(
2353 &global_dir.join("settings.json"),
2354 r#"{ "extensionPolicy": { "profile": "safe" } }"#,
2355 );
2356
2357 let config = Config::load_with_roots(None, &global_dir, &cwd).expect("load");
2358 let policy = config.resolve_extension_policy(None);
2359 assert_eq!(policy.mode, crate::extensions::ExtensionPolicyMode::Strict);
2360 }
2361
2362 #[test]
2363 fn extension_policy_cli_overrides_config() {
2364 let temp = TempDir::new().expect("create tempdir");
2365 let cwd = temp.path().join("cwd");
2366 let global_dir = temp.path().join("global");
2367 write_file(
2368 &global_dir.join("settings.json"),
2369 r#"{ "extensionPolicy": { "profile": "safe" } }"#,
2370 );
2371
2372 let config = Config::load_with_roots(None, &global_dir, &cwd).expect("load");
2373 let policy = config.resolve_extension_policy(Some("permissive"));
2375 assert_eq!(
2376 policy.mode,
2377 crate::extensions::ExtensionPolicyMode::Permissive
2378 );
2379 }
2380
2381 #[test]
2382 fn extension_policy_allow_dangerous_removes_deny() {
2383 let temp = TempDir::new().expect("create tempdir");
2384 let cwd = temp.path().join("cwd");
2385 let global_dir = temp.path().join("global");
2386 write_file(
2387 &global_dir.join("settings.json"),
2388 r#"{ "extensionPolicy": { "defaultPermissive": false, "allowDangerous": true } }"#,
2389 );
2390
2391 let config = Config::load_with_roots(None, &global_dir, &cwd).expect("load");
2392 let policy = config.resolve_extension_policy(None);
2393 assert!(!policy.deny_caps.contains(&"exec".to_string()));
2395 assert!(!policy.deny_caps.contains(&"env".to_string()));
2396 }
2397
2398 #[test]
2399 fn extension_policy_project_overrides_global() {
2400 let temp = TempDir::new().expect("create tempdir");
2401 let cwd = temp.path().join("cwd");
2402 let global_dir = temp.path().join("global");
2403 write_file(
2404 &global_dir.join("settings.json"),
2405 r#"{ "extensionPolicy": { "profile": "safe" } }"#,
2406 );
2407 write_file(
2408 &cwd.join(".pi/settings.json"),
2409 r#"{ "extensionPolicy": { "profile": "permissive" } }"#,
2410 );
2411
2412 let config = Config::load_with_roots(None, &global_dir, &cwd).expect("load");
2413 let policy = config.resolve_extension_policy(None);
2414 assert_eq!(
2415 policy.mode,
2416 crate::extensions::ExtensionPolicyMode::Permissive
2417 );
2418 }
2419
2420 #[test]
2421 fn extension_policy_unknown_profile_defaults_to_safe() {
2422 let config = Config::default();
2423 let policy = config.resolve_extension_policy(Some("unknown-value"));
2424 assert_eq!(policy.mode, crate::extensions::ExtensionPolicyMode::Strict);
2425 }
2426
2427 #[test]
2428 fn extension_policy_deserializes_camel_case() {
2429 let json = r#"{ "extensionPolicy": { "profile": "safe", "defaultPermissive": false, "allowDangerous": false } }"#;
2430 let config: Config = serde_json::from_str(json).expect("parse");
2431 assert_eq!(
2432 config.extension_policy.as_ref().unwrap().profile.as_deref(),
2433 Some("safe")
2434 );
2435 assert_eq!(
2436 config.extension_policy.as_ref().unwrap().default_permissive,
2437 Some(false)
2438 );
2439 assert_eq!(
2440 config.extension_policy.as_ref().unwrap().allow_dangerous,
2441 Some(false)
2442 );
2443 }
2444
2445 #[test]
2446 fn extension_policy_merge_project_overrides_global_partial() {
2447 let temp = TempDir::new().expect("create tempdir");
2448 let cwd = temp.path().join("cwd");
2449 let global_dir = temp.path().join("global");
2450 write_file(
2452 &global_dir.join("settings.json"),
2453 r#"{ "extensionPolicy": { "profile": "safe" } }"#,
2454 );
2455 write_file(
2457 &cwd.join(".pi/settings.json"),
2458 r#"{ "extensionPolicy": { "allowDangerous": true } }"#,
2459 );
2460
2461 let config = Config::load_with_roots(None, &global_dir, &cwd).expect("load");
2462 let ext_config = config.extension_policy.as_ref().unwrap();
2464 assert_eq!(ext_config.profile.as_deref(), Some("safe"));
2465 assert_eq!(ext_config.allow_dangerous, Some(true));
2466 }
2467
2468 #[test]
2473 fn dangerous_opt_in_audit_present_when_allow_dangerous() {
2474 let temp = TempDir::new().expect("create tempdir");
2475 let cwd = temp.path().join("cwd");
2476 let global_dir = temp.path().join("global");
2477 write_file(
2478 &global_dir.join("settings.json"),
2479 r#"{ "extensionPolicy": { "profile": "safe", "allowDangerous": true } }"#,
2480 );
2481
2482 let config = Config::load_with_roots(None, &global_dir, &cwd).expect("load");
2483 let resolved = config.resolve_extension_policy_with_metadata(None);
2484 assert!(resolved.allow_dangerous);
2485 let audit = resolved
2486 .dangerous_opt_in_audit
2487 .expect("audit entry must be present");
2488 assert_eq!(audit.source, "config");
2489 assert_eq!(audit.profile, "safe");
2490 assert!(audit.capabilities_unblocked.contains(&"exec".to_string()));
2491 assert!(audit.capabilities_unblocked.contains(&"env".to_string()));
2492 }
2493
2494 #[test]
2495 fn dangerous_opt_in_audit_absent_when_not_opted_in() {
2496 let config = Config::default();
2497 let resolved = config.resolve_extension_policy_with_metadata(None);
2498 assert!(!resolved.allow_dangerous);
2499 assert!(resolved.dangerous_opt_in_audit.is_none());
2500 }
2501
2502 #[test]
2503 fn dangerous_opt_in_audit_empty_unblocked_when_permissive() {
2504 let temp = TempDir::new().expect("create tempdir");
2505 let cwd = temp.path().join("cwd");
2506 let global_dir = temp.path().join("global");
2507 write_file(
2508 &global_dir.join("settings.json"),
2509 r#"{ "extensionPolicy": { "profile": "permissive", "allowDangerous": true } }"#,
2510 );
2511
2512 let config = Config::load_with_roots(None, &global_dir, &cwd).expect("load");
2513 let resolved = config.resolve_extension_policy_with_metadata(None);
2514 let audit = resolved
2515 .dangerous_opt_in_audit
2516 .expect("audit entry must be present");
2517 assert!(
2518 audit.capabilities_unblocked.is_empty(),
2519 "permissive has no deny_caps to remove"
2520 );
2521 }
2522
2523 #[test]
2524 fn profile_downgrade_safe_roundtrip_verifiable() {
2525 let config = Config::default();
2526 let permissive = config.resolve_extension_policy(Some("permissive"));
2527 let safe = config.resolve_extension_policy(Some("safe"));
2528
2529 assert_eq!(
2530 permissive.evaluate("exec").decision,
2531 crate::extensions::PolicyDecision::Allow
2532 );
2533 assert_eq!(
2534 safe.evaluate("exec").decision,
2535 crate::extensions::PolicyDecision::Deny
2536 );
2537
2538 let check = crate::extensions::ExtensionPolicy::is_valid_downgrade(&permissive, &safe);
2539 assert!(check.is_valid_downgrade);
2540 }
2541
2542 #[test]
2543 fn profile_upgrade_safe_to_permissive_not_downgrade() {
2544 let config = Config::default();
2545 let safe = config.resolve_extension_policy(Some("safe"));
2546 let permissive = config.resolve_extension_policy(Some("permissive"));
2547
2548 let check = crate::extensions::ExtensionPolicy::is_valid_downgrade(&safe, &permissive);
2549 assert!(!check.is_valid_downgrade);
2550 }
2551
2552 #[test]
2553 fn profile_metadata_includes_audit_for_balanced_allow_dangerous() {
2554 let temp = TempDir::new().expect("create tempdir");
2555 let cwd = temp.path().join("cwd");
2556 let global_dir = temp.path().join("global");
2557 write_file(
2558 &global_dir.join("settings.json"),
2559 r#"{ "extensionPolicy": { "profile": "balanced", "allowDangerous": true } }"#,
2560 );
2561
2562 let config = Config::load_with_roots(None, &global_dir, &cwd).expect("load");
2563 let resolved = config.resolve_extension_policy_with_metadata(None);
2564 assert_eq!(resolved.effective_profile, "balanced");
2565 assert!(resolved.allow_dangerous);
2566 let audit = resolved.dangerous_opt_in_audit.unwrap();
2567 assert_eq!(audit.source, "config");
2568 assert_eq!(audit.profile, "balanced");
2569 assert!(audit.capabilities_unblocked.contains(&"exec".to_string()));
2570 }
2571
2572 #[test]
2573 fn explain_policy_runtime_callable_from_config() {
2574 let config = Config::default();
2575 let policy = config.resolve_extension_policy(Some("safe"));
2576 let explanation = policy.explain_effective_policy(None);
2577 assert_eq!(
2578 explanation.mode,
2579 crate::extensions::ExtensionPolicyMode::Strict
2580 );
2581 assert!(!explanation.dangerous_denied.is_empty());
2582 assert!(explanation.dangerous_allowed.is_empty());
2583 }
2584
2585 #[test]
2590 fn repair_policy_defaults_to_suggest() {
2591 let config = Config::default();
2592 let policy = config.resolve_repair_policy(None);
2593 assert_eq!(policy, crate::extensions::RepairPolicyMode::Suggest);
2594 }
2595
2596 #[test]
2597 fn repair_policy_metadata_reports_cli_source() {
2598 let config = Config::default();
2599 let resolved = config.resolve_repair_policy_with_metadata(Some("off"));
2600 assert_eq!(resolved.source, "cli");
2601 assert_eq!(resolved.requested_mode, "off");
2602 assert_eq!(
2603 resolved.effective_mode,
2604 crate::extensions::RepairPolicyMode::Off
2605 );
2606 }
2607
2608 #[test]
2609 fn repair_policy_metadata_unknown_mode_defaults_to_suggest() {
2610 let config = Config::default();
2611 let resolved = config.resolve_repair_policy_with_metadata(Some("unknown"));
2612 assert_eq!(resolved.requested_mode, "unknown");
2613 assert_eq!(
2614 resolved.effective_mode,
2615 crate::extensions::RepairPolicyMode::Suggest
2616 );
2617 }
2618
2619 #[test]
2620 fn repair_policy_from_settings_json() {
2621 let temp = TempDir::new().expect("create tempdir");
2622 let cwd = temp.path().join("cwd");
2623 let global_dir = temp.path().join("global");
2624 write_file(
2625 &global_dir.join("settings.json"),
2626 r#"{ "repairPolicy": { "mode": "auto-safe" } }"#,
2627 );
2628
2629 let config = Config::load_with_roots(None, &global_dir, &cwd).expect("load");
2630 let policy = config.resolve_repair_policy(None);
2631 assert_eq!(policy, crate::extensions::RepairPolicyMode::AutoSafe);
2632 }
2633
2634 #[test]
2635 fn repair_policy_cli_overrides_config() {
2636 let temp = TempDir::new().expect("create tempdir");
2637 let cwd = temp.path().join("cwd");
2638 let global_dir = temp.path().join("global");
2639 write_file(
2640 &global_dir.join("settings.json"),
2641 r#"{ "repairPolicy": { "mode": "off" } }"#,
2642 );
2643
2644 let config = Config::load_with_roots(None, &global_dir, &cwd).expect("load");
2645 let policy = config.resolve_repair_policy(Some("auto-strict"));
2646 assert_eq!(policy, crate::extensions::RepairPolicyMode::AutoStrict);
2647 }
2648
2649 #[test]
2650 fn repair_policy_project_overrides_global() {
2651 let temp = TempDir::new().expect("create tempdir");
2652 let cwd = temp.path().join("cwd");
2653 let global_dir = temp.path().join("global");
2654 write_file(
2655 &global_dir.join("settings.json"),
2656 r#"{ "repairPolicy": { "mode": "off" } }"#,
2657 );
2658 write_file(
2659 &cwd.join(".pi/settings.json"),
2660 r#"{ "repairPolicy": { "mode": "auto-safe" } }"#,
2661 );
2662
2663 let config = Config::load_with_roots(None, &global_dir, &cwd).expect("load");
2664 let policy = config.resolve_repair_policy(None);
2665 assert_eq!(policy, crate::extensions::RepairPolicyMode::AutoSafe);
2666 }
2667
2668 proptest! {
2669 #![proptest_config(ProptestConfig { cases: 128, .. ProptestConfig::default() })]
2670
2671 #[test]
2672 fn proptest_config_merge_prefers_other_for_scalar_fields(
2673 base_theme in prop::option::of(string_regex("[A-Za-z0-9_-]{1,16}").unwrap()),
2674 other_theme in prop::option::of(string_regex("[A-Za-z0-9_-]{1,16}").unwrap()),
2675 base_provider in prop::option::of(string_regex("[A-Za-z0-9_-]{1,16}").unwrap()),
2676 other_provider in prop::option::of(string_regex("[A-Za-z0-9_-]{1,16}").unwrap()),
2677 base_hide_thinking in prop::option::of(any::<bool>()),
2678 other_hide_thinking in prop::option::of(any::<bool>()),
2679 base_autocomplete in prop::option::of(0u16..512u16),
2680 other_autocomplete in prop::option::of(0u16..512u16),
2681 ) {
2682 let base = Config {
2683 theme: base_theme.clone(),
2684 default_provider: base_provider.clone(),
2685 hide_thinking_block: base_hide_thinking,
2686 autocomplete_max_visible: base_autocomplete.map(u32::from),
2687 ..Config::default()
2688 };
2689 let other = Config {
2690 theme: other_theme.clone(),
2691 default_provider: other_provider.clone(),
2692 hide_thinking_block: other_hide_thinking,
2693 autocomplete_max_visible: other_autocomplete.map(u32::from),
2694 ..Config::default()
2695 };
2696
2697 let merged = Config::merge(base, other);
2698 prop_assert_eq!(merged.theme, other_theme.or(base_theme));
2699 prop_assert_eq!(merged.default_provider, other_provider.or(base_provider));
2700 prop_assert_eq!(
2701 merged.hide_thinking_block,
2702 other_hide_thinking.or(base_hide_thinking)
2703 );
2704 prop_assert_eq!(
2705 merged.autocomplete_max_visible,
2706 other_autocomplete
2707 .map(u32::from)
2708 .or_else(|| base_autocomplete.map(u32::from))
2709 );
2710 }
2711
2712 #[test]
2713 fn proptest_merge_extension_risk_prefers_other_fields_when_present(
2714 base_present in any::<bool>(),
2715 other_present in any::<bool>(),
2716 base_enabled in prop::option::of(any::<bool>()),
2717 other_enabled in prop::option::of(any::<bool>()),
2718 base_alpha in prop::option::of(-1.0e6f64..1.0e6f64),
2719 other_alpha in prop::option::of(-1.0e6f64..1.0e6f64),
2720 base_window in prop::option::of(1u16..1024u16),
2721 other_window in prop::option::of(1u16..1024u16),
2722 base_ledger_limit in prop::option::of(1u16..2048u16),
2723 other_ledger_limit in prop::option::of(1u16..2048u16),
2724 base_timeout_ms in prop::option::of(1u16..5000u16),
2725 other_timeout_ms in prop::option::of(1u16..5000u16),
2726 base_fail_closed in prop::option::of(any::<bool>()),
2727 other_fail_closed in prop::option::of(any::<bool>()),
2728 base_enforce in prop::option::of(any::<bool>()),
2729 other_enforce in prop::option::of(any::<bool>()),
2730 ) {
2731 let base = base_present.then_some(ExtensionRiskConfig {
2732 enabled: base_enabled,
2733 alpha: base_alpha,
2734 window_size: base_window.map(u32::from),
2735 ledger_limit: base_ledger_limit.map(u32::from),
2736 decision_timeout_ms: base_timeout_ms.map(u64::from),
2737 fail_closed: base_fail_closed,
2738 enforce: base_enforce,
2739 });
2740 let other = other_present.then_some(ExtensionRiskConfig {
2741 enabled: other_enabled,
2742 alpha: other_alpha,
2743 window_size: other_window.map(u32::from),
2744 ledger_limit: other_ledger_limit.map(u32::from),
2745 decision_timeout_ms: other_timeout_ms.map(u64::from),
2746 fail_closed: other_fail_closed,
2747 enforce: other_enforce,
2748 });
2749
2750 let merged = super::merge_extension_risk(base.clone(), other.clone());
2751 match (base, other, merged) {
2752 (None, None, None) => {}
2753 (Some(base), None, Some(merged)) => {
2754 prop_assert_eq!(merged.enabled, base.enabled);
2755 prop_assert_eq!(merged.alpha, base.alpha);
2756 prop_assert_eq!(merged.window_size, base.window_size);
2757 prop_assert_eq!(merged.ledger_limit, base.ledger_limit);
2758 prop_assert_eq!(merged.decision_timeout_ms, base.decision_timeout_ms);
2759 prop_assert_eq!(merged.fail_closed, base.fail_closed);
2760 prop_assert_eq!(merged.enforce, base.enforce);
2761 }
2762 (None, Some(other), Some(merged)) => {
2763 prop_assert_eq!(merged.enabled, other.enabled);
2764 prop_assert_eq!(merged.alpha, other.alpha);
2765 prop_assert_eq!(merged.window_size, other.window_size);
2766 prop_assert_eq!(merged.ledger_limit, other.ledger_limit);
2767 prop_assert_eq!(merged.decision_timeout_ms, other.decision_timeout_ms);
2768 prop_assert_eq!(merged.fail_closed, other.fail_closed);
2769 prop_assert_eq!(merged.enforce, other.enforce);
2770 }
2771 (Some(base), Some(other), Some(merged)) => {
2772 prop_assert_eq!(merged.enabled, other.enabled.or(base.enabled));
2773 prop_assert_eq!(merged.alpha, other.alpha.or(base.alpha));
2774 prop_assert_eq!(merged.window_size, other.window_size.or(base.window_size));
2775 prop_assert_eq!(merged.ledger_limit, other.ledger_limit.or(base.ledger_limit));
2776 prop_assert_eq!(
2777 merged.decision_timeout_ms,
2778 other.decision_timeout_ms.or(base.decision_timeout_ms)
2779 );
2780 prop_assert_eq!(merged.fail_closed, other.fail_closed.or(base.fail_closed));
2781 prop_assert_eq!(merged.enforce, other.enforce.or(base.enforce));
2782 }
2783 _ => assert!(false, "merge_extension_risk must preserve Option-shape semantics"),
2784 }
2785 }
2786
2787 #[test]
2788 fn proptest_deep_merge_settings_value_scalar_and_null_patch_semantics(
2789 base_entries in prop::collection::hash_map(
2790 string_regex("[a-z][a-z0-9_]{0,10}").unwrap(),
2791 any::<i64>(),
2792 0..16
2793 ),
2794 patch_entries in prop::collection::hash_map(
2795 string_regex("[a-z][a-z0-9_]{0,10}").unwrap(),
2796 prop::option::of(any::<i64>()),
2797 0..16
2798 ),
2799 ) {
2800 let mut dst = Value::Object(
2801 base_entries
2802 .iter()
2803 .map(|(key, value)| (key.clone(), json!(*value)))
2804 .collect(),
2805 );
2806 let patch = Value::Object(
2807 patch_entries
2808 .iter()
2809 .map(|(key, value)| {
2810 (
2811 key.clone(),
2812 value.map_or(Value::Null, |number| json!(number)),
2813 )
2814 })
2815 .collect(),
2816 );
2817
2818 super::deep_merge_settings_value(&mut dst, patch).expect("merge should succeed");
2819 let dst_obj = dst.as_object().expect("merged value should stay an object");
2820
2821 let mut expected = base_entries;
2822 for (key, value) in &patch_entries {
2823 match value {
2824 Some(number) => {
2825 expected.insert(key.clone(), *number);
2826 }
2827 None => {
2828 expected.remove(key);
2829 }
2830 }
2831 }
2832
2833 prop_assert_eq!(dst_obj.len(), expected.len());
2834 for (key, expected_value) in expected {
2835 prop_assert_eq!(dst_obj.get(&key), Some(&json!(expected_value)));
2836 }
2837 }
2838
2839 #[test]
2840 fn proptest_deep_merge_settings_value_nested_object_patch_semantics(
2841 base_nested in prop::collection::hash_map(
2842 string_regex("[a-z][a-z0-9_]{0,10}").unwrap(),
2843 any::<i64>(),
2844 0..12
2845 ),
2846 patch_nested in prop::collection::hash_map(
2847 string_regex("[a-z][a-z0-9_]{0,10}").unwrap(),
2848 prop::option::of(any::<i64>()),
2849 0..12
2850 ),
2851 preserve_value in any::<i64>(),
2852 ) {
2853 let mut dst = json!({
2854 "nested": Value::Object(
2855 base_nested
2856 .iter()
2857 .map(|(key, value)| (key.clone(), json!(*value)))
2858 .collect()
2859 ),
2860 "preserve": preserve_value
2861 });
2862
2863 let patch = json!({
2864 "nested": Value::Object(
2865 patch_nested
2866 .iter()
2867 .map(|(key, value)| {
2868 (
2869 key.clone(),
2870 value.map_or(Value::Null, |number| json!(number)),
2871 )
2872 })
2873 .collect()
2874 )
2875 });
2876
2877 super::deep_merge_settings_value(&mut dst, patch).expect("nested merge should succeed");
2878
2879 let mut expected_nested = base_nested;
2880 for (key, value) in &patch_nested {
2881 match value {
2882 Some(number) => {
2883 expected_nested.insert(key.clone(), *number);
2884 }
2885 None => {
2886 expected_nested.remove(key);
2887 }
2888 }
2889 }
2890
2891 let nested = dst
2892 .get("nested")
2893 .and_then(Value::as_object)
2894 .expect("nested key should stay an object");
2895 prop_assert_eq!(nested.len(), expected_nested.len());
2896 for (key, expected_value) in expected_nested {
2897 prop_assert_eq!(nested.get(&key), Some(&json!(expected_value)));
2898 }
2899 prop_assert_eq!(dst.get("preserve"), Some(&json!(preserve_value)));
2900 }
2901
2902 #[test]
2903 fn proptest_deep_merge_settings_value_rejects_non_object_patch(
2904 patch in prop_oneof![
2905 any::<bool>().prop_map(Value::Bool),
2906 any::<i64>().prop_map(Value::from),
2907 Just(Value::Null),
2908 prop::collection::vec(any::<i64>(), 0..8).prop_map(|values| json!(values)),
2909 ],
2910 ) {
2911 let mut dst = json!({});
2912 let err = super::deep_merge_settings_value(&mut dst, patch)
2913 .expect_err("non-object patch must fail closed");
2914 prop_assert!(
2915 err.to_string().contains("Settings patch must be a JSON object"),
2916 "unexpected error: {err}"
2917 );
2918 }
2919
2920 #[test]
2921 fn proptest_extension_risk_alpha_finite_values_clamp(alpha in -1.0e6f64..1.0e6f64) {
2922 let config = Config {
2923 extension_risk: Some(ExtensionRiskConfig {
2924 alpha: Some(alpha),
2925 ..ExtensionRiskConfig::default()
2926 }),
2927 ..Config::default()
2928 };
2929
2930 let resolved = config.resolve_extension_risk_with_metadata();
2931 let env_alpha = std::env::var("PI_EXTENSION_RISK_ALPHA")
2932 .ok()
2933 .and_then(|raw| raw.trim().parse::<f64>().ok())
2934 .and_then(|parsed| parsed.is_finite().then_some(parsed.clamp(1.0e-6, 0.5)));
2935
2936 let expected_alpha = env_alpha.unwrap_or_else(|| alpha.clamp(1.0e-6, 0.5));
2938 prop_assert!((resolved.settings.alpha - expected_alpha).abs() <= f64::EPSILON);
2939 if env_alpha.is_some() {
2940 prop_assert_eq!(resolved.source, "env");
2941 }
2942 }
2943
2944 #[test]
2945 fn proptest_config_deserializes_extension_risk_alpha_values(alpha in -1.0e6f64..1.0e6f64) {
2946 let parsed: Config = serde_json::from_value(json!({
2947 "extensionRisk": {
2948 "alpha": alpha
2949 }
2950 }))
2951 .expect("config with finite alpha should deserialize");
2952
2953 prop_assert_eq!(
2954 parsed.extension_risk.as_ref().and_then(|risk| risk.alpha),
2955 Some(alpha)
2956 );
2957 }
2958
2959 #[test]
2960 fn proptest_extension_risk_alpha_non_finite_values_are_ignored(
2961 alpha in prop_oneof![Just(f64::NAN), Just(f64::INFINITY), Just(f64::NEG_INFINITY)]
2962 ) {
2963 let config = Config {
2964 extension_risk: Some(ExtensionRiskConfig {
2965 alpha: Some(alpha),
2966 ..ExtensionRiskConfig::default()
2967 }),
2968 ..Config::default()
2969 };
2970
2971 let baseline = Config::default().resolve_extension_risk_with_metadata();
2972 let resolved = config.resolve_extension_risk_with_metadata();
2973 prop_assert!((resolved.settings.alpha - baseline.settings.alpha).abs() <= f64::EPSILON);
2976 prop_assert_eq!(resolved.source, baseline.source);
2977 }
2978
2979 #[test]
2980 fn proptest_parse_queue_mode_unknown_values_return_none(raw in string_regex("[A-Za-z0-9_-]{1,24}").unwrap()) {
2981 let lowered = raw.to_ascii_lowercase();
2982 prop_assume!(lowered != "all" && lowered != "one-at-a-time");
2983 prop_assert_eq!(super::parse_queue_mode(Some(&raw)), None);
2984 }
2985
2986 #[test]
2987 fn proptest_extension_policy_unknown_profile_fails_closed(raw in string_regex("[A-Za-z0-9_-]{1,24}").unwrap()) {
2988 let lowered = raw.to_ascii_lowercase();
2989 prop_assume!(
2990 lowered != "safe"
2991 && lowered != "balanced"
2992 && lowered != "standard"
2993 && lowered != "permissive"
2994 );
2995
2996 let config: Config = serde_json::from_value(json!({
2997 "extensionPolicy": {
2998 "profile": raw
2999 }
3000 }))
3001 .expect("config should deserialize");
3002 let resolved = config.resolve_extension_policy_with_metadata(Some(&raw));
3005 prop_assert_eq!(resolved.effective_profile, "safe");
3006 prop_assert_eq!(
3007 resolved.policy.mode,
3008 crate::extensions::ExtensionPolicyMode::Strict
3009 );
3010 }
3011 }
3012
3013 #[test]
3016 fn markdown_code_block_indent_deserializes() {
3017 let json = r#"{"markdown":{"codeBlockIndent":4}}"#;
3018 let config: Config = serde_json::from_str(json).unwrap();
3019 assert_eq!(config.markdown.as_ref().unwrap().code_block_indent, Some(4));
3020 }
3021
3022 #[test]
3023 fn markdown_code_block_indent_accepts_legacy_string() {
3024 let json = r#"{"markdown":{"codeBlockIndent":" "}}"#;
3025 let config: Config = serde_json::from_str(json).unwrap();
3026 assert_eq!(config.markdown.as_ref().unwrap().code_block_indent, Some(4));
3027 }
3028
3029 #[test]
3030 fn markdown_code_block_indent_snake_case_alias() {
3031 let json = r#"{"markdown":{"code_block_indent":6}}"#;
3032 let config: Config = serde_json::from_str(json).unwrap();
3033 assert_eq!(config.markdown.as_ref().unwrap().code_block_indent, Some(6));
3034 }
3035
3036 #[test]
3037 fn markdown_code_block_indent_absent() {
3038 let json = r"{}";
3039 let config: Config = serde_json::from_str(json).unwrap();
3040 assert!(config.markdown.is_none());
3041 assert_eq!(config.markdown_code_block_indent(), 2);
3042 }
3043
3044 #[test]
3045 fn markdown_code_block_indent_zero() {
3046 let json = r#"{"markdown":{"codeBlockIndent":0}}"#;
3047 let config: Config = serde_json::from_str(json).unwrap();
3048 assert_eq!(config.markdown.as_ref().unwrap().code_block_indent, Some(0));
3049 }
3050
3051 #[test]
3052 fn markdown_merge_prefers_other() {
3053 let base: Config = serde_json::from_str(r#"{"markdown":{"codeBlockIndent":2}}"#).unwrap();
3054 let other: Config = serde_json::from_str(r#"{"markdown":{"codeBlockIndent":4}}"#).unwrap();
3055 let merged = Config::merge(base, other);
3056 assert_eq!(merged.markdown.as_ref().unwrap().code_block_indent, Some(4));
3057 }
3058
3059 #[test]
3062 fn check_for_updates_default_is_true() {
3063 let config: Config = serde_json::from_str("{}").unwrap();
3064 assert!(config.should_check_for_updates());
3065 }
3066
3067 #[test]
3068 fn check_for_updates_explicit_false() {
3069 let json = r#"{"checkForUpdates": false}"#;
3070 let config: Config = serde_json::from_str(json).unwrap();
3071 assert!(!config.should_check_for_updates());
3072 }
3073
3074 #[test]
3075 fn check_for_updates_explicit_true() {
3076 let json = r#"{"check_for_updates": true}"#;
3077 let config: Config = serde_json::from_str(json).unwrap();
3078 assert!(config.should_check_for_updates());
3079 }
3080
3081 mod merge_proptests {
3084 use super::*;
3085
3086 proptest! {
3093 #[test]
3098 fn compaction_none_none_is_none(() in Just(())) {
3099 assert!(merge_compaction(None, None).is_none());
3100 }
3101
3102 #[test]
3103 fn compaction_right_identity(
3104 enabled in prop::option::of(any::<bool>()),
3105 reserve in prop::option::of(1u32..100_000),
3106 keep in prop::option::of(1u32..100_000),
3107 ) {
3108 let base = CompactionSettings { enabled, reserve_tokens: reserve, keep_recent_tokens: keep };
3109 let result = merge_compaction(Some(base.clone()), None).unwrap();
3110 assert_eq!(result.enabled, base.enabled);
3111 assert_eq!(result.reserve_tokens, base.reserve_tokens);
3112 assert_eq!(result.keep_recent_tokens, base.keep_recent_tokens);
3113 }
3114
3115 #[test]
3116 fn compaction_left_identity(
3117 enabled in prop::option::of(any::<bool>()),
3118 reserve in prop::option::of(1u32..100_000),
3119 keep in prop::option::of(1u32..100_000),
3120 ) {
3121 let other = CompactionSettings { enabled, reserve_tokens: reserve, keep_recent_tokens: keep };
3122 let result = merge_compaction(None, Some(other.clone())).unwrap();
3123 assert_eq!(result.enabled, other.enabled);
3124 assert_eq!(result.reserve_tokens, other.reserve_tokens);
3125 assert_eq!(result.keep_recent_tokens, other.keep_recent_tokens);
3126 }
3127
3128 #[test]
3129 fn compaction_other_overrides_base(
3130 b_en in prop::option::of(any::<bool>()),
3131 b_res in prop::option::of(1u32..100_000),
3132 o_en in prop::option::of(any::<bool>()),
3133 o_res in prop::option::of(1u32..100_000),
3134 ) {
3135 let base = CompactionSettings { enabled: b_en, reserve_tokens: b_res, keep_recent_tokens: None };
3136 let other = CompactionSettings { enabled: o_en, reserve_tokens: o_res, keep_recent_tokens: None };
3137 let result = merge_compaction(Some(base), Some(other)).unwrap();
3138 assert_eq!(result.enabled, o_en.or(b_en));
3139 assert_eq!(result.reserve_tokens, o_res.or(b_res));
3140 }
3141
3142 #[test]
3147 fn branch_summary_none_none_is_none(() in Just(())) {
3148 assert!(merge_branch_summary(None, None).is_none());
3149 }
3150
3151 #[test]
3152 fn branch_summary_other_overrides(
3153 b_res in prop::option::of(1u32..100_000),
3154 o_res in prop::option::of(1u32..100_000),
3155 ) {
3156 let base = BranchSummarySettings { reserve_tokens: b_res };
3157 let other = BranchSummarySettings { reserve_tokens: o_res };
3158 let result = merge_branch_summary(Some(base), Some(other)).unwrap();
3159 assert_eq!(result.reserve_tokens, o_res.or(b_res));
3160 }
3161
3162 #[test]
3167 fn retry_none_none_is_none(() in Just(())) {
3168 assert!(merge_retry(None, None).is_none());
3169 }
3170
3171 #[test]
3172 fn retry_other_overrides(
3173 b_en in prop::option::of(any::<bool>()),
3174 b_max in prop::option::of(1u32..10),
3175 o_en in prop::option::of(any::<bool>()),
3176 o_base_delay in prop::option::of(100u32..5000),
3177 ) {
3178 let base = RetrySettings { enabled: b_en, max_retries: b_max, base_delay_ms: None, max_delay_ms: None };
3179 let other = RetrySettings { enabled: o_en, max_retries: None, base_delay_ms: o_base_delay, max_delay_ms: None };
3180 let result = merge_retry(Some(base), Some(other)).unwrap();
3181 assert_eq!(result.enabled, o_en.or(b_en));
3182 assert_eq!(result.max_retries, b_max); assert_eq!(result.base_delay_ms, o_base_delay); }
3185
3186 #[test]
3191 fn images_none_none_is_none(() in Just(())) {
3192 assert!(merge_images(None, None).is_none());
3193 }
3194
3195 #[test]
3196 fn images_other_overrides(
3197 b_resize in prop::option::of(any::<bool>()),
3198 b_block in prop::option::of(any::<bool>()),
3199 o_resize in prop::option::of(any::<bool>()),
3200 o_block in prop::option::of(any::<bool>()),
3201 ) {
3202 let base = ImageSettings { auto_resize: b_resize, block_images: b_block };
3203 let other = ImageSettings { auto_resize: o_resize, block_images: o_block };
3204 let result = merge_images(Some(base), Some(other)).unwrap();
3205 assert_eq!(result.auto_resize, o_resize.or(b_resize));
3206 assert_eq!(result.block_images, o_block.or(b_block));
3207 }
3208
3209 #[test]
3214 fn terminal_none_none_is_none(() in Just(())) {
3215 assert!(merge_terminal(None, None).is_none());
3216 }
3217
3218 #[test]
3219 fn terminal_other_overrides(
3220 b_show in prop::option::of(any::<bool>()),
3221 b_clear in prop::option::of(any::<bool>()),
3222 o_show in prop::option::of(any::<bool>()),
3223 o_clear in prop::option::of(any::<bool>()),
3224 ) {
3225 let base = TerminalSettings { show_images: b_show, clear_on_shrink: b_clear };
3226 let other = TerminalSettings { show_images: o_show, clear_on_shrink: o_clear };
3227 let result = merge_terminal(Some(base), Some(other)).unwrap();
3228 assert_eq!(result.show_images, o_show.or(b_show));
3229 assert_eq!(result.clear_on_shrink, o_clear.or(b_clear));
3230 }
3231
3232 #[test]
3237 fn thinking_budgets_none_none_is_none(() in Just(())) {
3238 assert!(merge_thinking_budgets(None, None).is_none());
3239 }
3240
3241 #[test]
3242 fn thinking_budgets_other_overrides(
3243 b_min in prop::option::of(1u32..65536),
3244 b_low in prop::option::of(1u32..65536),
3245 o_med in prop::option::of(1u32..65536),
3246 o_high in prop::option::of(1u32..65536),
3247 ) {
3248 let base = ThinkingBudgets { minimal: b_min, low: b_low, medium: None, high: None, xhigh: None };
3249 let other = ThinkingBudgets { minimal: None, low: None, medium: o_med, high: o_high, xhigh: None };
3250 let result = merge_thinking_budgets(Some(base), Some(other)).unwrap();
3251 assert_eq!(result.minimal, b_min); assert_eq!(result.low, b_low); assert_eq!(result.medium, o_med); assert_eq!(result.high, o_high); assert_eq!(result.xhigh, None); }
3257
3258 #[test]
3263 fn extension_policy_none_none_is_none(() in Just(())) {
3264 assert!(merge_extension_policy(None, None).is_none());
3265 }
3266
3267 #[test]
3268 fn extension_policy_other_overrides(
3269 b_profile in prop::option::of(string_regex("[a-z]{3,10}").unwrap()),
3270 b_default_permissive in prop::option::of(any::<bool>()),
3271 b_danger in prop::option::of(any::<bool>()),
3272 o_profile in prop::option::of(string_regex("[a-z]{3,10}").unwrap()),
3273 o_default_permissive in prop::option::of(any::<bool>()),
3274 o_danger in prop::option::of(any::<bool>()),
3275 ) {
3276 let base = ExtensionPolicyConfig {
3277 profile: b_profile.clone(),
3278 default_permissive: b_default_permissive,
3279 allow_dangerous: b_danger,
3280 };
3281 let other = ExtensionPolicyConfig {
3282 profile: o_profile.clone(),
3283 default_permissive: o_default_permissive,
3284 allow_dangerous: o_danger,
3285 };
3286 let result = merge_extension_policy(Some(base), Some(other)).unwrap();
3287 assert_eq!(result.profile, o_profile.or(b_profile));
3288 assert_eq!(
3289 result.default_permissive,
3290 o_default_permissive.or(b_default_permissive)
3291 );
3292 assert_eq!(result.allow_dangerous, o_danger.or(b_danger));
3293 }
3294
3295 #[test]
3300 fn repair_policy_none_none_is_none(() in Just(())) {
3301 assert!(merge_repair_policy(None, None).is_none());
3302 }
3303
3304 #[test]
3305 fn repair_policy_other_overrides(
3306 b_mode in prop::option::of(string_regex("[a-z-]{3,12}").unwrap()),
3307 o_mode in prop::option::of(string_regex("[a-z-]{3,12}").unwrap()),
3308 ) {
3309 let base = RepairPolicyConfig { mode: b_mode.clone() };
3310 let other = RepairPolicyConfig { mode: o_mode.clone() };
3311 let result = merge_repair_policy(Some(base), Some(other)).unwrap();
3312 assert_eq!(result.mode, o_mode.or(b_mode));
3313 }
3314
3315 #[test]
3320 fn extension_risk_none_none_is_none(() in Just(())) {
3321 assert!(merge_extension_risk(None, None).is_none());
3322 }
3323
3324 #[test]
3325 fn extension_risk_other_overrides(
3326 b_en in prop::option::of(any::<bool>()),
3327 b_window in prop::option::of(1u32..1000),
3328 o_en in prop::option::of(any::<bool>()),
3329 o_timeout in prop::option::of(1u64..60_000),
3330 ) {
3331 let base = ExtensionRiskConfig {
3332 enabled: b_en, alpha: None, window_size: b_window,
3333 ledger_limit: None, decision_timeout_ms: None,
3334 fail_closed: None, enforce: None,
3335 };
3336 let other = ExtensionRiskConfig {
3337 enabled: o_en, alpha: None, window_size: None,
3338 ledger_limit: None, decision_timeout_ms: o_timeout,
3339 fail_closed: None, enforce: None,
3340 };
3341 let result = merge_extension_risk(Some(base), Some(other)).unwrap();
3342 assert_eq!(result.enabled, o_en.or(b_en));
3343 assert_eq!(result.window_size, b_window); assert_eq!(result.decision_timeout_ms, o_timeout); }
3346 }
3347
3348 proptest! {
3353 #[test]
3354 fn deep_merge_null_deletes_key(key in "[a-z]{1,8}", val in "[a-z]{1,12}") {
3355 let mut dst = json!({ &key: val });
3356 deep_merge_settings_value(&mut dst, json!({ &key: null })).unwrap();
3357 assert!(dst.get(&key).is_none());
3358 }
3359
3360 #[test]
3361 fn deep_merge_leaf_replaces(key in "[a-z]{1,8}", old in 0i64..100, new in 100i64..200) {
3362 let mut dst = json!({ &key: old });
3363 deep_merge_settings_value(&mut dst, json!({ &key: new })).unwrap();
3364 assert_eq!(dst[&key], json!(new));
3365 }
3366
3367 #[test]
3368 fn deep_merge_nested_preserves_siblings(
3369 parent in "[a-z]{1,6}",
3370 child_a in "[a-z]{1,6}",
3371 child_b in "[a-z]{1,6}",
3372 val_a in 0i64..100,
3373 val_b in 0i64..100,
3374 val_new in 100i64..200,
3375 ) {
3376 if child_a != child_b {
3377 let mut dst = json!({ &parent: { &child_a: val_a, &child_b: val_b } });
3378 deep_merge_settings_value(
3379 &mut dst,
3380 json!({ &parent: { &child_a: val_new } }),
3381 ).unwrap();
3382 assert_eq!(dst[&parent][&child_a], json!(val_new));
3383 assert_eq!(dst[&parent][&child_b], json!(val_b));
3384 }
3385 }
3386
3387 #[test]
3388 fn deep_merge_non_object_patch_rejected(val in 0i64..1000) {
3389 let mut dst = json!({});
3390 assert!(deep_merge_settings_value(&mut dst, json!(val)).is_err());
3391 }
3392
3393 #[test]
3394 fn deep_merge_idempotent(key in "[a-z]{1,6}", val in "[a-z]{1,10}") {
3395 let patch = json!({ &key: &val });
3396 let mut dst1 = json!({});
3397 let mut dst2 = json!({});
3398 deep_merge_settings_value(&mut dst1, patch.clone()).unwrap();
3399 deep_merge_settings_value(&mut dst2, patch.clone()).unwrap();
3400 deep_merge_settings_value(&mut dst2, patch).unwrap();
3401 assert_eq!(dst1, dst2);
3402 }
3403 }
3404 }
3405}