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