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