Skip to main content

pi/
config.rs

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