Skip to main content

pi/
config.rs

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