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