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