Skip to main content

pi/
config.rs

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