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