Skip to main content

pi/
config.rs

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